From 6c909b0f14c9fe0fb4a4e923ae26faedd71ef7f3 Mon Sep 17 00:00:00 2001 From: hotragn Date: Tue, 25 Aug 2026 15:44:10 -0400 Subject: [PATCH 01/13] Record why a message was not routed, not only where it went MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `channel.routed` carried `fallback: true` for two unrelated situations: the router answering that no specialist was a confident match, which is the feature working, and the router not answering at all, which is an endpoint that is down. One boolean, one sentence, no way to tell them apart. That has already cost something. #178 found the intent router appending `/v1` to an `OPENAI_BASE_URL` that already carried one, so every call 404'd on every deployment that set the variable, for an unknown period. Its own changelog entry says untagged messages "silently stopped being routed and nothing said why". The URL is fixed; nothing was added that would have shown it. The decision now carries `undecided`: `unreachable`, `unparsed`, `off-roster`, `unconfident`, `one-candidate`, or null when the router decided. Named values rather than prose, because the useful question is how often, and a count needs something to group by. It goes on the audit row beside `fallback`, which is where a deployment can ask. Two corrections came out of writing the tests. The reach-based answer discarded the cause. Landing on the only coworker that can reach the system a message names is a good outcome, and it says nothing about whether the router answered — so a router down for a week produced rows reading exactly like reach-based routing working as intended. The cause now survives that path, which is the case the field mostly exists for. An answer with no JSON in it was recorded as off-roster. A model replying in prose fell through as `{}`, reached the roster check, matched nothing, and was filed as the router naming a coworker that does not exist — pointing whoever reads it at their roster when what is wrong is the model's format. It is reported as unparsed now. Nothing changes about where a message goes. Every routing decision is the same decision it was; only the record of it says more. Twelve tests added, 33 pass in the two routing files. The one asserting the reach path keeps the cause is the one worth keeping. --- CHANGELOG.md | 25 +++++ server/src/routing/classify.ts | 97 ++++++++++++++++++-- server/src/routing/routes.ts | 29 +++++- server/tests/routing-classify.test.ts | 127 ++++++++++++++++++++++++++ server/tests/routing-routes.test.ts | 51 ++++++++++- 5 files changed, 315 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c58967..e1f3a28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A routing trail says why a message was not routed, not only that it was not + +Every untagged message writes a `channel.routed` row, and that row carried `fallback: true` for two +completely different situations: the router answering honestly that no specialist was a confident +match, which is the feature working, and the router not answering at all, which is an endpoint that is +down. Both read identically, so a deployment whose router had stopped working looked like one whose +messages were simply hard to route. + +That is not hypothetical. The intent router spent an unknown period 404'ing on every deployment that +set `OPENAI_BASE_URL`, because a `/v1` was appended to a URL that already had one. It was fixed in +0.0.3, whose own note says untagged messages "silently stopped being routed and nothing said why". + +The row now carries `undecided`, naming the cause: `unreachable`, `unparsed`, `off-roster`, +`unconfident`, or `one-candidate` — and `null` when the router did decide. Named values rather than a +sentence, because the useful question is how often, and a count needs something to group by. + +Two smaller corrections came with it. A message routed to the only coworker that can reach the system +it names kept that as its reason and threw the cause away, so a router that had been down for a week +produced rows reading like reach-based routing working as intended; the cause now survives that path. +And an answer containing no JSON at all — a model replying in prose — was recorded as the router +naming a coworker off the roster, which sends whoever reads it to look at their roster rather than at +the model. It is now reported as unparsed, which is what it is. + +Nothing changes about where a message goes. Every routing decision is the same decision it was. + ### Notion joins the connector catalogue Notion is now a governed MCP connector, reached through Notion's own hosted server on the diff --git a/server/src/routing/classify.ts b/server/src/routing/classify.ts index 5ae457b0..0286fa7c 100644 --- a/server/src/routing/classify.ts +++ b/server/src/routing/classify.ts @@ -34,6 +34,35 @@ export type RoutingCandidate = { reaches?: readonly string[]; }; +/** + * Why the router did not decide, when it did not. + * + * `fallback` says a message did not reach a coworker by an inferred match. It does not say whether + * that was the router declining or the router failing, and those are different facts about a + * deployment: "no specialist was a confident match" is the feature working, and "the router was + * unreachable" is an endpoint that is down. Both landed on the same boolean and the same sentence. + * + * That mattered once already. #178 found the intent router appending `/v1` to a `OPENAI_BASE_URL` + * that already carried one, so every call 404'd on every deployment that set the variable — and its + * own changelog entry says untagged messages "silently stopped being routed and nothing said why". + * The URL is fixed. The blindness that let it go unnoticed is this field. + * + * Named rather than free text so a trail can be counted: "this deployment routed nothing by inference + * for a week" is a question `select ... where payload->>'undecided' = 'unreachable'` answers, and a + * sentence is not. + */ +export type RoutingUndecided = + /** The model call threw. An endpoint being down, a bad key, a gateway 404. */ + | "unreachable" + /** It answered, and the answer was not JSON this could read. */ + | "unparsed" + /** It named a coworker that is not on the roster it was given. */ + | "off-roster" + /** It answered honestly that it was not sure. The feature working, not failing. */ + | "unconfident" + /** Nothing to decide between, so no call was made. */ + | "one-candidate"; + export type RoutingDecision = { agentId: string; name: string; @@ -41,6 +70,14 @@ export type RoutingDecision = { reason: string; /** True when this is the default rather than an inferred match: an honest "we were not sure". */ fallback: boolean; + /** + * Why it was not decided, or null when it was. + * + * Survives the reach-based answer below. Landing on the one coworker that can reach the system a + * message names is a good outcome and says nothing about whether the router answered, so replacing + * this with that would hide exactly the failure it exists to count. + */ + undecided: RoutingUndecided | null; }; /** Below this the match is a guess, and a guess should defer to the default rather than surprise. */ @@ -138,7 +175,10 @@ export function createIntentRouter(deps: { defaultId: string, ): Promise { const byId = new Map(candidates.map((c) => [c.id, c])); - const fallback = (reason: string): RoutingDecision => { + const fallback = ( + reason: string, + undecided: RoutingUndecided, + ): RoutingDecision => { /* * Before the default, ask whether the message named a system only one coworker can reach. * @@ -157,18 +197,33 @@ export function createIntentRouter(deps: { name: reachable.name, reason: `the only coworker that can reach ${reachable.system}`, fallback: true, + // Carried through. Reach answered where the message went; it did not answer whether the + // router did, and a router that has been down for a week must not read as this. + undecided, }; } const chosen = byId.get(defaultId) ?? candidates[0]; return chosen - ? { agentId: chosen.id, name: chosen.name, reason, fallback: true } + ? { + agentId: chosen.id, + name: chosen.name, + reason, + fallback: true, + undecided, + } : // No roster at all is a misconfiguration, not a routing outcome; surface the default id. - { agentId: defaultId, name: defaultId, reason, fallback: true }; + { + agentId: defaultId, + name: defaultId, + reason, + fallback: true, + undecided, + }; }; // Nothing to decide between: one coworker, or none but the default. if (candidates.length <= 1) { - return fallback("the only coworker available"); + return fallback("the only coworker available", "one-candidate"); } let raw: string; @@ -177,17 +232,34 @@ export function createIntentRouter(deps: { } catch { return fallback( "sent to your default while the router was unreachable", + "unreachable", ); } let parsed: { agentId?: unknown; reason?: unknown; confidence?: unknown }; + // The model is asked for bare JSON, but tolerate a fenced or padded answer. Named `jsonPart` + // rather than `match`, which is the roster lookup a few lines below. + const jsonPart = raw.match(/\{[\s\S]*\}/); + /* + * An answer with no object in it at all is unparsed, not off-roster. + * + * This used to fall through as `{}`, so a model replying in prose — "I think Risk Analyst is + * best" — reached the roster check, found no id, and was recorded as the router having named a + * coworker that does not exist. That points whoever reads it at their roster, when what is + * wrong is that the model is not answering in the format it was asked for. + */ + if (!jsonPart) { + return fallback( + "sent to your default; the router's answer did not parse", + "unparsed", + ); + } try { - // The model is asked for bare JSON, but tolerate a fenced or padded answer. - const match = raw.match(/\{[\s\S]*\}/); - parsed = match ? JSON.parse(match[0]) : {}; + parsed = JSON.parse(jsonPart[0]); } catch { return fallback( "sent to your default; the router's answer did not parse", + "unparsed", ); } @@ -197,6 +269,7 @@ export function createIntentRouter(deps: { // A returned id that is not on the roster is the dangerous case: never act on it. return fallback( "sent to your default; the router named no coworker on your roster", + "off-roster", ); } const confidence = @@ -204,6 +277,7 @@ export function createIntentRouter(deps: { if (confidence < MIN_CONFIDENCE) { return fallback( "sent to your default; no specialist was a confident match", + "unconfident", ); } @@ -211,7 +285,14 @@ export function createIntentRouter(deps: { typeof parsed.reason === "string" && parsed.reason.trim() ? parsed.reason.trim() : `matches ${match.name}`; - return { agentId: match.id, name: match.name, reason, fallback: false }; + // The router answered, on the roster, confidently. The only path where nothing was undecided. + return { + agentId: match.id, + name: match.name, + reason, + fallback: false, + undecided: null, + }; }, // Split out so the prompt-build + call is one seam the tests can leave alone. diff --git a/server/src/routing/routes.ts b/server/src/routing/routes.ts index 5d523c20..e8ae0720 100644 --- a/server/src/routing/routes.ts +++ b/server/src/routing/routes.ts @@ -4,7 +4,11 @@ import type { AuditStore } from "../audit"; import { recordAuditEvent } from "../audit"; import type { AppVariables } from "../auth/guards"; import type { AgentProfileStore } from "../agents/profile-store"; -import type { IntentRouter, RoutingCandidate } from "./classify"; +import type { + IntentRouter, + RoutingCandidate, + RoutingUndecided, +} from "./classify"; const DEV_ACTOR_EMAIL = "dev@openbot.local"; @@ -68,6 +72,14 @@ export function createRoutingRoutes( fallback: boolean, viaMention: boolean, candidates: readonly string[], + /* + * Why the router did not decide, when it did not. + * + * On the row rather than only in the sentence, because this is the field a deployment counts. A + * router that has been unreachable for a week produced rows that read like ordinary + * "no confident match" ones, which is how #178 went unnoticed for as long as it did. + */ + undecided: RoutingUndecided | null, ): Promise { if (!auditStore) return; await recordAuditEvent(auditStore, { @@ -75,7 +87,7 @@ export function createRoutingRoutes( targetType: "agent", targetId: chosen, ...(actorUserId ? { actorUserId } : {}), - payload: { chosen, reason, fallback, viaMention, candidates }, + payload: { chosen, reason, fallback, viaMention, candidates, undecided }, }); } @@ -126,7 +138,16 @@ export function createRoutingRoutes( * @zopeVaibhav had this right in #134. */ const reason = "named by the person asking"; - await record(actorId(actor), chosen.id, reason, false, true, [chosen.id]); + // The person chose. Nothing was left to the router, so nothing about it was undecided. + await record( + actorId(actor), + chosen.id, + reason, + false, + true, + [chosen.id], + null, + ); return context.json({ agentId: chosen.id, name: chosen.name, @@ -164,6 +185,7 @@ export function createRoutingRoutes( decision.fallback, false, candidates.map((c) => c.id), + decision.undecided, ); return context.json({ @@ -171,6 +193,7 @@ export function createRoutingRoutes( name: decision.name, reason: decision.reason, fallback: decision.fallback, + undecided: decision.undecided, viaMention: false, }); }); diff --git a/server/tests/routing-classify.test.ts b/server/tests/routing-classify.test.ts index 9407879b..f801f5f6 100644 --- a/server/tests/routing-classify.test.ts +++ b/server/tests/routing-classify.test.ts @@ -269,3 +269,130 @@ describe("falling back to somebody who can actually answer", () => { expect(decision.fallback).toBe(false); }); }); + +/** + * Why a message was not routed, as a thing a deployment can count. + * + * `fallback` says an inferred match did not happen. It does not say whether the router declined or + * the router failed, and only one of those is a deployment with something wrong with it. #178 was + * exactly that: the router 404'd on every deployment that set `OPENAI_BASE_URL`, and its changelog + * says untagged messages "silently stopped being routed and nothing said why". These pin the field + * that answers it. + */ +describe("saying why a message was not routed", () => { + test("a confident match leaves nothing undecided", async () => { + const decision = await withAnswer( + JSON.stringify({ + agentId: "risk-analyst", + reason: "fraud", + confidence: 0.9, + }), + ).route("is this transaction fraud", ROSTER, "general-assistant"); + + expect(decision.fallback).toBe(false); + expect(decision.undecided).toBeNull(); + }); + + test("an unreachable router is named as unreachable", async () => { + const decision = await throwing().route( + "anything", + ROSTER, + "general-assistant", + ); + expect(decision.undecided).toBe("unreachable"); + }); + + test("an answer that does not parse is named as unparsed", async () => { + const decision = await withAnswer('{ "agentId": }').route( + "anything", + ROSTER, + "general-assistant", + ); + expect(decision.undecided).toBe("unparsed"); + }); + + test("prose with no JSON in it is unparsed, not off-roster", async () => { + /* + * A model answering in sentences is a model not following the format. Recorded as off-roster it + * reads as a roster problem, and whoever investigates goes and looks at their roster. + */ + const decision = await withAnswer("I think Risk Analyst is best.").route( + "anything", + ROSTER, + "general-assistant", + ); + expect(decision.undecided).toBe("unparsed"); + }); + + test("an id that is not on the roster is named as off-roster", async () => { + const decision = await withAnswer( + JSON.stringify({ agentId: "somebody-else", confidence: 0.9 }), + ).route("anything", ROSTER, "general-assistant"); + expect(decision.undecided).toBe("off-roster"); + }); + + test("an honest low confidence is named as unconfident, not as a failure", async () => { + // The one cause that is the feature working. Counting it with the failures would make the + // number useless, which is the whole reason this is a named cause rather than a boolean. + const decision = await withAnswer( + JSON.stringify({ agentId: "risk-analyst", confidence: 0.2 }), + ).route("anything", ROSTER, "general-assistant"); + expect(decision.undecided).toBe("unconfident"); + }); + + test("a roster of one is named as one-candidate, and asks nothing", async () => { + const decision = await throwing().route( + "anything", + [ROSTER[0] as RoutingCandidate], + "general-assistant", + ); + expect(decision.undecided).toBe("one-candidate"); + }); + + test("the reach answer does not hide that the router never answered", async () => { + /* + * THE CASE THIS EXISTS FOR. Landing on the only coworker that can reach Google Drive is a good + * outcome, and it says nothing about whether the router was up. Before this the reach sentence + * replaced the failure entirely, so a deployment whose router had been down for a week produced + * rows that read exactly like reach-based routing working as intended. + */ + const reaching: RoutingCandidate[] = [ + { ...(ROSTER[0] as RoutingCandidate) }, + { + ...(ROSTER[1] as RoutingCandidate), + reaches: ["google-drive"], + }, + ]; + + const decision = await throwing().route( + "find the PRD in google drive", + reaching, + "general-assistant", + ); + + // Still routed by reach, and still a fallback — both unchanged. + expect(decision.agentId).toBe("knowledge"); + expect(decision.reason).toContain("google-drive"); + expect(decision.fallback).toBe(true); + // And the router failure survives it. + expect(decision.undecided).toBe("unreachable"); + }); + + test("reach after a low-confidence answer says unconfident, not unreachable", async () => { + // The two are told apart on the reach path as well, or the count is wrong wherever reach fires. + const reaching: RoutingCandidate[] = [ + { ...(ROSTER[0] as RoutingCandidate) }, + { + ...(ROSTER[1] as RoutingCandidate), + reaches: ["google-drive"], + }, + ]; + + const decision = await withAnswer( + JSON.stringify({ agentId: "knowledge", confidence: 0.1 }), + ).route("find the PRD in google drive", reaching, "general-assistant"); + + expect(decision.agentId).toBe("knowledge"); + expect(decision.undecided).toBe("unconfident"); + }); +}); diff --git a/server/tests/routing-routes.test.ts b/server/tests/routing-routes.test.ts index 974996b2..b974d136 100644 --- a/server/tests/routing-routes.test.ts +++ b/server/tests/routing-routes.test.ts @@ -4,7 +4,7 @@ import { Hono } from "hono"; import type { AgentProfileStore } from "../src/agents/profile-store"; import type { AuditStore } from "../src/audit"; import type { AppVariables } from "../src/auth/guards"; -import type { IntentRouter } from "../src/routing/classify"; +import type { IntentRouter, RoutingUndecided } from "../src/routing/classify"; import { createRoutingRoutes } from "../src/routing/routes"; /** @@ -47,7 +47,7 @@ type Recorded = { payload: Record; }; -function app(options: { routed?: string } = {}) { +function app(options: { routed?: string; undecided?: RoutingUndecided } = {}) { const written: Recorded[] = []; /** Every call the router was asked to make, so "never asked" is an assertion and not a hope. */ const asked: string[] = []; @@ -72,7 +72,8 @@ function app(options: { routed?: string } = {}) { agentId: chosen, name: ROSTER.find((a) => a.id === chosen)?.name ?? chosen, reason: "matches what it is for", - fallback: false, + fallback: options.undecided !== undefined, + undecided: options.undecided ?? null, }; }, } as unknown as IntentRouter; @@ -185,3 +186,47 @@ describe("recording which coworker a message went to", () => { expect(asked).toEqual(["hello"]); }); }); + +/** + * Why it was not decided, on the row rather than only in the sentence. + * + * The reason is prose for a person. This is the field a deployment counts, and counting is the point: + * a router that has been unreachable for a week is invisible until somebody can ask how often. + */ +describe("recording why a message was not routed", () => { + test("an unreachable router is on the row, not only in the sentence", async () => { + const { server, written } = app({ undecided: "unreachable" }); + + await post(server, { text: "what is our PTO policy" }); + + expect(written[0]?.payload).toMatchObject({ + chosen: "knowledge", + fallback: true, + undecided: "unreachable", + }); + }); + + test("a decided routing records no cause", async () => { + const { server, written } = app(); + + await post(server, { text: "what is our PTO policy" }); + + expect(written[0]?.payload).toMatchObject({ + fallback: false, + undecided: null, + }); + }); + + test("a coworker the person named records no cause either", async () => { + // Nothing was left to the router, so there is nothing about it to have failed. Recording a cause + // here would count a person's own choice as a routing failure. + const { server, written } = app(); + + await post(server, { text: "hello", agentId: "risk-analyst" }); + + expect(written[0]?.payload).toMatchObject({ + viaMention: true, + undecided: null, + }); + }); +}); From 88078a412c52d5e86ee009e4ed1690ecd6c30562 Mon Sep 17 00:00:00 2001 From: David McKay Date: Tue, 25 Aug 2026 18:18:06 -0700 Subject: [PATCH 02/13] Run OpenBot on Kubernetes: Bots and all, proven on EKS (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Run OpenBot on Kubernetes: a Helm chart, and what installing it found One chart for EKS, GKE, AKS and somebody's own cluster, with nothing but values between them. No cloud branching in any template: every place the clouds differ is a value whose default is what a plain self-hosted cluster does. Identity is one annotations map, because that is all IRSA, Workload Identity and AKS workload identity are. Secrets are a plain Secret by default and an ExternalSecret against any backend when asked. Two replicas by default, because horizontal is the point and one hides every bug that is not. A bad install is refused at helm install naming the value to change, rather than found in a crash loop. Three things only a real install could find: drizzle-kit cannot migrate in the shipped image. It reads a TypeScript config, which needs the esbuild that bun install --production leaves out, so it printed one line, exited 1 and said nothing. EMBEDDED_POSTGRES=on was starting containers whose database was never migrated. The migrator inside drizzle-orm is a runtime dependency already and keeps the same journal. sessionOf answered from a map in the process that started the computer, which is right until there are two of them. The replica taking a snapshot is usually not the one handling the click, and an unknown session skips the generation check rather than failing it, so the check that stops a ref from a replaced computer resolving against a live one was silently absent on the shape it was written for. It now asks by listing, never by ensuring, so asking cannot start a computer that had stopped. A browser in an API pod cannot be replicated, so the image's computer gets the same switch its database has. * Give Bots computers on Kubernetes, and suspend them when idle The chart had no computer, so no Bot could do anything on a cluster. It has one now, and a Bot has driven a real browser on real EKS with the decision in the audit trail. computers.mode picks the shape. shared runs one browser for every Bot and needs nothing installed. sandbox gives each Bot its own as a Sandbox from kubernetes-sigs/agent-sandbox, which is built for exactly this: an isolated stateful singleton with a stable identity and persistent storage, where suspending is a field that keeps the volumes, so a computer comes back with its logins rather than signed out. What decides a computer is idle is the audit trail, not the browser. Asking the browser wakes it, so every computer anything asked about would come back up and the bill would never fall. The work is claimed and leased out of Postgres with for update skip locked. Three features need that one mechanism, so it is written once with all three in view: the culler here, routines, and a hop from one Bot to another. A CronJob runs the sweep rather than a timer in the API, because a timer fires in every replica and suspending a browser somebody just started using is not something to do five times. Also: a fresh EKS cluster very often has no default StorageClass. eksctl creates gp2, unmarked and on the in-tree provisioner current Kubernetes no longer has, so a volume asking for the default never binds and nothing says why. Found on a real 1.34 cluster and written down where somebody configuring one will read it. * Refuse a sandbox install on a cluster that cannot make one computers.mode: sandbox creates Sandbox objects, which exist only once the agent-sandbox controller is installed. Without it the install succeeds, every pod is healthy, and the deployment looks finished right up until the first Bot asks for a browser and the API server answers 404. That is the worst moment to learn it. The check reads the cluster rather than a value somebody has to remember to set, and the message carries the one command that fixes it. Proven both ways: refused on a cluster with no CRD, installs on the EKS cluster that has one. Also from driving it on real EKS: lost+found was listed as a Bot, because an EBS volume is ext4 and arrives with that directory, which a bind mount never does. The allow-list that stops a hostile id becoming a path answers the other half of the question too. The migration Job named a ServiceAccount that does not exist yet, since a pre-install hook runs before the chart's own resources. It talks to a database and never to the cluster, so it needs no account at all. The API pod gets a cluster token only in sandbox mode, the pods roll when the computer template changes, the Sandbox asks for a Service so it has an address that survives a resume, and the cluster CA is actually used when talking to the API server. * Tell one run of a computer from the next across a suspend A resumed browser counts snapshot generations from one again, so a ref the model still holds from before the suspend matches a row nothing has overwritten, and the boundary decides about an element on a page that no longer exists. The first answer used the node and the pod address. Resuming a real computer on EKS disproved it: a suspended sandbox is very often rescheduled onto the same node and handed the same address back, and both were identical across the cycle, so the check would have said same run for the exact case it exists to catch. The Ready condition's transition time moves whenever a computer starts serving again, needs no permission beyond the sandbox already read, and is precisely the question. Driven on EKS: a ref taken before a suspend is refused after the resume, naming why, and a fresh ref from a new snapshot clicks through. * Let the policy reach the computers, and refuse one that fences off the database The NetworkPolicy allowed DNS and the bundled database. Nothing let the API reach a Bot's computer, which it does for every browser action, and nothing let it reach a managed database, whose address this chart cannot know. On a cluster that enforces policy both are outages that read as something else: the API looks broken rather than fenced. The computers and the API server are allowed now, and turning the policy on with an external database and no rule for it is refused with the shape of the rule to add. None of this showed up by installing it, because EKS runs its CNI with --enable-network-policy=false and the policy is inert there. That is worth knowing on its own, so it is written down: a policy that installs, looks right, and does nothing is worse than one that is off. Also driven on EKS: reset takes the volumes with it and the Bot gets a clean profile afterwards, and the HPA reads real metrics. * Keep the browsing that produced an answer Every turn in which a Bot used a tool vanished from the transcript on reload. The sentence the Bot wrote stayed, the browsing that produced it did not, the inline screen went with it, and the footer said some messages could not be read. The history store writes a tool call as {id, name, args}; AG-UI describes {id, type: function, function: {name, arguments}}. The reader validated against the second and treated the first as damage from an interrupted run. It is not damage, it is how every tool call is stored, so a guard written against one bad turn was deleting all the real ones. Found by driving a real conversation on the EKS deployment rather than by reading: two browsing turns, both counted unreadable, both well formed in the store's own dialect. Both spellings now read as the same thing. A mixed or unrecognised array is still refused rather than half-translated, because a reader that rewrites what it does not recognise is worse than one that refuses it. * Show the page a finished turn opened, not the one open now Reopening a conversation made every past turn fetch the screen as it is now, so an answer about Hacker News from an hour ago sat under a picture of whatever the Bot had open since. The frame was live and the caption was not, and the turn read as though it had browsed somewhere it never went. A turn that has finished is history, and history is not polled. It names the page that turn actually left open, which the tool result already carried. Nothing changes while a turn runs: those frames are its own and freeze where it left them. It names the page rather than showing it, because nothing stored the picture and fetching one now would show a different page. Naming it stays true however many times the Bot has browsed since. Driven on EKS: three turns, three different pages, each holding its own across a reload. * Keep the frame a browsing turn ended on Reopening a conversation made every past turn fetch the screen as it is now, so an answer about one page sat under a picture of whatever the Bot had open since. A browsing turn keeps its last frame in computer_turn_frame, filed under the tool call and written once, because a turn that has happened does not happen differently later. Three things had to be true together and each was wrong on its own first. The frame is read at the moment the turn ends, since a short turn finishes before the tile has polled anything. Restoring a kept frame must not make the turn look live again, which the first version did: it counted a turn as history only while it had no picture, so restoring one restarted the polling that then replaced it. And a turn is over when it has a result rather than when its status says so, because a restored tool call arrives with its result in hand and a status that is briefly something else. Found by watching the network on the deployed cluster rather than by reading: two live screenshot reads before the restore, on every reload. * Keep a turn's frame only when it is a frame of that turn's page The capture ran at the end of a turn and took whatever the screen showed then. That is usually right and sometimes badly wrong: the same computer is driven by other conversations, a resumed one starts blank, and a short turn finishes before the tile has polled anything. So an answer about one page could be filed with a picture of another, which is worse than having no picture at all. A frame is now kept only when its own url is the page the turn opened. Unknown counts as no match, because storing on unknown is how the wrong picture gets kept. Also folds the restore and the capture into one effect asked in order: what is stored first, the live screen only if nothing is. Two effects racing is what made a reopened turn restore the right frame and then overwrite it with a fresh screenshot one render later, which the console showed plainly once I stopped guessing and logged it. * Photograph the page where it is opened, not where it is read back The transcript's inline screen used to capture its own frame after the turn ended, and file it under the tool call. That is a race it cannot win. A reopened turn and one that has just finished look identical from inside the component, the same computer is driven by other conversations in between, and a resumed computer starts blank, so the picture filed was routinely of somewhere the turn never went, or of nothing. The frame is now taken on the server the moment a navigation succeeds, which is the one moment the screen is certainly showing the page that was asked for, and kept per computer and page rather than per tool call. The surface only reads. Failing to take the picture never fails the navigation. * Open the Bot screen on a Bot this deployment has Three things a fork trips over. The Bot screen defaulted to a coworker named risk-analyst, which is a name from one tenant package and a crash on every other. OpenBot exists to be forked, so a Bot id written into a route is a defect on all but the deployment it came from: the screen took the whole page down to an unstyled error boundary. It now opens on whatever Bot this deployment actually has, and answers a mistyped name in a sentence. The audit trail wrote "not in the current snapshot" against every navigation, file read and command. That sentence is about a ref the server could not resolve, and deciding it by elimination put it on actions that never named an element at all, sending a reader looking for a snapshot nobody took. It is keyed on the ref now. The chart had no way to point at anyone's own AG-UI Bot, which is the seam the whole product is about. config.managedAgent.url and secrets.managedAgentToken, refused at install if one is set without the other. * Format the regenerated migration snapshot * Fix what the review found, and make CI able to find it next time Every claim driven against the real thing rather than read, and all but two held. THE QUEUE. Leases were computed on the replica's clock and compared against the database's, which is two clocks pretending to be one: a node ninety seconds behind wrote a sixty-second lease that arrived already expired, and the next replica took the item out from under it. Both ran it. `finish` and `release` matched on the key alone, so a replica whose lease had quietly gone deleted or rescheduled work another was executing. Nothing ever renewed, and the culler took twenty items on one lease. `finish` deleted the row, destroying the idempotence this table's own comment promises: the insert a re-offer was meant to collide with had nothing left to collide with. And a permanently failing item retried until somebody noticed, which on a queue with no dashboard is never. Every moment is named in SQL now, all three lease calls ask the same question, the culler renews between items, a finished row stays until a retention sweep takes it, and an item that runs out of attempts stops with its count and its reason where a person can query them. The probe that reproduced the first two comes back clean. THE CHART could not start a server on any of its four shipped targets: each configured sign-in and none supplied the secret sessions are signed with, and one carried the public example encryption key. Driven on a real cluster, watched to crash-loop, fixed, watched to come up ready. Both states are refused at install now, including the one hiding behind an external secret store, where the value is unreadable but the list of keys is not. THE DATABASE WAS REACHABLE FROM THE BOT'S BROWSER. Compose has kept those apart since the beginning; the chart dropped it, and the bundled database shipped a policy admitting any pod in any namespace on 5432. Proven by opening a socket from the browser pod. Pinned, plus a policy for the computer itself, which had none. That pod also carried a cluster credential it has no use for, which it no longer does: verified on a recreated per-Bot computer. The service account token was read once and held for the life of the process. Projected tokens rotate on a schedule the cluster picks, so sandbox calls work until the first rotation and then all return 401, which reads like the cluster broke. THE TRANSCRIPT still lied in two places. A finished turn holding a stale live frame fell through to "Waiting for the assistant's screen…" and waited there for ever, because the poll that would end the wait stops when a turn settles. And zooming a past turn mounted the live stream and offered Take control, so the one gesture for looking closer at what a turn did replaced it with whatever the Bot has open now. The kept frame exists to stop exactly that. TWO TESTS passed a pool-options object where a connection string belongs and were green for a reason unrelated to what they check, because the test tree is not type-checked. That is its own sweep; the misuse throws now. One of them also deleted every real queued suspension in the database. NOTHING HAS EVER RENDERED THIS CHART, which is how four broken targets shipped and stayed shipped. CI lints, renders and checks five targets now, including the per-Bot mode nothing rendered before, and a script that asks whether every secret key a container demands is one the chart writes. * Give the chart job the runtime its check needs * Address the second review: the frame goes back on turn identity, and the fixes stop breaking things Most of round two is consequences of round one, which is the honest summary. THE QUEUE WEDGED ITS OWN KEYS. An item at the attempt cap is not finished, so `claim` skipped it, `purge` did not match it, and `offer` cannot replace a row that is still there. The culler keys on the Bot id: five failed suspends and that Bot never scaled to zero again, silently and for good. Both kinds of done are reaped now, on the same window, which is also how long it waits before anything tries again. Giving up is logged rather than simply ceasing. PINNING THE DATABASE POLICY BROKE THE THINGS THAT USE IT. Only the API server carried the client label; the migration Job and the culler both open the database and neither did, so any cluster that actually enforces would have failed the install. My own probe could not have caught it, because that cluster ships enforcement switched off. THE FRAME GOES BACK ON THE TURN. Keying it on the page was a mistake with a plausible reason: two visits to one address collided, and letting the newer win made a past turn's picture change under the person reading it, which is the mutability this whole change exists to remove. It was chosen because the navigate handler seemed not to know its tool call. It does, on `context.toolCall.id`, which I assumed rather than checked. The row is written once and never updated. That leaves the race the old client-side guard used to cover: the screenshot is a second round trip, and with one computer shared by every Bot another Bot's navigation lands in the gap. The guard is back, on the side that now does the capturing. The capture also refuses to resume a suspended computer, so a convenience picture cannot undo a cull or hold a navigation open for a pod schedule. A TURN IS OVER WHETHER OR NOT IT GOT ANYWHERE. Settling on "do I have a page" left refused, failed and stopped navigations polling the live screen for ever under a finished answer, which are the turns where what is on screen has least to do with what is being read. And the control pill was the one affordance the merge did not teach: take the wheel mid-navigation, the turn settles, and a frozen picture from an hour ago asserted "You have control" with no way to hand it back. RESET NOW MEANS RESET. "Every login the Bot had is gone" was said while screenshots of the signed-in pages stayed in the database, readable from the transcript by anyone who could reach that Bot. The frames go with the profile, and a reaper takes the rest on a retention window, because a page is a row and nothing ever took anything out of that table. A REJECTED PROMISE WAS REMEMBERED FOR EVER. One unreadable token file at the wrong moment and every computer request for the pod's life failed with the same stale error, with no probe failing. And the chart's own gates were softer than they looked. `helm lint` reports a template `fail` as an INFO line and exits 0 even under `--strict`, which I drove rather than assumed, so it can never gate a refusal. Rendering can, and now does: CI asserts three refusals actually fire. The render check can no longer pass by matching nothing. `better-auth-secret` is optional only when it truly is, which also makes the existing-Secret path visible to that check. The policies render in a CI target for the first time. The subchart, its image and the lock are all pinned, and the lock is committed rather than ignored beside the tarballs it exists to pin. * Assert the example-key refusal only where it is armed * Arm the Bot-endpoint refusal under an external secret store too * Close the round-one gates: one dialect, one predicate, one shipped rule Four things that were reported as still open, and all four were. THE BOT SIDE HAD THE SAME DIALECT BUG AS THE SURFACE. A call read back from the thread store arrives as `{id, name, args}`, so `call.function` is empty: agent-bot defaulted every restored call to a tool named `tool` with no arguments, which is a call the model cannot recognise as the one it made, so it makes it again. That is the repetition the default was written to prevent, caused by the default. The LangGraph twin did not degrade at all, it dereferenced straight through and threw. Both read either spelling now, and the fallback is the last resort it was meant to be. THE SURFACE STILL PASSED ARGUMENTS THROUGH UNTOUCHED. AG-UI types them as a string and the store is under no such obligation, so a tool called with structured input produced a call that looked translated and failed validation anyway. Strings are passed through exactly, down to their whitespace, because a fragment of a stream that was never valid JSON is what the model actually said. AND IT DROPPED A TURN THAT CALLED A TOOL AND SAID NOTHING. The schema makes an assistant's content optional and does not allow null, so the two mean the same thing and only one parsed: the same loss as the dialect bug, by a different route. A person's turn is not the same case, and #207's decision to refuse and count that one stands. The tests that pinned multimodal content, null content and ordering are back, and the shapes were driven against the reader rather than assumed: the one I was most confident about, that a list of parts is refused, turned out to be wrong. THE PROFILE TEST PROVED ITS OWN COPY. It reimplemented the filter it was checking, so deleting the real one left the suite green and the fleet page listing `lost+found` as a Bot again. The rule is its own module now, imported by both, and removing the filter fails the test. * Run the reaper that was written, and keep frames through a rollout Two things asked for before merge, both mine, and the second was worse than reported. THE REAPER HAD NO CALLER. `computer_page_frame` had a purge, an index to serve it and a test proving it works, and nothing ever invoked it: written on every navigation, taken out by a profile wipe and by nothing else. The culler calls it, because that is already the sweep that runs on a schedule with a claim under it and a second timer would be a second thing to get wrong. Kept a month, which is long after anybody reads a conversation back. Deleting a Bot still leaves them, and that is left alone deliberately: a delete is soft and touches no computer state at all today, not the profile, not the browser, not the snapshots. Clearing only the screenshots would be the one half-measure that reads as though the rest had been handled. A SCREENSHOT THAT DOES NOT SAY WHAT IT IS OF IS THE ORDINARY CASE ON AN OLD COMPUTER. That field arrived after the first computers shipped. Refusing on a missing url therefore did not fail safe, it failed silently and completely: a fleet part-way through a rollout kept no frames at all and said nothing about why. The question is now asked where it means something. With a computer each there is nobody to race with and the picture can only be this turn's. On one shared browser another Bot's navigation lands in exactly that gap, so an unlabelled frame is still refused, and the rollout order that matters is written down where somebody upgrading will read it. And every refusal says so now. Two of the three returned quietly, under a docstring promising the opposite, which is how a deployment ends up keeping no frames with nothing in its logs to explain it. --- .github/workflows/ci.yml | 79 +- .gitignore | 5 + CHANGELOG.md | 129 +- agent-bot/src/history.ts | 44 +- agent-bot/tests/history.test.ts | 44 + agent-computer/src/profile-listing.ts | 38 + agent-computer/src/profiles.ts | 12 +- agent-computer/tests/profile-listing.test.ts | 49 + agent-langgraph/src/history.ts | 31 +- app/src/components/computer/computer-view.tsx | 369 ++- app/src/lib/computers/screen.ts | 28 + app/src/lib/copilot/computer-tools.tsx | 67 +- app/src/lib/copilot/thread-messages.ts | 109 +- app/src/routes/_authed/_app/bot.tsx | 39 +- app/tests/thread-messages.test.ts | 313 +- charts/openbot/.helmignore | 5 + charts/openbot/Chart.lock | 6 + charts/openbot/Chart.yaml | 31 + charts/openbot/README.md | 225 ++ charts/openbot/ci/aks-values.yaml | 56 + charts/openbot/ci/eks-sandbox-values.yaml | 106 + charts/openbot/ci/eks-values.yaml | 84 + charts/openbot/ci/gke-values.yaml | 55 + charts/openbot/ci/self-hosted-values.yaml | 48 + charts/openbot/templates/_helpers.tpl | 387 +++ .../templates/computer/culler-cronjob.yaml | 77 + .../templates/computer/pod-template.yaml | 24 + .../templates/computer/sandbox-rbac.yaml | 47 + .../templates/computer/sandbox-template.yaml | 23 + .../openbot/templates/computer/service.yaml | 19 + .../templates/computer/statefulset.yaml | 170 ++ .../openbot/templates/computer/warmpool.yaml | 22 + charts/openbot/templates/configmap.yaml | 17 + charts/openbot/templates/externalsecret.yaml | 24 + charts/openbot/templates/httproute.yaml | 26 + charts/openbot/templates/ingress.yaml | 44 + charts/openbot/templates/migrations/job.yaml | 105 + charts/openbot/templates/networkpolicy.yaml | 140 + charts/openbot/templates/secret.yaml | 50 + .../openbot/templates/server/deployment.yaml | 155 + charts/openbot/templates/server/hpa.yaml | 37 + charts/openbot/templates/server/pdb.yaml | 30 + charts/openbot/templates/server/service.yaml | 24 + .../templates/server/serviceaccount.yaml | 17 + charts/openbot/templates/validation.yaml | 249 ++ charts/openbot/values.yaml | 381 +++ docker/s6/s6-rc.d/computer/run | 13 + docker/s6/scripts/migrate.sh | 5 +- scripts/check-rendered-chart.ts | 140 + server/drizzle.config.ts | 1 + server/drizzle/0017_durable_work.sql | 16 + server/drizzle/0018_page_frames.sql | 11 + server/drizzle/meta/0017_snapshot.json | 2506 ++++++++++++++++ server/drizzle/meta/0018_snapshot.json | 2577 +++++++++++++++++ server/drizzle/meta/_journal.json | 14 + server/scripts/cull-idle-computers.ts | 100 + server/scripts/migrate.ts | 43 + server/src/app.ts | 17 +- server/src/computer/gateway.ts | 39 +- server/src/computer/page-frames.ts | 139 + server/src/computer/provider.ts | 70 +- server/src/computer/routes.ts | 171 +- server/src/computer/sandbox.ts | 416 +++ server/src/computer/supervisor.ts | 41 +- server/src/config.ts | 75 +- server/src/db/client.ts | 14 + server/src/db/schema/computer.ts | 53 +- server/src/db/schema/index.ts | 3 +- server/src/db/schema/work.ts | 98 + server/src/index.ts | 6 + server/src/work/culler.ts | 229 ++ server/src/work/queue.ts | 286 ++ .../tests/computer-culler.integration.test.ts | 222 ++ server/tests/computer-gateway.test.ts | 16 +- .../tests/computer-page-frame-route.test.ts | 308 ++ ...puter-page-frame-store.integration.test.ts | 214 ++ server/tests/computer-provider.test.ts | 44 +- server/tests/computer-sandbox.test.ts | 159 + server/tests/computer-supervisor.test.ts | 76 + server/tests/work-queue.integration.test.ts | 326 +++ server/tsconfig.json | 9 +- 81 files changed, 12252 insertions(+), 245 deletions(-) create mode 100644 agent-computer/src/profile-listing.ts create mode 100644 agent-computer/tests/profile-listing.test.ts create mode 100644 charts/openbot/.helmignore create mode 100644 charts/openbot/Chart.lock create mode 100644 charts/openbot/Chart.yaml create mode 100644 charts/openbot/README.md create mode 100644 charts/openbot/ci/aks-values.yaml create mode 100644 charts/openbot/ci/eks-sandbox-values.yaml create mode 100644 charts/openbot/ci/eks-values.yaml create mode 100644 charts/openbot/ci/gke-values.yaml create mode 100644 charts/openbot/ci/self-hosted-values.yaml create mode 100644 charts/openbot/templates/_helpers.tpl create mode 100644 charts/openbot/templates/computer/culler-cronjob.yaml create mode 100644 charts/openbot/templates/computer/pod-template.yaml create mode 100644 charts/openbot/templates/computer/sandbox-rbac.yaml create mode 100644 charts/openbot/templates/computer/sandbox-template.yaml create mode 100644 charts/openbot/templates/computer/service.yaml create mode 100644 charts/openbot/templates/computer/statefulset.yaml create mode 100644 charts/openbot/templates/computer/warmpool.yaml create mode 100644 charts/openbot/templates/configmap.yaml create mode 100644 charts/openbot/templates/externalsecret.yaml create mode 100644 charts/openbot/templates/httproute.yaml create mode 100644 charts/openbot/templates/ingress.yaml create mode 100644 charts/openbot/templates/migrations/job.yaml create mode 100644 charts/openbot/templates/networkpolicy.yaml create mode 100644 charts/openbot/templates/secret.yaml create mode 100644 charts/openbot/templates/server/deployment.yaml create mode 100644 charts/openbot/templates/server/hpa.yaml create mode 100644 charts/openbot/templates/server/pdb.yaml create mode 100644 charts/openbot/templates/server/service.yaml create mode 100644 charts/openbot/templates/server/serviceaccount.yaml create mode 100644 charts/openbot/templates/validation.yaml create mode 100644 charts/openbot/values.yaml create mode 100644 scripts/check-rendered-chart.ts create mode 100644 server/drizzle/0017_durable_work.sql create mode 100644 server/drizzle/0018_page_frames.sql create mode 100644 server/drizzle/meta/0017_snapshot.json create mode 100644 server/drizzle/meta/0018_snapshot.json create mode 100644 server/scripts/cull-idle-computers.ts create mode 100644 server/scripts/migrate.ts create mode 100644 server/src/computer/page-frames.ts create mode 100644 server/src/computer/sandbox.ts create mode 100644 server/src/db/schema/work.ts create mode 100644 server/src/work/culler.ts create mode 100644 server/src/work/queue.ts create mode 100644 server/tests/computer-culler.integration.test.ts create mode 100644 server/tests/computer-page-frame-route.test.ts create mode 100644 server/tests/computer-page-frame-store.integration.test.ts create mode 100644 server/tests/computer-sandbox.test.ts create mode 100644 server/tests/work-queue.integration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5f0ec41..6c79e8f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,83 @@ jobs: - run: bun run typecheck working-directory: ${{ matrix.package }} + chart: + name: chart (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + # One red target must not hide whether another is red too. + fail-fast: false + matrix: + target: [self-hosted, eks, eks-sandbox, gke, aks] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + with: + version: v3.19.0 + # For the coherence check below, which is a Bun script like everything else here. + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + # Nothing rendered this chart until now, which is how four values files that produce a server + # unable to start were shipped and stayed shipped. Rendering is the cheap half; the refusals in + # validation.yaml are the half that catches a missing value before a cluster does. + - run: helm dependency build charts/openbot + # Structure only. `helm lint` reports a template `fail` as an INFO line and exits 0 even under + # `--strict`, which was driven and confirmed, so it cannot gate the refusals below. Rendering + # does: `helm template` exits non-zero on one. + - run: helm lint charts/openbot --values charts/openbot/ci/${{ matrix.target }}-values.yaml + # A key encryption key is a real 32 bytes rather than a placeholder, because the chart checks + # its shape. Generated here so no example key is ever a literal in this repository. + - name: Render + run: | + helm template ci charts/openbot \ + --values charts/openbot/ci/${{ matrix.target }}-values.yaml \ + --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" \ + --api-versions agents.x-k8s.io/v1beta1/Sandbox \ + --api-versions extensions.agents.x-k8s.io/v1beta1/SandboxTemplate \ + > rendered.yaml + # Rendering proves the templates run. This proves the result is coherent, which is a different + # question: every secret key a container demands has to be one the chart actually writes. + # Getting that wrong is invisible until a pod starts, and every shipped target had it wrong. + - run: bun scripts/check-rendered-chart.ts rendered.yaml + # And that the refusals are load-bearing rather than decorative. A chart full of `fail` + # messages nothing ever triggers is a chart that has never been shown to refuse anything, and + # every one of these describes a state that shipped in a values file at some point. + - name: Refusals fire + run: | + set -uo pipefail + refuses() { + local why="$1"; shift + if helm template ci charts/openbot \ + --values charts/openbot/ci/${{ matrix.target }}-values.yaml \ + --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" \ + --api-versions agents.x-k8s.io/v1beta1/Sandbox \ + --api-versions extensions.agents.x-k8s.io/v1beta1/SandboxTemplate \ + "$@" >/dev/null 2>&1; then + echo "::error::The chart rendered $why, which it is supposed to refuse." + return 1 + fi + echo "refused: $why" + } + # The public example key, which the server will not start with. Only where this chart + # holds the secret: with a store, the value is not readable at template time, so the + # refusal is deliberately not armed and asserting it here would be asserting a bug. + if ! grep -qE '^ *enabled: true' <(sed -n '/^externalSecrets:/,/^[a-z]/p' charts/openbot/ci/${{ matrix.target }}-values.yaml); then + refuses "the public example encryption key" \ + --set-string secrets.keyEncryptionKey="$(head -c 32 /dev/zero | base64)" + else + echo "skipped: the example-key refusal is not armed when the secret comes from a store" + fi + # A Bot endpoint with nothing on the request that says who is calling. Armed whether the + # secret is this chart's or a store's, because the key list is readable either way. + refuses "a managed agent URL with no token" \ + --set-string config.managedAgent.url=http://agent.default:8000/ag-ui + # A browser inside every replica of a replicated API. + refuses "an embedded browser across several replicas" \ + --set server.embeddedComputer=true --set server.replicaCount=2 + test: name: tests runs-on: ubuntu-latest @@ -261,7 +338,7 @@ jobs: name: verify runs-on: ubuntu-latest if: always() - needs: [static, deployables, test, build, migrations, image] + needs: [static, deployables, chart, test, build, migrations, image] steps: - name: Require every check env: diff --git a/.gitignore b/.gitignore index f5d48e06..f1e6ae03 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,8 @@ app/src/lib/generated/application-config.ts # TanStack Router scratch output app/.tanstack/ + +# Helm subchart tarballs, fetched by `helm dependency build`. The lock beside them is NOT ignored: +# it is what makes that fetch reproducible, and ignoring it meant every build resolved the dependency +# afresh, so CI and a customer install could take different subchart versions with no diff to show it. +charts/*/charts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c58967..b90ea999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,134 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A finished turn shows the page it opened, not the one open now + +Reopening a conversation made every past turn fetch the screen as it is now, so an answer about +Hacker News from an hour ago sat under a picture of whatever the Bot had open since. + +A page is now photographed where it is opened. The server takes the frame the moment a navigation +succeeds and keeps it in `computer_page_frame` under the computer and the address, which is the one +moment the screen is certainly showing the page that was asked for. Reopening the conversation shows +that frame rather than the live screen, and a turn with nothing kept names the page instead of +drawing the wrong one. + +The surface used to capture it itself once the turn went quiet, and that is a race it cannot win: a +reopened turn and one that has just finished are indistinguishable from inside the component, the +same computer is driven by other conversations in between, and a resumed computer starts blank. It +filed pictures of pages the turn never opened, or none at all. It only reads now. + +**Redeploy the computers with the server.** A screenshot only says which page it is of on an +`agent-computer` built after that field was added, and this is what decides whether a frame is kept. +Where each Bot has a computer of its own there is nobody to race with, so an old computer's picture is +accepted and the feature works through a rollout. On ONE SHARED COMPUTER it cannot be: another Bot's +navigation lands between the navigation and the picture, and a frame that cannot be told apart from +theirs is refused. So a shared-computer deployment that updates the server and not the computer keeps +no frames until it does, and says so in the server log each time rather than leaving somebody to +wonder. + +Two things followed from making a past turn a record. Its placeholder is decided by the turn being +over rather than by whether a live frame happens to be in hand, because a tile that was live a moment +ago keeps its last screenshot and used to fall through to "Waiting for the assistant's screen…" and +wait there for ever. And opening one full size shows that same kept frame, with no live stream and no +wheel: zooming a past turn used to mount the socket and offer Take control, so the one gesture for +looking closer at what a turn did replaced it with whatever the Bot has open now. + +### A conversation keeps the browsing that produced its answers + +Every turn in which a Bot used a tool was disappearing from the transcript on reload. The sentence +the Bot wrote stayed; the browsing that produced it did not, the inline screen went with it, and the +footer said some messages could not be read. + +The history store writes a tool call as `{id, name, args}`. AG-UI describes +`{id, type: "function", function: {name, arguments}}`. The reader validated against the second, +treated the first as damage from an interrupted run, and dropped it. It is not damage: it is how +every tool call is stored, so what looked like a guard against one bad turn was deleting all of the +real ones. Observed on a live thread where every browsing turn was counted unreadable and every one +of them was well formed in the store's own dialect. + +The two spellings are now read as the same thing. The check stays for turns that really are +malformed, and a mixed or unrecognised array is still refused rather than half-translated, because a +reader that rewrites what it does not recognise is worse than one that refuses it. + + +### Run this on Kubernetes + +A Helm chart under `charts/openbot`, Bots and all, and the fixes that installing it for real turned +up. Proven on a real EKS cluster: five workloads, replicas across two nodes, EBS volumes bound, and a +Bot opening a real page from inside AWS with the decision in the audit trail. + +One chart, four targets: EKS, GKE, AKS and somebody's own cluster, with nothing but values between +them. There is no cloud branching in any template. Every place the clouds genuinely differ is a +value whose default is what a plain self-hosted cluster does: the cluster's own default StorageClass, +no RuntimeClass, a plain Kubernetes Secret, an Ingress. Identity is one `serviceAccount.annotations` +map, which is all IRSA, Workload Identity and AKS workload identity are. Secrets are a plain Secret +by default and an ExternalSecret against any backend when asked, so Secrets Manager, Secret Manager +and Key Vault are a values block rather than three code paths. Gateway API is supported beside +Ingress rather than instead of it. `charts/openbot/ci` holds a values file per target. + +Two replicas by default, because horizontal is the point and one replica hides every bug that is +not. A bad install is refused at `helm install`, naming the value to change, rather than discovered +in a crash loop: no database or two of them, nobody who could sign in, nobody who would be an +administrator, a key of the wrong shape, both routers enabled, or a browser asked for inside more +than one replica. + +**A Bot's computer is not in an API pod.** The image runs one beside the API so that a single +container works on its own, and `EMBEDDED_COMPUTER=off` turns it off. A replica must not carry a +browser: it is a few hundred megabytes holding one Bot's logins, so scaling the API would scale +those with it. + +**Migrations no longer need a development tool.** `bun x drizzle-kit migrate` cannot run in the +shipped image at all. The CLI reads a TypeScript config, which needs the esbuild that +`bun install --production` correctly leaves out, so it printed "Reading config file", exited 1 and +said nothing else. `EMBEDDED_POSTGRES=on` was therefore starting a container whose database was +never migrated, and the first symptom was the API reporting that `users` does not exist. +`server/scripts/migrate.ts` uses the migrator inside `drizzle-orm`, which is a runtime dependency +already, and keeps the same journal, so a database migrated by either tool is migrated. + +**A computer for each Bot, suspended when idle.** `computers.mode: sandbox` gives every Bot its own +browser as a `Sandbox` from `kubernetes-sigs/agent-sandbox`, which is built for this workload: an +isolated, stateful, singleton pod with a stable identity and persistent storage. Suspending is one +field, and it keeps the volumes, so a computer comes back with its logins rather than signed out of +everything. `shared` stays the default and needs nothing installed in the cluster. + +**The NetworkPolicy would have fenced the API off from its own work.** Its egress named DNS and the +bundled database and nothing else, so on a cluster that enforces policy the API could not have +reached a Bot's computer or, with a managed database, the database. Both are allowed now, and turning +the policy on with an external database and no rule for it is refused rather than shipped. Worth +knowing either way: EKS runs its CNI with `--enable-network-policy=false`, so a policy there installs, +looks right, and does nothing at all. + +**A cluster with no controller is refused at install.** `computers.mode: sandbox` needs the +agent-sandbox CRD, and without it the install succeeds, every pod is healthy, and the deployment +looks finished until the first Bot asks for a browser. The chart reads the cluster and refuses, +naming the one command that fixes it. + +**What decides a computer is idle is the audit trail, not the browser.** Asking the browser would +wake it, so every computer anything asked about would come back up and the bill would never fall. + +**Durable work, claimed by whichever replica gets there first.** `work_items` plus +`select ... for update skip locked` and a lease: no coordinator, no leader election, and a replica +added is throughput added. The idle-computer culler is its first user; scheduled routines and +hand-offs between Bots are the other two, which is why it is written once rather than three times +slightly differently. A CronJob runs the sweep, because a timer in the API fires in every replica and +suspending a browser somebody just started using is not something to do five times. + +**Which run of a computer this is, across a suspend.** A resumed browser counts snapshot +generations from one again, so a ref the model still holds from before the suspend would match a row +nothing has overwritten and the boundary would decide about an element on a page that no longer +exists. The first answer here used the node and the pod address, and resuming a real computer +disproved it: a suspended sandbox is very often rescheduled onto the same node and handed the same +address back, so both were identical across a suspend and resume and the check would have said "same +run" for the exact case it exists to catch. It reads the `Ready` condition's transition time instead, +which moves every time a computer starts serving again. + +**Which run of a computer this is, on more than one replica.** `sessionOf` answered from a map in +the process that started the computer, which is right until there are two: the replica that took a +snapshot is usually not the one handling the click, and the second had nothing to answer with. An +unknown session means "no opinion" and skips the generation check, so on exactly the deployment +shape it was written for, the check that stops a ref from a replaced computer resolving against a +live one was silently absent. It now asks the supervisor when it does not know, by listing rather +than by ensuring, so asking never starts a computer that had stopped. ### Notion joins the connector catalogue Notion is now a governed MCP connector, reached through Notion's own hosted server on the @@ -35,7 +163,6 @@ read that as a stolen token and revoke the whole connection. Every plugin call t token now locks the credential's vault row for the length of the exchange, so a second replica waits rather than races, and the rotated token is written back in the same transaction that held the lock. Nothing to configure; a connection just stops going stale under concurrent traffic. - ### Knowledge searches instead of guessing A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the diff --git a/agent-bot/src/history.ts b/agent-bot/src/history.ts index b4f09a68..1c7165bb 100644 --- a/agent-bot/src/history.ts +++ b/agent-bot/src/history.ts @@ -63,16 +63,7 @@ export function toProviderMessages( const toolCalls = message.toolCalls?.map((call) => ({ id: call.id, type: "function" as const, - function: { - /* - * A name is required by the provider and is not always present: read back from the thread - * store these arrive undefined, and a payload carrying `"name": undefined` is rejected - * outright. The call still has to be shown, or the model repeats an action it already - * took, so it keeps its id and is named as something the model can read. - */ - name: call.function?.name ?? "tool", - arguments: call.function?.arguments ?? "{}", - }, + function: callDetails(call), })); messages.push({ role: "assistant", @@ -109,3 +100,36 @@ export function toProviderMessages( return messages; } + +/** + * A tool call's name and arguments, in whichever dialect it arrived in. + * + * TWO SPELLINGS, ONE CALL. AG-UI describes `{id, type: "function", function: {name, arguments}}` and + * the history store writes `{id, name, args}`. Read back from a thread, every call arrives in the + * second, so code reaching straight for `call.function` finds nothing there. + * + * That was diagnosed here as "the name is not always present" and papered over with a default, which + * turned every restored call into a tool named `tool` with no arguments. The model is then shown a + * call it cannot recognise as the one it made, so it makes it again: the exact repetition the + * fallback was written to prevent. + */ +function callDetails(call: { + function?: { name?: unknown; arguments?: unknown }; + name?: unknown; + args?: unknown; +}): { name: string; arguments: string } { + const name = call.function?.name ?? call.name; + const args = call.function?.arguments ?? call.args; + return { + // Still defaulted, because a call with no name at all is rejected outright by the provider and + // showing the model something is better than losing the turn. It is now the last resort it was + // meant to be rather than the ordinary path. + name: typeof name === "string" && name ? name : "tool", + arguments: + typeof args === "string" + ? args + : args === undefined || args === null + ? "{}" + : JSON.stringify(args), + }; +} diff --git a/agent-bot/tests/history.test.ts b/agent-bot/tests/history.test.ts index 540d29d8..9aae3b0b 100644 --- a/agent-bot/tests/history.test.ts +++ b/agent-bot/tests/history.test.ts @@ -207,3 +207,47 @@ describe("a history that arrives out of order", () => { expect(assistant.tool_calls?.[0]?.function.name).toBe("tool"); }); }); + +/** + * A call read back from the thread store arrives in the store's dialect, not AG-UI's. + * + * `{id, name, args}` rather than `{id, type, function: {name, arguments}}`. Reaching straight for + * `call.function` finds nothing there, and the default underneath turned every restored call into a + * tool named `tool` with no arguments: the model is shown a call it cannot recognise as the one it + * made, so it makes it again. That is the repetition the default was written to prevent. + */ +describe("a tool call restored from the thread store", () => { + test("keeps the name and arguments it was made with", () => { + const messages = toProviderMessages({ + messages: [ + { + id: "m1", + role: "assistant", + content: null, + toolCalls: [ + { + id: "call_1", + name: "computer_navigate", + args: '{"url":"https://news.ycombinator.com"}', + }, + ], + }, + { + id: "m2", + role: "tool", + toolCallId: "call_1", + content: '{"ok":true}', + }, + ], + } as never); + + const withCalls = messages.find( + (message: Record) => message.tool_calls, + ) as Record; + const call = (withCalls.tool_calls as Array>)[0]; + const fn = call.function as Record; + + expect(fn.name).toBe("computer_navigate"); + expect(fn.arguments).toBe('{"url":"https://news.ycombinator.com"}'); + }); +}); diff --git a/agent-computer/src/profile-listing.ts b/agent-computer/src/profile-listing.ts new file mode 100644 index 00000000..cd00047c --- /dev/null +++ b/agent-computer/src/profile-listing.ts @@ -0,0 +1,38 @@ +/** + * Which entries under the profiles root are Bots. + * + * ITS OWN MODULE SO ONE ANSWER SERVES BOTH SIDES. The rule lived inline in `profiles.ts`, and the + * test that covered it had a second copy: delete the production filter and the suite stayed green, + * because it was checking its own copy rather than the shipped one. A predicate worth testing is + * worth importing. + * + * Free of Playwright on purpose. `profiles.ts` launches browsers, so a test that wanted this rule had + * to drag a browser runtime in with it, which is most of why the copy existed in the first place. + */ +import { isPlainBotId } from "./bot-id"; + +/** The shape of a directory entry, as both `readdir` and a test can supply it. */ +export type ProfileEntry = { name: string; isDirectory: () => boolean }; + +/** + * The Bot ids among a directory listing, sorted and deduplicated. + * + * ONLY ENTRIES THIS CODE COULD HAVE MADE. The root is a mounted volume, and a volume is not an empty + * directory: a real disk formatted ext4 arrives with `lost+found` already in it, so on a cloud the + * fleet page listed a Bot by that name, offered to reset it, and nobody could say where it came from. + * Never seen locally, because a bind mount and kind's local-path volumes have no such directory, + * which is exactly the shape of bug that ships. + * + * `isPlainBotId` is the same allow-list that stops a hostile id becoming a path, used here for the + * other half of the question: an entry it would refuse to create is not one of ours to list. + */ +export function botIdsIn(entries: readonly ProfileEntry[]): string[] { + return [ + ...new Set( + entries + .filter((entry) => entry.isDirectory()) + .filter((entry) => isPlainBotId(entry.name)) + .map((entry) => entry.name), + ), + ].sort(); +} diff --git a/agent-computer/src/profiles.ts b/agent-computer/src/profiles.ts index 420931ee..28f6547f 100644 --- a/agent-computer/src/profiles.ts +++ b/agent-computer/src/profiles.ts @@ -40,6 +40,7 @@ import { profileDirectoryFor } from "./bot-id"; import { chooseEvictions, chooseIdle } from "./browser-eviction"; import { egressFor, egressLabel } from "./egress"; import { numberFromEnv } from "./env"; +import { botIdsIn } from "./profile-listing"; // Re-exported so callers that already import it from here do not change, while the test imports it // from the playwright-free `./env` instead of pulling this module's browser driver in with it. @@ -368,12 +369,11 @@ export function createProfiles(root: string) { const onDisk = await readdir(root, { withFileTypes: true }).catch( () => [], ); - return [ - ...new Set([ - ...onDisk.filter((e) => e.isDirectory()).map((e) => e.name), - ...live.keys(), - ]), - ].sort(); + /* + * The rule lives in its own module, so the test that covers it imports the same one this uses. + * It had a copy before, which meant deleting this filter left the suite green. + */ + return [...new Set([...botIdsIn(onDisk), ...live.keys()])].sort(); }, /** What the admin surface lists. Running or not, because a Bot that has a profile has a computer. */ diff --git a/agent-computer/tests/profile-listing.test.ts b/agent-computer/tests/profile-listing.test.ts new file mode 100644 index 00000000..e790a80c --- /dev/null +++ b/agent-computer/tests/profile-listing.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { isPlainBotId } from "../src/bot-id"; +import { botIdsIn } from "../src/profile-listing"; + +/** + * Which directories under the profiles root are Bots. + * + * The root is a mounted volume, and a volume is not an empty directory. A real disk formatted ext4 + * arrives with `lost+found` already in it, so on a cloud the fleet page listed a Bot by that name and + * offered to reset it. Never seen locally, because a bind mount and kind's local-path volumes have no + * such directory: the bug appears only on the deployment shape the feature is for. + * + * The rule is the one that already exists. `isPlainBotId` decides what may become a profile path, so + * an entry it would refuse to create is not one of ours to list. + */ +async function knownIn(root: string): Promise { + const onDisk = await readdir(root, { withFileTypes: true }).catch(() => []); + /* + * THE SHIPPED PREDICATE, imported rather than restated. + * + * This test used to carry its own copy of the filter, which meant it proved the copy rather than + * the product: deleting the real one left the suite green and the fleet page listing `lost+found` + * as a Bot again. + */ + return botIdsIn(onDisk); +} + +describe("listing the Bots that have a computer", () => { + test("lists Bot profiles and ignores what the filesystem put there", async () => { + const root = await mkdtemp(join(tmpdir(), "profiles-")); + await mkdir(join(root, "knowledge")); + await mkdir(join(root, "risk-analyst")); + // What an ext4 volume brings with it, which is the whole reason this test exists. + await mkdir(join(root, "lost+found")); + // A file is not a computer either. + await writeFile(join(root, "notes.txt"), ""); + + expect(await knownIn(root)).toEqual(["knowledge", "risk-analyst"]); + }); + + test("the name a real volume arrives with is not a usable Bot id", () => { + // Stated directly, because this is the property the filter leans on. + expect(isPlainBotId("lost+found")).toBe(false); + expect(isPlainBotId("knowledge")).toBe(true); + }); +}); diff --git a/agent-langgraph/src/history.ts b/agent-langgraph/src/history.ts index e95f376a..ce16acf4 100644 --- a/agent-langgraph/src/history.ts +++ b/agent-langgraph/src/history.ts @@ -73,11 +73,11 @@ export function toLangChainMessages(input: RunAgentInput): BaseMessage[] { tool_calls: message.toolCalls?.map((call) => ({ id: call.id, - name: call.function.name, + name: callDetails(call).name, // LangChain wants parsed arguments where AG-UI carries the raw string. A call whose // arguments did not parse is passed as empty rather than dropped: the model needs to // see that it made the call, or it makes it again. - args: parseArguments(call.function.arguments), + args: parseArguments(callDetails(call).arguments), })) ?? [], }), ); @@ -95,7 +95,7 @@ export function toLangChainMessages(input: RunAgentInput): BaseMessage[] { new ToolMessage({ tool_call_id: call.id, content: NO_ANSWER_CAME, - name: call.function.name, + name: callDetails(call).name, }), ); } @@ -116,3 +116,28 @@ function parseArguments(raw: string): Record { return {}; } } + +/** + * A tool call's name and arguments, in whichever dialect it arrived in. + * + * TWO SPELLINGS, ONE CALL. AG-UI describes `{id, type: "function", function: {name, arguments}}` and + * the history store writes `{id, name, args}`. Read back from a thread, every call arrives in the + * second, so `call.function.name` here did not merely degrade: it threw, and took the run with it. + */ +function callDetails(call: { + function?: { name?: unknown; arguments?: unknown }; + name?: unknown; + args?: unknown; +}): { name: string; arguments: string } { + const name = call.function?.name ?? call.name; + const args = call.function?.arguments ?? call.args; + return { + name: typeof name === "string" && name ? name : "tool", + arguments: + typeof args === "string" + ? args + : args === undefined || args === null + ? "{}" + : JSON.stringify(args), + }; +} diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx index cfb7c00c..532c85b1 100644 --- a/app/src/components/computer/computer-view.tsx +++ b/app/src/components/computer/computer-view.tsx @@ -7,7 +7,11 @@ import { supplySecret, takeControl, } from "@/lib/computers/control"; -import { readScreenshot, type Screenshot } from "@/lib/computers/screen"; +import { + readPageFrame, + readScreenshot, + type Screenshot, +} from "@/lib/computers/screen"; import { ChannelAvatar } from "../channels/avatar"; import { LiveScreen } from "./live-screen"; @@ -18,6 +22,53 @@ function isBlankBrowser(shot: Screenshot): boolean { return url === "" || url === "about:blank"; } +/** The part of a URL worth putting on screen; the whole thing is rarely readable at this size. */ +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + +/** + * What each finished turn opened, and the frame it ended on, kept outside any component. + * + * MODULE SCOPE, BECAUSE THE TILE DOES NOT SURVIVE. A transcript re-renders freely and remounts the + * tiles in it, and anything held in component state goes with it: the fresh mount has no page yet, + * behaves for one render like a live turn, and reaches for the live screen. Keyed on the tool call, + * which is the identity of the turn rather than of the component drawing it. + * + * Bounded, because a long conversation is a lot of screenshots. Oldest out first, and a turn whose + * frame has been dropped falls back to naming its page. + */ +type RememberedTurn = { + page?: { url?: string; title?: string }; + frame?: { base64: string; url: string }; + /** Whether the server has already been asked, so a turn with no frame is not asked again. */ + asked?: boolean; +}; +const REMEMBERED_TURNS = new Map(); +const MAX_REMEMBERED_TURNS = 40; + +function rememberTurn(toolCallId: string, patch: RememberedTurn): void { + const existing = REMEMBERED_TURNS.get(toolCallId) ?? {}; + /* + * A FRAME IS WRITTEN ONCE, which is what the server's own insert says and what this has to agree + * with. Letting a later write win is exactly what went wrong: the tile restored the right frame and + * then replaced it, one render later, with a screenshot of whatever the Bot had open by then. + */ + const merged: RememberedTurn = { ...existing, ...patch }; + if (existing.frame) merged.frame = existing.frame; + REMEMBERED_TURNS.delete(toolCallId); + REMEMBERED_TURNS.set(toolCallId, merged); + while (REMEMBERED_TURNS.size > MAX_REMEMBERED_TURNS) { + const oldest = REMEMBERED_TURNS.keys().next().value; + if (oldest === undefined) break; + REMEMBERED_TURNS.delete(oldest); + } +} + /** Default browser viewport ratio, reserved before the first screenshot arrives. */ const DEFAULT_ASPECT_RATIO = 1280 / 800; @@ -56,13 +107,50 @@ const SECRET_CONFIRM_MS = 6_000; function NothingToSee({ problem, blankBrowser, + settled, + page, }: { problem: string | null; blankBrowser: boolean; + /** Whether this is a turn that has finished, rather than the browser as it is now. */ + settled?: boolean; + /** The page that turn opened, named when there is no picture of it. */ + page?: { url?: string; title?: string } | undefined; }) { return ( - {problem ? ( + {settled ? ( + <> + {/* + What this turn had open, named rather than drawn. + + The picture is gone: nothing stored it, and fetching one now would show a different page. + Naming the page is the honest version of the same sentence, and it stays true however + many times the Bot has browsed since. + + GATED ON THE TURN BEING OVER, not on whether a live frame happens to be in hand. A tile + that was live a moment ago keeps its last screenshot in state after it settles, and this + used to check for that: with one held and no frame stored, it fell through to "Waiting + for the assistant's screen…" and waited there for ever, because the poll that would have + ended the wait stops the moment a turn settles. + */} + {page?.url ? ( + <> + {page.title || "A page"} + {hostOf(page.url)} + + Opened during this turn. The screen has moved on since. + + + ) : ( + /* + * A turn that ended without getting anywhere: refused by a boundary, stopped, or failed. + * Saying "opened during this turn" here would describe something that did not happen. + */ + This turn did not open a page. + )} + + ) : problem ? ( <> You cannot see the screen right now @@ -94,6 +182,30 @@ type Props = { minHeight?: number; /** Whose screen this is, drawn as a small badge over the frame. Absent, no badge is drawn. */ name?: string; + /** + * The page this turn left the browser on, for a turn that has finished. + * + * A conversation is a record, and a record must not change its mind. Without this, reopening a + * conversation made every past turn fetch the screen as it is now, so an answer about Hacker News + * from an hour ago sat under a picture of whatever the Bot has open today. The frame was live, the + * caption was not, and the turn read as though it had browsed somewhere it never went. + */ + page?: { url?: string; title?: string }; + /** + * Whether the turn this tile belongs to has ended. + * + * SEPARATE FROM HAVING A PAGE. A navigation that was refused, failed or stopped ends without one, + * and a tile that decided history by "do I have a page" left exactly those turns polling the live + * screen for ever, under an answer that had nothing to do with what was on it. + */ + finished?: boolean; + /** + * The tool call this tile belongs to, which is what a kept frame is filed under. + * + * Without it the tile can still name the page; with it, it can show the page. Optional because the + * side panel is not a turn and has nothing to remember. + */ + toolCallId?: string; }; export function ComputerView({ @@ -104,6 +216,9 @@ export function ComputerView({ minWidth = DEFAULT_MIN_WIDTH, minHeight = DEFAULT_MIN_HEIGHT, name, + page, + finished, + toolCallId, }: Props) { const [shot, setShot] = useState(null); const [problem, setProblem] = useState(null); @@ -132,8 +247,76 @@ export function ComputerView({ /** Force a short watch window after non-Bot actions such as secret entry. */ const watchUntil = useRef(0); + /** + * A finished turn is history, and history is not polled. + * + * While a turn runs, the frames are that turn's own and freeze where it left them, which is right. + * Reopening the conversation later is the case this guards: the component mounts with no frame, + * and fetching one would put today's page under yesterday's answer. It shows the page that turn + * actually left open instead, which is the thing being remembered. + * + * `page` is what marks a turn as settled history rather than one still going, so a caller that + * knows nothing about the page keeps the old behaviour and nothing regresses. + * + * DELIBERATELY NOT "AND WE HAVE NO FRAME YET". That is what this said first, and it undid itself: + * restoring the kept frame set the frame, which made the turn stop counting as history, which + * restarted the polling this exists to prevent, which replaced the restored picture with the live + * one. The turn being over is the fact; whether a picture has arrived yet is not. + */ + if (toolCallId && page?.url) rememberTurn(toolCallId, { page }); + const knownPage = + page?.url !== undefined + ? page + : toolCallId + ? REMEMBERED_TURNS.get(toolCallId)?.page + : undefined; + const keptFrame = toolCallId + ? (REMEMBERED_TURNS.get(toolCallId)?.frame ?? null) + : null; + /** Bumped when a frame arrives, because the store it lands in is not React state. */ + const [, setFrameArrived] = useState(0); + + const settled = !active && (finished || Boolean(knownPage)); + + /* + * The frame this turn's page was showing, fetched once and then kept. + * + * A READ, AND ONLY A READ. The tile used to capture the frame itself once the turn went inactive, + * and it kept filing the wrong picture: a reopened turn and one that has just finished look + * identical from in here, the same computer is driven by other conversations between the two, and + * a resumed computer starts blank. The frame is now taken on the server the moment the navigation + * succeeds, which is the one moment the screen is certainly showing the page that was asked for, + * so there is nothing left here to race. + */ + useEffect(() => { + if (!toolCallId || !settled) return; + const remembered = REMEMBERED_TURNS.get(toolCallId); + /* + * Asked once per turn, answer or not. Without remembering the empty answer, every turn from + * before this shipped refetched nothing on every remount, which on a long transcript is one + * pointless request per turn per scroll. + */ + if (remembered?.frame || remembered?.asked) return; + let current = true; + + void (async () => { + const stored = await readPageFrame(computerId, toolCallId); + if (!current) return; + rememberTurn(toolCallId, { + asked: true, + ...(stored ? { frame: { base64: stored.frame, url: stored.url } } : {}), + }); + if (stored) setFrameArrived((n) => n + 1); + })(); + + return () => { + current = false; + }; + }, [computerId, toolCallId, settled]); + // biome-ignore lint/correctness/useExhaustiveDependencies: `secretPending` intentionally restarts settled polling. useEffect(() => { + if (settled) return; const mine = ++generation.current; let timer: ReturnType; // Consecutive identical frames observed during post-action settling. @@ -181,10 +364,11 @@ export function ComputerView({ generation.current++; clearTimeout(timer); }; - }, [computerId, active, intervalMs, secretPending]); + }, [computerId, active, intervalMs, secretPending, settled]); /** Poll control state independently from screenshot polling so help/secret prompts surface. */ useEffect(() => { + if (settled) return; let live = true; let timer: ReturnType; const tick = async () => { @@ -198,7 +382,7 @@ export function ComputerView({ live = false; clearTimeout(timer); }; - }, [computerId]); + }, [computerId, settled]); // Input forwarding lives in LiveScreen on the socket. // Escape is bound to the window so it works regardless of overlay focus. @@ -212,7 +396,11 @@ export function ComputerView({ }, [expanded]); // Always render the card frame; help/secret controls live below the conditional picture. - const blankBrowser = shot ? isBlankBrowser(shot) : false; + /* + * A finished turn is never "blank": it opened a page, and that is what it shows or names. Only a + * live browser can be sitting on about:blank. + */ + const blankBrowser = !settled && shot ? isBlankBrowser(shot) : false; /* * Sized from the ratio, never from the payload, so the frame is identical while a screen is @@ -221,8 +409,19 @@ export function ComputerView({ * surface whose whole job is showing a screen kept surprising the layout around it. */ const frameStyle = { aspectRatio, minWidth, minHeight }; + /** + * What this tile draws: the kept frame for a turn that is over, the live one while it runs. + * + * A finished turn never draws `shot`. It may hold one, caught in the render between mounting and + * its result arriving, and that frame is of whatever the Bot has open now rather than of this turn. + */ + const drawn = settled + ? keptFrame + : shot + ? { base64: shot.base64, url: shot.url ?? "" } + : null; /** Whether there is a page to draw. A blank browser and an unreadable screen are both "no". */ - const showScreen = shot !== null && !blankBrowser; + const showScreen = drawn !== null && !blankBrowser; /** * Whether the full-size view has a stream worth opening. * @@ -230,11 +429,21 @@ export function ComputerView({ * gets the live socket whatever is on it, because once a person is driving the stream is the truth * about the page and a placeholder over it would be the view arguing with them. */ - const showLiveScreen = showScreen || driving; + const showLiveScreen = !settled && (showScreen || driving); + /** + * Whether the wheel in somebody's hands is the wheel THIS tile is showing. + * + * A person can take control mid-navigation, and the turn then settles under them. `driving` stays + * true, because it is true: they are driving the browser. It is just not the browser in this + * picture any more. Left ungated, the frozen tile asserted "You have control" over a page from an + * hour ago, with the hand-back footer already gone and the backdrop refusing to close because it + * believed somebody was driving it. + */ + const wheelHere = driving && !settled; const polledScreen = showScreen ? ( What the assistant is looking at {name ? ( @@ -270,7 +479,7 @@ export function ComputerView({ {name} ) : null} - {driving ? ( + {wheelHere ? ( You have control @@ -279,7 +488,12 @@ export function ComputerView({ ) : null} {showScreen ? null : ( - + )} @@ -293,7 +507,7 @@ export function ComputerView({ * view to find out what was wanted would hide the reason behind a click. Taking the wheel * from here opens that view, because driving is what they are being asked to do. */} - {!driving && control?.requested ? ( + {!driving && !settled && control?.requested ? (
The assistant needs you.{" "} @@ -387,14 +601,17 @@ export function ComputerView({ aria-label="The assistant's screen" className="fixed inset-0 z-50 flex flex-col items-center justify-center p-4 sm:p-8" > - {/* Backdrop closes only while read-only; during driving, Escape remains the exit. */} + {/* + Backdrop closes only while read-only; during driving, Escape remains the exit. A + turn that is over is always read-only, whoever is holding the live browser. + */} - ) : ( - - )} -
+ + ) : ( + + )} + + )} , document.body, diff --git a/app/src/lib/computers/screen.ts b/app/src/lib/computers/screen.ts index 874a5438..df3025e2 100644 --- a/app/src/lib/computers/screen.ts +++ b/app/src/lib/computers/screen.ts @@ -40,3 +40,31 @@ export async function readScreenshot( return { error: unavailable }; } } + +/** The frame a page was showing when a Bot opened it. */ +export type PageFrame = { url: string; title: string | null; frame: string }; + +/** + * What this turn had on screen when it opened its page, or nothing if it was never kept. + * + * Nothing here writes. The frame is taken on the server at the moment the navigation succeeds, which + * is the only moment the screen is certainly showing the page that was asked for. Capturing it here + * instead meant capturing it after the turn, from a computer other conversations are also driving, + * and filing whatever it happened to show. + */ +export async function readPageFrame( + computerId: string, + toolCallId: string, +): Promise { + try { + const response = await tryClient( + `/api/computers/${computerId}/page-frame/${encodeURIComponent(toolCallId)}`, + ); + if (!response.ok) return null; + const body = (await response.json()) as { frame?: PageFrame | null }; + return body.frame ?? null; + } catch { + // A missing picture is a smaller sentence, not a broken conversation. + return null; + } +} diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index c4087b49..20d9f75d 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -126,6 +126,9 @@ type ComputerOutcome = { bytes?: number; /** A file read. Named `text` on the way back and `contents` on the way in. */ text?: string; + /** Where a navigation landed, which is what a finished turn's screen tile remembers. */ + url?: string; + title?: string; }; /** @@ -249,7 +252,10 @@ export function ComputerTools() { handler: async ( { url }: { url: string }, // Context is optional in the SDK. - { signal }: { signal?: AbortSignal } = {}, + { + signal, + toolCall, + }: { signal?: AbortSignal; toolCall?: { id?: string } } = {}, ) => { const computerId = bot.current; const result = await callComputer( @@ -257,7 +263,15 @@ export function ComputerTools() { "/navigate", { method: "POST", - body: { url }, + /* + * Which turn is asking, so the server can file the picture under it. + * + * The handler's context carries the tool call, which is worth saying because assuming it + * did not is how the frame ended up keyed on the page instead: two visits to one address + * then collided, and resolving that by letting the newer win made a past turn's picture + * change under the person reading it. + */ + body: { url, ...(toolCall?.id ? { toolCallId: toolCall.id } : {}) }, }, signal, ); @@ -279,11 +293,50 @@ export function ComputerTools() { } : result; }, - render: ({ status }) => ( -
- -
- ), + render: ({ result, status, toolCallId }) => { + /* + * The page this turn left open, so reopening the conversation shows what it browsed rather + * than what the Bot has open now. Only once the turn is finished: while it runs, the live + * frames are its own. + */ + /* + * A RESULT IS WHAT MAKES A TURN OVER, not the status. + * + * A restored tool call arrives with its result already in hand and a status that is briefly + * something other than complete, so keying on the status alone made every reopened turn look + * like one still running: the tile polled the live screen, put today's page under yesterday's + * answer, and only then restored the frame it should have shown from the start. + */ + const finished = status === "complete" || result !== undefined; + const outcome = finished ? outcomeOf(result) : {}; + const page = + typeof outcome.url === "string" + ? { + url: outcome.url, + ...(typeof outcome.title === "string" + ? { title: outcome.title } + : {}), + } + : undefined; + return ( +
+ +
+ ); + }, }); useFrontendTool({ diff --git a/app/src/lib/copilot/thread-messages.ts b/app/src/lib/copilot/thread-messages.ts index 887ecec3..d6d35e97 100644 --- a/app/src/lib/copilot/thread-messages.ts +++ b/app/src/lib/copilot/thread-messages.ts @@ -11,14 +11,25 @@ import { tryClient } from "@/lib/client"; * * WHAT ARRIVES HERE IS NOT TRUSTED. This used to end `stored as Message[]`, which is a cast rather * than a check: whatever the history store held was handed to `setMessages` and then to every - * projection that reads a transcript. A turn shaped differently — a tool call persisted as - * `{id, name, args}` instead of AG-UI's `{id, type: "function", function: {…}}`, which interrupted - * runs have produced — reached a renderer that dereferenced `toolCall.function.arguments` and took - * the whole conversation down with it. One bad turn made a thread unreadable. + * projection that reads a transcript. A turn shaped differently reached a renderer that dereferenced + * `toolCall.function.arguments` and took the whole conversation down with it. One bad turn made a + * thread unreadable. * * So each turn is parsed against the schema AG-UI ships, and one that does not parse is left out. * Checked here rather than in a projection because there are several projections and one history: * fixing it in the reader that is closest to the wire is what makes every consumer safe at once. + * + * BUT `{id, name, args}` IS NOT A CORRUPTION, AND TREATING IT AS ONE DELETED REAL WORK. That shape + * was read as damage from an interrupted run and dropped. It is how the runtime persists every tool + * call it stores, so dropping it meant every turn in which a Bot used a tool vanished on reload: the + * transcript kept the sentence the Bot wrote and lost the browsing that produced it, the inline + * screen went with it, and the footer said some messages could not be read. Observed against a live + * thread, where every browsing turn was counted unreadable and every one of them was well formed in + * the store's own dialect. + * + * So it is translated rather than refused. The check stays for turns that really are malformed; a + * reader is entitled to insist on one shape, but not to throw away the history because the writer + * spells it another way. */ /** @@ -52,8 +63,11 @@ export function readableTurns(stored: readonly unknown[]): StoredThread { let unreadable = 0; for (const turn of stored) { - if (MessageSchema.safeParse(turn).success) { - messages.push(turn as Message); + const candidate = withNormalisedToolCalls( + withoutNullAssistantContent(turn), + ); + if (MessageSchema.safeParse(candidate).success) { + messages.push(candidate as Message); } else { unreadable += 1; } @@ -62,6 +76,89 @@ export function readableTurns(stored: readonly unknown[]): StoredThread { return { messages, unreadable }; } +/** + * An assistant turn whose content is `null`, read as one that simply has no content. + * + * The schema makes an assistant's content optional and does not allow it to be null, so the two say + * the same thing and only one parses. A turn that called a tool and said nothing alongside it is + * written exactly that way, so this dropped the browsing and kept nothing in its place: the same + * loss the tool-call dialect caused, arriving by a different route. + * + * ASSISTANT ONLY. A user turn's content is required, and `content: null` there is not a message + * somebody sent; it used to reach a projection and draw as a blank line, which is why it is refused + * and counted rather than quietly shown. That decision stands. + */ +function withoutNullAssistantContent(turn: unknown): unknown { + if (typeof turn !== "object" || turn === null) return turn; + const record = turn as Record; + if (record.role !== "assistant" || record.content !== null) return turn; + const { content: _dropped, ...rest } = record; + return rest; +} + +/** A tool call as the history store writes one. */ +type StoredToolCall = { id?: unknown; name?: unknown; args?: unknown }; + +/** + * The store's dialect for a tool call, in the shape AG-UI describes. + * + * `{id, name, args}` becomes `{id, type: "function", function: {name, arguments}}`. Only the array is + * rebuilt and only when every entry is in that dialect: a turn already in AG-UI's shape is returned + * untouched, and a mixed or unrecognised array is left exactly as it came so the parse below still + * refuses it rather than this quietly inventing something. + * + * The rest of the message is spread through unchanged, for the same reason `parsed.data` is not used + * anywhere here: a reader that rewrites what it does not recognise is worse than one that refuses it. + */ +function withNormalisedToolCalls(turn: unknown): unknown { + if (typeof turn !== "object" || turn === null) return turn; + const calls = (turn as { toolCalls?: unknown }).toolCalls; + if (!Array.isArray(calls) || calls.length === 0) return turn; + + const isStoredDialect = (call: unknown): call is StoredToolCall => + typeof call === "object" && + call !== null && + "name" in call && + "args" in call && + !("function" in call); + if (!calls.every(isStoredDialect)) return turn; + + return { + ...(turn as Record), + toolCalls: calls.map((call) => ({ + id: call.id, + type: "function", + function: { name: call.name, arguments: argumentsOf(call.args) }, + })), + }; +} + +/** + * The arguments, as a string, because that is what the protocol says they are. + * + * AG-UI types `arguments` as a string and the store is under no such obligation: it holds whatever + * the run put there, which for a tool called with structured input is an object. Passing that through + * produced a call that looked translated and still failed validation, so the turn was dropped anyway. + * That is this whole function's bug one layer down, which is a good reason to be explicit here rather + * than to trust the shapes to line up. + * + * A string is already right and is left exactly as it is, down to its whitespace: it may be a + * fragment of a stream that was never valid JSON, and re-encoding it would change what the model + * actually said. Anything else is encoded. `undefined` becomes `"{}"`, which is what a call with no + * arguments means and what every reader of this field expects to parse. + */ +function argumentsOf(args: unknown): string { + if (typeof args === "string") return args; + if (args === undefined || args === null) return "{}"; + try { + return JSON.stringify(args); + } catch { + // Circular, or something else that cannot be encoded. An empty object is a call the reader can + // parse; a throw here would lose the whole conversation over one malformed argument list. + return "{}"; + } +} + export async function readThreadMessages( threadId: string, agentId: string, diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx index 620b2e96..b29f1322 100644 --- a/app/src/routes/_authed/_app/bot.tsx +++ b/app/src/routes/_authed/_app/bot.tsx @@ -1,7 +1,9 @@ import { CopilotChat } from "@copilotkit/react-core/v2"; import { IconPlus } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; +import { agentListQueryOptions } from "@/lib/agents/queries"; import { useActiveBot } from "@/lib/copilot/active-bot"; import { useBotThread } from "@/lib/copilot/bot-thread"; import { useStoppedTurn } from "@/lib/copilot/stopped-turn"; @@ -13,10 +15,45 @@ export const Route = createFileRoute("/_authed/_app/bot")({ }), }); +/** + * Which Bot this screen is for. + * + * WHATEVER THIS DEPLOYMENT ACTUALLY HAS. The default used to be a hardcoded `risk-analyst`, a name + * from a tenant package this one is not: on a clone that ships anything else, opening this screen + * without naming a Bot took the whole page down to an unstyled error boundary, because the chat + * throws when asked for an agent the runtime never synced. OpenBot exists to be forked, so a Bot + * name written into a route is a defect on every fork but the one it came from. + * + * A named Bot that this deployment does not have is answered in a sentence rather than thrown, + * for the same reason: a mistyped link is not a crash. + */ function RouteComponent() { const { agent } = Route.useSearch(); - const agentId = agent ?? "risk-analyst"; + const { data: agents, isPending } = useQuery(agentListQueryOptions()); + const agentId = agent ?? agents?.[0]?.id; + const known = agents?.some((candidate) => candidate.id === agentId) ?? false; + if (isPending) return null; + if (!agentId || !known) { + return ( +
+

+ {agent + ? `This deployment has no Bot called "${agent}".` + : "This deployment has no Bots yet."} +

+
+ ); + } + + /* + * Keyed on the Bot, so the hooks below never see it change under them. They cannot be called + * conditionally, and the guards above return before any of them run. + */ + return ; +} + +function BotChat({ agentId }: { agentId: string }) { // Tool calls here act on this Bot's own computer. useActiveBot(agentId); /* diff --git a/app/tests/thread-messages.test.ts b/app/tests/thread-messages.test.ts index faa8f831..badce946 100644 --- a/app/tests/thread-messages.test.ts +++ b/app/tests/thread-messages.test.ts @@ -1,159 +1,252 @@ import { describe, expect, test } from "bun:test"; -import { readableTurns } from "@/lib/copilot/thread-messages"; +import { readableTurns } from "../src/lib/copilot/thread-messages"; /** - * What comes back out of the history store, and what is refused at the door. + * Reading back a conversation that used a tool. * - * The old reader cast whatever it was given to `Message[]`, so a turn the store held in some other - * shape reached every projection that draws a transcript — and one of them dereferenced - * `toolCall.function.arguments`, which took the conversation down rather than the turn. - * - * These are the shapes that have actually been seen, not invented ones: the tool call persisted as - * `{id, name, args}` comes from #199, which found seventeen of them in one thread after interrupted - * runs, and the content shapes come from the tests on #43. + * The shapes below are copied from a live thread rather than invented. The store writes a tool call + * as `{id, name, args}`; AG-UI describes `{id, type: "function", function: {name, arguments}}`. A + * reader that insists on the second and refuses the first throws away every turn in which a Bot did + * anything, which is the half of the conversation worth keeping. */ - -const userTurn = { id: "m1", role: "user", content: "What did I miss?" }; - -const assistantTurn = { - id: "m2", - role: "assistant", - content: "Here is the summary.", +const userTurn = { + id: "6953d56c", + role: "user", + content: "open hackernews.com and tell me the top 3 stories", }; -/** As AG-UI defines a tool call: a literal `function` type, with the call nested under it. */ -const wellFormedToolCall = { - id: "m3", +/** As the history store writes it. */ +const storedToolCall = { + id: "0fe7b049", role: "assistant", toolCalls: [ { - id: "call_1", - type: "function", - function: { name: "search_files", arguments: "{}" }, + id: "call_maB4q3", + name: "computer_navigate", + args: '{"url":"https://news.ycombinator.com"}', }, ], }; -describe("reading back a stored thread", () => { - test("an ordinary conversation comes back whole, with nothing counted", () => { - const { messages, unreadable } = readableTurns([userTurn, assistantTurn]); - expect(messages).toHaveLength(2); +const toolResult = { + id: "aa5e9452", + role: "tool", + toolCallId: "call_maB4q3", + content: '{"ok":true,"title":"Hacker News"}', +}; + +const answer = { id: "5c1f", role: "assistant", content: "Top 3 stories…" }; + +describe("restoring a conversation that used a tool", () => { + test("a browsing turn survives the read", () => { + const { messages, unreadable } = readableTurns([ + userTurn, + storedToolCall, + toolResult, + answer, + ]); + + // Every one of them, and the tool call above all: without it the transcript keeps the sentence + // the Bot wrote and loses the browsing that produced it. + expect(messages).toHaveLength(4); expect(unreadable).toBe(0); }); - test("a well-formed tool call survives", () => { - // The shape the transcript knows how to draw. If validation rejected this, the fix would have - // traded a crash for an empty conversation. - const { messages, unreadable } = readableTurns([wellFormedToolCall]); - expect(messages).toHaveLength(1); - expect(unreadable).toBe(0); + test("the tool call comes back in the shape every renderer reads", () => { + const { messages } = readableTurns([storedToolCall]); + expect(messages[0]).toMatchObject({ + role: "assistant", + toolCalls: [ + { + id: "call_maB4q3", + type: "function", + function: { + name: "computer_navigate", + arguments: '{"url":"https://news.ycombinator.com"}', + }, + }, + ], + }); }); - test("a tool call stored the LangChain way is dropped and counted", () => { - /* - * The turn from #199: `{id, name, args}` rather than `{id, type: "function", function: {…}}`. - * This is the one that crashed a renderer reading `toolCall.function.arguments`, so the whole - * turn has to not arrive — and the count is what stops it vanishing quietly. - */ - const langChainShaped = { - id: "m4", + test("a call already in AG-UI's shape is left alone", () => { + const already = { + id: "x", role: "assistant", - toolCalls: [{ id: "call_2", name: "search_files", args: {} }], + toolCalls: [ + { + id: "c1", + type: "function", + function: { name: "computer_click", arguments: "{}" }, + }, + ], }; + const { messages, unreadable } = readableTurns([already]); + expect(unreadable).toBe(0); + expect(messages[0]).toEqual(already as never); + }); - const { messages, unreadable } = readableTurns([ - userTurn, - langChainShaped, - assistantTurn, - ]); - - expect(messages.map((message) => message.id)).toEqual(["m1", "m2"]); + test("a turn that is genuinely malformed is still refused", () => { + /* + * The guard is not being removed, only taught a second spelling. A tool call with neither shape + * is something no renderer can draw, and letting it through is how one bad turn used to take a + * whole conversation down. + */ + const nonsense = { id: "y", role: "assistant", toolCalls: [{ id: "c2" }] }; + const { messages, unreadable } = readableTurns([nonsense]); + expect(messages).toHaveLength(0); expect(unreadable).toBe(1); }); - test("multimodal content is not mistaken for a malformed turn", () => { - // AG-UI allows content as typed parts as well as a string, so a turn carrying an image is - // ordinary. Rejecting it would lose real messages in the name of safety. - const withParts = { - id: "m5", - role: "user", - content: [{ type: "text", text: "What is in this?" }], + test("a mixed array is refused rather than half-translated", () => { + // Guessing at half of it would be this file inventing history rather than reading it. + const mixed = { + id: "z", + role: "assistant", + toolCalls: [ + { id: "a", name: "one", args: "{}" }, + { + id: "b", + type: "function", + function: { name: "two", arguments: "{}" }, + }, + ], }; - const { messages, unreadable } = readableTurns([withParts]); - expect(messages).toHaveLength(1); + expect(readableTurns([mixed]).unreadable).toBe(1); + }); + + test("everything else passes through untouched", () => { + const { messages, unreadable } = readableTurns([userTurn, answer]); expect(unreadable).toBe(0); + expect(messages[0]).toEqual(userTurn as never); }); +}); - test("content that is not content is dropped rather than drawn as empty", () => { +/** + * The cases the rewrite dropped, plus the one it never had. + * + * A reader that translates between two dialects is exactly where a quiet data-loss bug lives, and + * these are the shapes a real thread contains: arguments the store kept as an object, content that + * is a list of parts rather than a string, a turn with no content at all, and an order that has to + * survive the trip because a conversation read out of sequence is not the conversation. + */ +describe("shapes a real thread contains", () => { + test("arguments the store kept as an object become a string", () => { + const [turn] = readableTurns([ + { + id: "m1", + role: "assistant", + toolCalls: [ + { id: "c1", name: "computer_navigate", args: { url: "https://x" } }, + ], + }, + ]).messages as Array>; + + const call = (turn.toolCalls as Array>)[0]; + const fn = call.function as Record; /* - * From the #43 cases. These used to reach a projection and resolve to an empty message, so the - * transcript showed a turn that said nothing and read as though somebody had sent a blank line. - * Refused here instead, and reported. + * AG-UI types this as a string. Passing the object through produced a call that looked + * translated and still failed validation, so the turn was dropped anyway: this function's own + * bug, one layer down. */ - const shapes = [ - { id: "a", role: "user" }, - { id: "b", role: "user", content: null }, - { id: "c", role: "user", content: 42 }, - { id: "d", role: "user", content: [null, "text", 7] }, - ]; - - const { messages, unreadable } = readableTurns(shapes); - expect(messages).toEqual([]); - expect(unreadable).toBe(4); + expect(typeof fn.arguments).toBe("string"); + expect(JSON.parse(fn.arguments as string)).toEqual({ url: "https://x" }); }); - test("a turn that is not an object at all is dropped", () => { + test("a string of arguments is passed through exactly", () => { + const [turn] = readableTurns([ + { + id: "m1", + role: "assistant", + toolCalls: [{ id: "c1", name: "t", args: '{"url": "https://x"}' }], + }, + ]).messages as Array>; + + const call = (turn.toolCalls as Array>)[0]; + // Down to the whitespace: it may be a fragment of a stream that was never valid JSON, and + // re-encoding it would change what the model actually said. + expect((call.function as Record).arguments).toBe( + '{"url": "https://x"}', + ); + }); + + /* + * A call with no arguments at all. Not `args` missing entirely, which the dialect check refuses on + * purpose so that it never rewrites something that was not a stored call in the first place. + */ + test("a call with empty arguments becomes something a reader can parse", () => { + const [turn] = readableTurns([ + { + id: "m1", + role: "assistant", + toolCalls: [{ id: "c1", name: "t", args: null }], + }, + ]).messages as Array>; + + const call = (turn.toolCalls as Array>)[0]; + expect((call.function as Record).arguments).toBe("{}"); + }); + + /* + * A turn that called a tool and said nothing alongside it is written exactly this way, and it was + * being dropped: the same loss the tool-call dialect caused, arriving by a different route. The + * schema makes an assistant's content optional and does not allow null, so the two say the same + * thing and only one parsed. + */ + test("an assistant turn that said nothing while it worked survives", () => { const { messages, unreadable } = readableTurns([ - null, - "a string", - 7, - userTurn, + { + id: "m1", + role: "assistant", + content: null, + toolCalls: [{ id: "c1", name: "computer_navigate", args: "{}" }], + }, ]); - expect(messages.map((message) => message.id)).toEqual(["m1"]); - expect(unreadable).toBe(3); + + expect(messages).toHaveLength(1); + expect(unreadable).toBe(0); }); - test("a turn with no recognised role is dropped", () => { - // The schema is a union on `role`, so an unknown one matches no member. + /* + * And a person's turn is not the same case. Content is required there, so `null` is not a message + * somebody sent: it used to reach a projection and draw as a blank line. Refused and counted, so + * the surface can say so, which is the decision #207 made and this does not disturb. + */ + test("a person's turn with no content is still refused and counted", () => { const { messages, unreadable } = readableTurns([ - { id: "x", role: "narrator", content: "once upon a time" }, + { id: "m1", role: "user", content: null }, ]); + expect(messages).toEqual([]); expect(unreadable).toBe(1); }); - test("order is the stored order, so a dropped turn does not reshuffle the rest", () => { - const { messages } = readableTurns([ - assistantTurn, - { id: "bad", role: "assistant", toolCalls: [{ id: "c", name: "n" }] }, - userTurn, - ]); - expect(messages.map((message) => message.id)).toEqual(["m2", "m1"]); - }); + test("content that is a list of parts survives", () => { + const content = [{ type: "text", text: "What is in this?" }]; - test("a turn that parses keeps the fields the schema does not name", () => { - /* - * The reason the original object is returned rather than `parsed.data`. Zod strips unknown keys, - * so handing back the parsed copy would make this a silent rewrite of every message that passed - * — dropping whatever the runtime carries that this file has not heard of. - */ - const carrying = { ...userTurn, somethingTheRuntimeAdded: "keep me" }; - const { messages } = readableTurns([carrying]); - expect( - (messages[0] as unknown as { somethingTheRuntimeAdded?: string }) - .somethingTheRuntimeAdded, - ).toBe("keep me"); - }); + const { messages, unreadable } = readableTurns([ + { id: "m1", role: "user", content }, + ]); - test("an empty history is not a failure", () => { - expect(readableTurns([])).toEqual({ messages: [], unreadable: 0 }); + expect(messages).toHaveLength(1); + expect(unreadable).toBe(0); }); - test("a thread where nothing parses reports every turn", () => { - // The case that must not read as "this conversation is empty": the transcript has nothing to - // draw, so the count is the only thing that tells the person their history is still there. - const { messages, unreadable } = readableTurns([{ nope: true }, 1, null]); - expect(messages).toEqual([]); - expect(unreadable).toBe(3); + test("the order of the conversation is the order it came in", () => { + const read = readableTurns([ + { id: "m1", role: "user", content: "one" }, + { + id: "m2", + role: "assistant", + toolCalls: [ + { id: "c1", name: "computer_navigate", args: { url: "u" } }, + ], + }, + { id: "m3", role: "assistant", content: "three" }, + ]).messages as Array>; + + expect(read.map((m) => m.role)).toEqual(["user", "assistant", "assistant"]); + expect(read[0]?.content).toBe("one"); + expect(read[2]?.content).toBe("three"); }); }); diff --git a/charts/openbot/.helmignore b/charts/openbot/.helmignore new file mode 100644 index 00000000..8eb5223f --- /dev/null +++ b/charts/openbot/.helmignore @@ -0,0 +1,5 @@ +.DS_Store +.git/ +.gitignore +*.tmproj +ci/ diff --git a/charts/openbot/Chart.lock b/charts/openbot/Chart.lock new file mode 100644 index 00000000..0b0c959b --- /dev/null +++ b/charts/openbot/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: postgresql + repository: oci://registry-1.docker.io/bitnamicharts + version: 16.7.27 +digest: sha256:6fbb2d72eeffa5642d7158c0c7aaad596851e65272e3dc3411ce74c6f580b8e1 +generated: "2026-08-25T12:55:25.336493-07:00" diff --git a/charts/openbot/Chart.yaml b/charts/openbot/Chart.yaml new file mode 100644 index 00000000..b6e4f3d8 --- /dev/null +++ b/charts/openbot/Chart.yaml @@ -0,0 +1,31 @@ +apiVersion: v2 +name: openbot +description: Run OpenBot on any Kubernetes cluster, managed or your own +type: application +# The chart's own version, bumped when templates or defaults change. +version: 0.1.0 +# The OpenBot release this chart's default image tag points at. +appVersion: "0.0.4" +home: https://github.com/CopilotKit/OpenBot +sources: + - https://github.com/CopilotKit/OpenBot +keywords: + - openbot + - copilotkit + - agents +maintainers: + - name: CopilotKit + url: https://github.com/CopilotKit +dependencies: + # BUNDLED FOR SOMEBODY TRYING IT, OFF FOR ANYBODY WITH A DATABASE. A deployment with RDS, Cloud SQL + # or Azure Database sets `postgresql.enabled: false` and a connection URL, which is the same line + # the Intelligence chart draws, so somebody who has deployed that does not have to relearn it. + - name: postgresql + # EXACT, NOT A RANGE. `~16` let a subchart patch arrive on its own, and a subchart patch carries a + # new PostgreSQL image tag. That image comes from Bitnami's frozen `bitnamilegacy` mirror, which + # is not gaining new tags, so the next patch is one an install cannot pull. CI renders this chart + # and never pulls from it, so nothing here would have said a word: the first sign would have been + # somebody's install sitting in ImagePullBackOff. Bump it deliberately, with the tag below. + version: "16.7.27" + repository: "oci://registry-1.docker.io/bitnamicharts" + condition: postgresql.enabled diff --git a/charts/openbot/README.md b/charts/openbot/README.md new file mode 100644 index 00000000..e783de07 --- /dev/null +++ b/charts/openbot/README.md @@ -0,0 +1,225 @@ +# OpenBot on Kubernetes + +Runs OpenBot on any Kubernetes cluster: EKS, GKE, AKS, or your own. One chart, four targets, and the +only difference between them is values. + +## Install + +The bundled database and one administrator, which is the shortest thing that works: + +```sh +helm dependency build charts/openbot +helm upgrade --install openbot charts/openbot \ + --namespace openbot --create-namespace \ + --set postgresql.enabled=true \ + --set config.initialAdminEmails=you@example.com \ + --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" +``` + +`secrets.keyEncryptionKey` encrypts the credential vault. Generate it once, keep it, and do not put +it in a file anybody commits. The chart marks the Secret it creates `helm.sh/resource-policy: keep`, +so an uninstall does not take the key that every stored credential was encrypted with. + +## What the defaults assume + +**A plain cluster with no cloud features.** The cluster's own default StorageClass, no RuntimeClass, +a plain Kubernetes Secret, an Ingress. There is no cloud branching anywhere in the templates and +there should never be. A deployment on a managed cluster turns things on; a self-hosted one changes +nothing and still works. + +Two replicas by default, because horizontal is the point. Everything that has to survive a replica is +in PostgreSQL, and one replica hides every bug that is not. + +**No browser in the API pod.** The image runs a Bot's computer beside the API so that one container +works on its own. A replica must not carry one: a browser is a few hundred megabytes holding one +Bot's logins, so scaling the API would scale those with it. `server.embeddedComputer` is off here, +and asking for it with more than one replica is refused at install time. + +## Your own database, which is what a real deployment uses + +```sh +--set postgresql.enabled=false \ +--set database.existingSecret=openbot-database # key: database-url +``` + +`postgresql.enabled` is **off by default and not production-grade**. A database on a pod goes away +when the pod does: a rollout, a node drain or an eviction is a restart, and while the volume survives, +nothing about that shape gives you backups, failover or point-in-time recovery. It is there so +somebody can try OpenBot in one command. + +Point it at RDS, Cloud SQL, Azure Database or your own server, and keep the URL in a Secret rather +than in a values file. Setting both a bundled database and a URL is refused, rather than one of them +silently winning. + +**Put `?sslmode=require` on the URL.** Every managed database refuses an unencrypted connection: +RDS has `rds.force_ssl` on by default, and Cloud SQL and Azure Database do the same. Without it the +migration fails with `no pg_hba.conf entry for host ... no encryption`, which names the host and the +user and not the actual problem. + +**The migrating role has to be able to create and drop the `vector` extension.** The first migration +creates it and a later one drops it again. On a managed database, create it once as the +administrative role; `CREATE EXTENSION IF NOT EXISTS` then passes for an ordinary user. + +## The four targets + +`ci/` holds a values file per target, and each is the shortest thing that expresses what is different +about that cluster: + +| File | What it shows | +| --- | --- | +| `self-hosted-values.yaml` | Nothing turned on. If this file needs to grow, a default is wrong. | +| `eks-values.yaml` | IRSA, Secrets Manager, ALB, zone spread, autoscaling. | +| `gke-values.yaml` | Workload Identity, Secret Manager, Gateway API instead of an Ingress. | +| `aks-values.yaml` | Workload identity, Key Vault, the AKS web app routing class. | + +Render any of them without a cluster: + +```sh +helm template openbot charts/openbot -f charts/openbot/ci/eks-values.yaml +``` + +### Identity, in one map + +IRSA on EKS, Workload Identity on GKE and workload identity on AKS are all annotations on a +ServiceAccount, so `serviceAccount.annotations` covers all three and the chart needs no idea which +cloud it is on. + +### Secrets, without a vendor + +A plain Kubernetes Secret is the default, because that is what a self-hosted cluster has. Setting +`externalSecrets.enabled` turns the same keys into an ExternalSecret against whatever store the +cluster has, so Secrets Manager, Secret Manager and Key Vault are a values block rather than three +code paths. + +### Check for a default StorageClass first + +A fresh EKS cluster very often has none. `eksctl` creates `gp2`, which is not marked default and uses +the in-tree `kubernetes.io/aws-ebs` provisioner that current Kubernetes no longer has. A volume asking +for "the default" then never binds, the computer sits `Pending`, and nothing says why. One line tells +you: + +```sh +kubectl get sc +``` + +Either create a default class backed by `ebs.csi.aws.com`, or set `computers.persistence.storageClass` +and `postgresql.primary.persistence.storageClass` to one that exists. `ci/eks-values.yaml` does the +second. + +`volumeBindingMode: WaitForFirstConsumer` matters on every cloud: without it the volume is created in +a zone chosen before the pod is scheduled, and pods stick unschedulable with a node-affinity conflict. +That only happens in multi-zone clusters, so it passes every single-zone test. + +### Storage has gravity + +The API tier holds nothing on disk. When per-Bot computers arrive they will, and the ordinary block +volume on all three clouds is **zonal**: once provisioned, every pod referencing it is scheduled into +that zone, so a Bot's computer is pinned to a zone for as long as its profile exists. That is +acceptable and worth stating rather than discovering. `storageClass` stays empty by default, meaning +the cluster's default class, because naming `gp3` or `pd-balanced` here is how a chart stops +installing on somebody's bare-metal cluster. + +## Refused at install, not in a crash loop + +The chart fails the install, naming the value to change, when: there is no database or two of them; +nobody would be an administrator; `singleUser` is combined with a public URL; both an Ingress and an +HTTPRoute are enabled; both `externalSecrets` and an existing Secret are named; a Bot endpoint is +named with no token to call it with; or a browser is asked for inside more than one API replica. + +## Your own Bot + +OpenBot is a shell for somebody else's agent, and `config.managedAgent.url` is where that agent goes: +an AG-UI endpoint the server pod can reach, so a Service in this cluster rather than localhost. + +```yaml +config: + managedAgent: + url: http://my-agent.my-namespace:8000/ag-ui +secrets: + managedAgentToken: +``` + +The token travels on every call and is required whenever a url is set. With an existing Secret or an +external store, the key is `managed-agent-token`. + +Left empty, this deployment has the Bots its tenant package declares as built-in and no others. A +package entry pointing at an endpoint that resolves to nothing is dropped rather than registered as a +coworker nobody can talk to. + +## Upgrading the server without the computers + +`computers.mode: shared` runs one browser for every Bot, and on that shape the transcript's kept +screenshots need the computer image to be as new as the server's. A screenshot only says which page +it is of on a computer built after that field was added, and on a shared browser a picture that +cannot be told apart from another Bot's is refused rather than filed under the wrong turn. The +conversation still names the page it opened; it just does not show it, and the server log says why +each time. + +With `computers.mode: sandbox` or `external`, each Bot has a computer of its own, there is nobody to +race with, and this does not arise. + +## A computer for each Bot + +`computers.mode` decides how a Bot gets a browser: + +| Mode | What it does | Needs | +| --- | --- | --- | +| `shared` | One browser for every Bot, run by this chart. | Nothing. | +| `sandbox` | A computer each, suspended when idle and resumed with its logins intact. | The `agent-sandbox` controller in the cluster. | +| `external` | Neither; `computers.url` points at one somebody else runs. | Nothing. | + +`shared` is what a first install should use. Sessions, files and logins are shared between Bots in +that mode, which is stated on the fleet page rather than hidden. + +`sandbox` uses `kubernetes-sigs/agent-sandbox`, whose `Sandbox` CRD is built for exactly this +workload: an isolated, stateful, singleton pod with a stable identity and persistent storage. +Suspending is `operatingMode: Suspended`, which terminates the pod and keeps the volumes. + +**That controller is not installed by this chart, and the chart refuses to install without it.** +The check reads the cluster, so it is a real answer rather than a value somebody has to remember: + +```sh +kubectl apply --server-side -f \ + https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v0.5.6/sandbox-with-extensions.yaml +``` + +Without that refusal the install succeeds, every pod is healthy, and the deployment looks finished +until the first Bot asks for a browser and the API server answers 404. Rendering offline? Pass +`--api-versions agents.x-k8s.io/v1beta1/Sandbox`. + +**What decides that a computer is idle** is the audit trail, not the browser. Asking the browser +would wake it, so every computer anything asked about would come back up and the bill would never +fall. That is the known, invisible way to lose scale-to-zero: everything works, nothing suspends. + +**A CronJob does the suspending, not a timer in the API.** Every replica would fire its own timer and +each would decide independently to suspend the same computer. The work is claimed and leased out of +PostgreSQL with `select ... for update skip locked`, so whichever pod runs the sweep takes what +nobody else holds, and one that dies mid-suspend hands its work back when the lease expires. The +decision is re-checked at the moment of acting, because somebody may have come back in between. + +## NetworkPolicy, and whether your cluster enforces one + +Off by default, because a NetworkPolicy on a cluster whose CNI does not enforce one is a resource +that silently does nothing, and on a cluster that does enforce one a wrong rule is an outage. + +**On EKS it does nothing unless you turn it on.** The VPC CNI ships with +`--enable-network-policy=false`, so the policy installs, looks right, and is never applied. Check +before trusting it: + +```sh +kubectl -n kube-system get ds aws-node -o yaml | grep enable-network-policy +``` + +The egress rules allow DNS, a Bot's computer on 4100, the API server when computers are Sandboxes, +and the bundled database. **A managed database is an address this chart cannot know**, so turning +the policy on with an external database and no `networkPolicy.extraEgress` is refused: on an +enforcing cluster it would fence the API off from its own database, which reads as the database +being down. + +## Upgrades + +Migrations run as a `pre-install,pre-upgrade` Job, so no replica ever serves in front of a schema it +has not seen. An init container would mean every replica racing to migrate the same database. + +Use `helm upgrade --install --atomic` so a failed upgrade rolls back rather than leaving half a +rollout. diff --git a/charts/openbot/ci/aks-values.yaml b/charts/openbot/ci/aks-values.yaml new file mode 100644 index 00000000..de50c308 --- /dev/null +++ b/charts/openbot/ci/aks-values.yaml @@ -0,0 +1,56 @@ +# AKS. Azure Database, workload identity, Key Vault through external-secrets. +config: + initialAdminEmails: admin@example.com + intelligence: + apiUrl: https://api.cloud.copilotkit.ai + gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + auth: + google: + clientId: example.apps.googleusercontent.com + publicUrl: https://openbot.example.com +postgresql: + enabled: false +database: + existingSecret: openbot-database +serviceAccount: + annotations: + azure.workload.identity/client-id: 00000000-0000-0000-0000-000000000000 +server: + podLabels: + azure.workload.identity/use: "true" +externalSecrets: + enabled: true + secretStoreRef: + name: azure-key-vault + data: + - secretKey: key-encryption-key + remoteRef: + key: openbot-key-encryption-key + # Sessions are signed with this. The server refuses to start when sign-in is configured and it + # is missing, so every provider store has to carry it. + - secretKey: better-auth-secret + remoteRef: + key: openbot-better-auth-secret + - secretKey: intelligence-api-key + remoteRef: + key: openbot-intelligence-api-key + - secretKey: google-client-secret + remoteRef: + key: openbot/google-client-secret + - secretKey: computer-token + remoteRef: + key: openbot/computer-token + - secretKey: license-token + remoteRef: + key: openbot-license-token +ingress: + enabled: true + className: webapprouting.kubernetes.azure.com + hosts: + - host: openbot.example.com + paths: + - path: / + pathType: Prefix + +computers: + mode: shared diff --git a/charts/openbot/ci/eks-sandbox-values.yaml b/charts/openbot/ci/eks-sandbox-values.yaml new file mode 100644 index 00000000..2a7335a1 --- /dev/null +++ b/charts/openbot/ci/eks-sandbox-values.yaml @@ -0,0 +1,106 @@ +# EKS, with a computer each rather than one shared browser. +# +# Needs the agent-sandbox controller installed; CI renders it with --api-versions instead. +# +# CHECK THE CLUSTER HAS A DEFAULT STORAGECLASS BEFORE INSTALLING, because a fresh EKS cluster very +# often does not. `eksctl` creates `gp2`, which is not marked default and uses the in-tree +# `kubernetes.io/aws-ebs` provisioner that no longer exists in current Kubernetes. A volume asking +# for "the default" then never binds, the computer sits Pending, and nothing says why. Verified on a +# real 1.34 cluster; `kubectl get sc` shows it in one line. +# +# Either create a default class: +# +# provisioner: ebs.csi.aws.com, volumeBindingMode: WaitForFirstConsumer, +# annotated storageclass.kubernetes.io/is-default-class: "true" +# +# or name one here, which is what the line below does. `WaitForFirstConsumer` is not optional on any +# cloud: without it the volume is created in a zone picked before the pod is scheduled, and pods stick +# unschedulable with a node-affinity conflict, in multi-zone clusters only, so it passes every +# single-zone test. +config: + initialAdminEmails: admin@example.com + intelligence: + apiUrl: https://api.cloud.copilotkit.ai + gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + auth: + google: + clientId: example.apps.googleusercontent.com + publicUrl: https://openbot.example.com +postgresql: + enabled: false +database: + existingSecret: openbot-database +serviceAccount: + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::000000000000:role/openbot +externalSecrets: + enabled: true + secretStoreRef: + name: aws-secrets-manager + data: + - secretKey: key-encryption-key + remoteRef: + key: openbot/key-encryption-key + # Sessions are signed with this. The server refuses to start when sign-in is configured and it + # is missing, so every provider store has to carry it. + - secretKey: better-auth-secret + remoteRef: + key: openbot-better-auth-secret + - secretKey: intelligence-api-key + remoteRef: + key: openbot/intelligence-api-key + - secretKey: google-client-secret + remoteRef: + key: openbot/google-client-secret + - secretKey: computer-token + remoteRef: + key: openbot/computer-token + - secretKey: license-token + remoteRef: + key: openbot/license-token +ingress: + enabled: true + className: alb + annotations: + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip +server: + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: openbot + app.kubernetes.io/component: server + +computers: + # A computer each, which is the mode the rest of this chart exists for and the one nothing rendered + # until now. `shared` and `sandbox` produce different Deployments, different RBAC and a different + # pod template, so a target that only ever renders one of them checks half the chart. + mode: sandbox + persistence: + # Named here rather than in the chart, which is the whole point of a per-target values file: the + # chart must not name `gp3`, and a deployment on EKS should. + storageClass: gp3 + +# On, so these render in CI at all. They were written, reviewed and shipped without a single target +# producing one, which means nothing had ever checked they were valid YAML, let alone right. Off is +# still the chart's default: a policy on a cluster whose CNI ignores it does nothing, and on one that +# enforces it a wrong rule is an outage, so turning it on stays a deployment's decision. +networkPolicy: + enabled: true + # This target's database is RDS rather than a pod, so egress to it has to be named. The chart + # refuses to render without this, which is how the omission was found: rendering the policies in CI + # at all is what exercised the refusal. + extraEgress: + - to: + - ipBlock: + cidr: 10.0.0.0/16 + ports: + - port: 5432 + protocol: TCP diff --git a/charts/openbot/ci/eks-values.yaml b/charts/openbot/ci/eks-values.yaml new file mode 100644 index 00000000..737a3c60 --- /dev/null +++ b/charts/openbot/ci/eks-values.yaml @@ -0,0 +1,84 @@ +# EKS. RDS for the database, IRSA for identity, Secrets Manager through external-secrets. +# +# CHECK THE CLUSTER HAS A DEFAULT STORAGECLASS BEFORE INSTALLING, because a fresh EKS cluster very +# often does not. `eksctl` creates `gp2`, which is not marked default and uses the in-tree +# `kubernetes.io/aws-ebs` provisioner that no longer exists in current Kubernetes. A volume asking +# for "the default" then never binds, the computer sits Pending, and nothing says why. Verified on a +# real 1.34 cluster; `kubectl get sc` shows it in one line. +# +# Either create a default class: +# +# provisioner: ebs.csi.aws.com, volumeBindingMode: WaitForFirstConsumer, +# annotated storageclass.kubernetes.io/is-default-class: "true" +# +# or name one here, which is what the line below does. `WaitForFirstConsumer` is not optional on any +# cloud: without it the volume is created in a zone picked before the pod is scheduled, and pods stick +# unschedulable with a node-affinity conflict, in multi-zone clusters only, so it passes every +# single-zone test. +config: + initialAdminEmails: admin@example.com + intelligence: + apiUrl: https://api.cloud.copilotkit.ai + gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + auth: + google: + clientId: example.apps.googleusercontent.com + publicUrl: https://openbot.example.com +postgresql: + enabled: false +database: + existingSecret: openbot-database +serviceAccount: + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::000000000000:role/openbot +externalSecrets: + enabled: true + secretStoreRef: + name: aws-secrets-manager + data: + - secretKey: key-encryption-key + remoteRef: + key: openbot/key-encryption-key + # Sessions are signed with this. The server refuses to start when sign-in is configured and it + # is missing, so every provider store has to carry it. + - secretKey: better-auth-secret + remoteRef: + key: openbot-better-auth-secret + - secretKey: intelligence-api-key + remoteRef: + key: openbot/intelligence-api-key + - secretKey: google-client-secret + remoteRef: + key: openbot/google-client-secret + - secretKey: computer-token + remoteRef: + key: openbot/computer-token + - secretKey: license-token + remoteRef: + key: openbot/license-token +ingress: + enabled: true + className: alb + annotations: + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip +server: + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: openbot + app.kubernetes.io/component: server + +computers: + mode: shared + persistence: + # Named here rather than in the chart, which is the whole point of a per-target values file: the + # chart must not name `gp3`, and a deployment on EKS should. + storageClass: gp3 diff --git a/charts/openbot/ci/gke-values.yaml b/charts/openbot/ci/gke-values.yaml new file mode 100644 index 00000000..b2531e0e --- /dev/null +++ b/charts/openbot/ci/gke-values.yaml @@ -0,0 +1,55 @@ +# GKE. Cloud SQL, Workload Identity, Secret Manager through external-secrets, Gateway API. +config: + initialAdminEmails: admin@example.com + intelligence: + apiUrl: https://api.cloud.copilotkit.ai + gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + auth: + google: + clientId: example.apps.googleusercontent.com + publicUrl: https://openbot.example.com +postgresql: + enabled: false +database: + existingSecret: openbot-database +serviceAccount: + annotations: + iam.gke.io/gcp-service-account: openbot@example-project.iam.gserviceaccount.com +externalSecrets: + enabled: true + secretStoreRef: + name: gcp-secret-manager + data: + - secretKey: key-encryption-key + remoteRef: + key: openbot-key-encryption-key + # Sessions are signed with this. The server refuses to start when sign-in is configured and it + # is missing, so every provider store has to carry it. + - secretKey: better-auth-secret + remoteRef: + key: openbot-better-auth-secret + - secretKey: intelligence-api-key + remoteRef: + key: openbot-intelligence-api-key + - secretKey: google-client-secret + remoteRef: + key: openbot/google-client-secret + - secretKey: computer-token + remoteRef: + key: openbot/computer-token + - secretKey: license-token + remoteRef: + key: openbot-license-token +# Gateway API rather than an Ingress, which the same chart supports without a second code path. +ingress: + enabled: false +httpRoute: + enabled: true + parentRefs: + - name: openbot-gateway + namespace: gateway-system + hostnames: + - openbot.example.com + +computers: + mode: shared diff --git a/charts/openbot/ci/self-hosted-values.yaml b/charts/openbot/ci/self-hosted-values.yaml new file mode 100644 index 00000000..b4f7ea8f --- /dev/null +++ b/charts/openbot/ci/self-hosted-values.yaml @@ -0,0 +1,48 @@ +# Somebody's own cluster, which is the shape every default is written for. +# +# Nothing here turns a cloud feature on, because there are none to turn on: the cluster's default +# StorageClass, a plain Secret, the bundled database, an Ingress. If this file needs to grow, a +# default is wrong. +config: + initialAdminEmails: admin@example.com + intelligence: + apiUrl: https://api.cloud.copilotkit.ai + gatewayWsUrl: wss://gateway.cloud.copilotkit.ai + auth: + google: + clientId: example.apps.googleusercontent.com + publicUrl: https://openbot.internal +postgresql: + enabled: true + auth: + # Yours to choose, and the same value on every upgrade. Rendering example only. + password: "example-for-rendering-only" +secrets: + # Sessions are signed with this. Rendering example only; generate one with: openssl rand -base64 32 + betterAuthSecret: "example-for-rendering-only-at-least-32-chars" + # NOT SET HERE, AND THAT IS THE POINT. This file used to carry the public example key from + # `.env.example`, which the server refuses to start with in production: the target rendered, and + # the deployment it described could never run. Supply a real one: + # --set-string secrets.keyEncryptionKey="$(openssl rand -base64 32)" + intelligenceApiKey: "example-for-rendering-only" + googleClientSecret: "example-for-rendering-only" + computerToken: "example-for-rendering-only" + licenseToken: "example-for-rendering-only" +ingress: + enabled: true + className: nginx + hosts: + - host: openbot.internal + paths: + - path: / + pathType: Prefix + +computers: + mode: shared + +# On, so these render in CI at all. They were written, reviewed and shipped without a single target +# producing one, which means nothing had ever checked they were valid YAML, let alone right. Off is +# still the chart's default: a policy on a cluster whose CNI ignores it does nothing, and on one that +# enforces it a wrong rule is an outage, so turning it on stays a deployment's decision. +networkPolicy: + enabled: true diff --git a/charts/openbot/templates/_helpers.tpl b/charts/openbot/templates/_helpers.tpl new file mode 100644 index 00000000..14d96645 --- /dev/null +++ b/charts/openbot/templates/_helpers.tpl @@ -0,0 +1,387 @@ +{{/* +Shared shapes, so a component template says what is different about it and nothing else. + +Anything defined here is used by more than one component, or is a decision worth making in exactly +one place. A helper used once belongs in the template that uses it. +*/}} + +{{- define "openbot.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "openbot.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "openbot.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "openbot.labels" -}} +helm.sh/chart: {{ include "openbot.chart" . }} +{{ include "openbot.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- with .Values.commonLabels }} +{{ toYaml . }} +{{- end }} +{{- end -}} + +{{- define "openbot.selectorLabels" -}} +app.kubernetes.io/name: {{ include "openbot.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* Labels for one component, so two workloads in one release never select each other's pods. */}} +{{- define "openbot.componentLabels" -}} +{{ include "openbot.labels" .root }} +app.kubernetes.io/component: {{ .component }} +{{- end -}} + +{{- define "openbot.componentSelectorLabels" -}} +{{ include "openbot.selectorLabels" .root }} +app.kubernetes.io/component: {{ .component }} +{{- end -}} + +{{- define "openbot.componentName" -}} +{{- printf "%s-%s" (include "openbot.fullname" .root) .component | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "openbot.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "openbot.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} + +{{/* The image, with the chart's appVersion as the tag unless one is named. */}} +{{- define "openbot.image" -}} +{{- $tag := default .Chart.AppVersion .Values.image.tag -}} +{{- printf "%s:%s" .Values.image.repository $tag -}} +{{- end -}} + +{{- define "openbot.secretName" -}} +{{- default (printf "%s-secrets" (include "openbot.fullname" .)) .Values.secrets.existingSecret -}} +{{- end -}} + +{{- define "openbot.configMapName" -}} +{{- printf "%s-config" (include "openbot.fullname" .) -}} +{{- end -}} + +{{/* +Where the database is. + +One definition, because the migrations Job and the API must never disagree about it: a Job that +migrated one database while the API talked to another is a failure that looks like a missing table. +*/}} +{{- define "openbot.databaseUrlEnv" -}} +{{- if .Values.postgresql.enabled -}} +{{- /* + THE PASSWORD IS DECLARED FIRST, AND THAT IS NOT A STYLE CHOICE. + + Kubernetes expands `$(VAR)` in an env value only from variables defined earlier in the same list. + Declared after, the reference is left as the literal text `$(POSTGRES_PASSWORD)` and handed to the + server as the password, which fails authentication with `28P01` and reads exactly like a wrong + password rather than like a template that did not expand. +*/}} +- name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-postgresql" .Release.Name) .Values.postgresql.auth.existingSecret }} + {{- /* The subchart keeps the superuser's password under its own key, not `password`. */}} + key: {{ eq .Values.postgresql.auth.username "postgres" | ternary "postgres-password" "password" }} +- name: DATABASE_URL + value: postgres://{{ .Values.postgresql.auth.username }}:$(POSTGRES_PASSWORD)@{{ .Release.Name }}-postgresql:5432/{{ .Values.postgresql.auth.database }} +{{- else if .Values.database.existingSecret -}} +- name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.database.existingSecret }} + key: {{ .Values.database.existingSecretKey }} +{{- else -}} +- name: DATABASE_URL + value: {{ .Values.database.url | quote }} +{{- end -}} +{{- end -}} + +{{/* +Everything the API reads that is not the database. + +Secrets are referenced, never rendered: a value that appears here would appear in `helm get values` +and in whatever holds the release, which is not where `KEY_ENCRYPTION_KEY` belongs. +*/}} +{{- define "openbot.commonEnv" -}} +- name: PORT + value: {{ .Values.server.service.port | quote }} +- name: NODE_ENV + value: production +- name: EMBEDDED_POSTGRES + value: "off" +{{- /* The switch that makes a replica a replica: no browser in an API pod. */}} +- name: EMBEDDED_COMPUTER + value: {{ ternary "on" "off" .Values.server.embeddedComputer | quote }} +- name: TENANT_PACKAGE_DIR + value: {{ .Values.config.tenantPackageDir | quote }} +{{- if .Values.config.publicUrl }} +- name: OPENBOT_PUBLIC_URL + value: {{ .Values.config.publicUrl | quote }} +- name: BETTER_AUTH_URL + value: {{ .Values.config.publicUrl | quote }} +{{- end }} +{{- if .Values.config.initialAdminEmails }} +- name: INITIAL_ADMIN_EMAILS + value: {{ .Values.config.initialAdminEmails | quote }} +{{- end }} +{{- if .Values.config.singleUser }} +- name: OPENBOT_SINGLE_USER + value: "true" +{{- end }} +{{- if .Values.config.logLevel }} +- name: LOG_LEVEL + value: {{ .Values.config.logLevel | quote }} +{{- end }} +{{- /* + Where this deployment's Bots find a computer, decided by the mode rather than by the operator. + + `shared` addresses the StatefulSet's one pod by its stable name, which is what a headless Service + gives it. `external` takes the URL as written. `sandbox` sets neither: the provider asks the + cluster for each Bot's own computer and gets an address back, so a fixed URL would be the one + thing that could send every Bot to the same browser. +*/}} +{{- if eq .Values.computers.mode "shared" }} +- name: AGENT_COMPUTER_URL + value: http://{{ include "openbot.componentName" (dict "root" . "component" "computer") }}-0.{{ include "openbot.componentName" (dict "root" . "component" "computer") }}:4100 +{{- else if and (eq .Values.computers.mode "external") .Values.computers.url }} +- name: AGENT_COMPUTER_URL + value: {{ .Values.computers.url | quote }} +{{- else if eq .Values.computers.mode "sandbox" }} +- name: COMPUTER_SANDBOX_NAMESPACE + value: {{ default .Release.Namespace .Values.computers.sandbox.namespace | quote }} +- name: COMPUTER_SANDBOX_IDLE_AFTER + value: {{ .Values.computers.sandbox.idleAfter | quote }} +- name: COMPUTER_SANDBOX_TEMPLATE_FILE + value: /etc/openbot/sandbox-template.json +{{- end }} +- name: INTELLIGENCE_API_URL + value: {{ .Values.config.intelligence.apiUrl | quote }} +- name: INTELLIGENCE_GATEWAY_WS_URL + value: {{ .Values.config.intelligence.gatewayWsUrl | quote }} +- name: INTELLIGENCE_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: intelligence-api-key +- name: COPILOTKIT_LICENSE_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: license-token +{{- with .Values.config.managedAgent.url }} +- name: MANAGED_AGENT_AG_UI_URL + value: {{ . | quote }} +- name: MANAGED_AGENT_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" $ }} + key: managed-agent-token +{{- end }} +{{- with .Values.config.auth.google.clientId }} +- name: GOOGLE_OAUTH_CLIENT_ID + value: {{ . | quote }} +- name: GOOGLE_OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" $ }} + key: google-client-secret +{{- end }} +{{- with .Values.config.auth.microsoft.clientId }} +- name: MICROSOFT_OAUTH_CLIENT_ID + value: {{ . | quote }} +- name: MICROSOFT_OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" $ }} + key: microsoft-client-secret +{{- end }} +{{- with .Values.config.auth.microsoft.tenantId }} +- name: MICROSOFT_OAUTH_TENANT_ID + value: {{ . | quote }} +{{- end }} +{{- with .Values.config.auth.okta.clientId }} +- name: OKTA_OAUTH_CLIENT_ID + value: {{ . | quote }} +- name: OKTA_OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" $ }} + key: okta-client-secret +{{- end }} +{{- with .Values.config.auth.okta.issuer }} +- name: OKTA_OAUTH_ISSUER + value: {{ . | quote }} +{{- end }} +- name: KEY_ENCRYPTION_KEY + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: key-encryption-key +{{- /* + Optional only while it genuinely is. + + With no identity provider there is no sign-in and nothing to sign, so an absent key is correct. + With one configured the server refuses to start without it, and `optional: true` turned that into a + crash loop rather than a container that says which key is missing. It also hid the whole path from + the render check, which skips optional keys: a deployment supplying its own Secret without this in + it rendered clean and then never came up. +*/}} +- name: BETTER_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: better-auth-secret + optional: {{ not (or .Values.config.auth.google.clientId .Values.config.auth.microsoft.clientId .Values.config.auth.okta.clientId) }} +- name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "openbot.secretName" . }} + key: model-api-key + optional: true +- name: COMPUTER_TOKEN + valueFrom: + secretKeyRef: + name: {{ default (include "openbot.secretName" .) .Values.computers.existingTokenSecret }} + key: computer-token + optional: {{ eq .Values.computers.mode "external" }} +{{- with .Values.config.extraEnv }} +{{ toYaml . }} +{{- end }} +{{- end -}} + +{{/* +Keeping replicas apart. + +Soft by default, so a one-node cluster still schedules. A deployment that means it sets +`podAntiAffinity: hard` and gets a replica per node, or writes its own `affinity` and gets neither. +*/}} +{{- define "openbot.podAntiAffinity" -}} +{{- $root := .root -}} +{{- $component := .component -}} +{{- if $root.Values.server.affinity -}} +{{ toYaml $root.Values.server.affinity }} +{{- else if eq (default "soft" $root.Values.server.podAntiAffinity) "hard" -}} +podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" $root "component" $component) | indent 10 }} +{{- else if eq (default "soft" $root.Values.server.podAntiAffinity) "soft" -}} +podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" $root "component" $component) | indent 12 }} +{{- end -}} +{{- end -}} + +{{/* +The pod and volumes every Bot's computer is cut from, as JSON. + +One definition, used by the ConfigMap the server reads and by the SandboxTemplate a warm pool cuts +from, so a pre-warmed computer and one created on demand cannot drift into being different things. + +NO CLUSTER CREDENTIAL. Every pod gets a service account token mounted unless it says otherwise, so +the container that opens pages a person named and runs commands a model chose was carrying one. It +could not do much with it, which is not the point: this is the last pod in the deployment that should +be able to address the API server at all, and the default is the wrong way round. +*/}} +{{- define "openbot.sandboxPodTemplate" -}} +{{- $spec := dict + "podTemplate" (dict + "metadata" (dict "labels" (dict + "app.kubernetes.io/name" (include "openbot.name" .) + "app.kubernetes.io/instance" .Release.Name + "app.kubernetes.io/component" "computer")) + "spec" (dict + "terminationGracePeriodSeconds" 30 + "automountServiceAccountToken" false + "containers" (list (dict + "name" "computer" + "image" (include "openbot.image" .) + "imagePullPolicy" .Values.image.pullPolicy + "command" (list "/usr/local/bin/bun" "/app/agent-computer/src/index.ts") + "ports" (list (dict "name" "http" "containerPort" 4100)) + "env" (concat + (list + (dict "name" "PORT" "value" "4100") + (dict "name" "WORKSPACE_DIR" "value" "/workspace") + (dict "name" "PROFILES_DIR" "value" "/profiles") + (dict "name" "COMPUTER_TOKEN" "valueFrom" (dict "secretKeyRef" (dict + "name" (default (include "openbot.secretName" .) .Values.computers.existingTokenSecret) + "key" "computer-token")))) + .Values.computers.extraEnv) + "volumeMounts" (list + (dict "name" "profiles" "mountPath" "/profiles") + (dict "name" "workspace" "mountPath" "/workspace")) + "readinessProbe" (dict + "httpGet" (dict "path" "/health" "port" "http") + "periodSeconds" 10 + "failureThreshold" 6) + "resources" .Values.computers.resources)))) -}} +{{- $pod := index $spec "podTemplate" -}} +{{- $podSpec := index $pod "spec" -}} +{{- with .Values.computers.runtimeClassName }}{{- $_ := set $podSpec "runtimeClassName" . }}{{- end }} +{{- with .Values.imagePullSecrets }}{{- $_ := set $podSpec "imagePullSecrets" . }}{{- end }} +{{- with .Values.computers.nodeSelector }}{{- $_ := set $podSpec "nodeSelector" . }}{{- end }} +{{- with .Values.computers.tolerations }}{{- $_ := set $podSpec "tolerations" . }}{{- end }} +{{- $claim := dict + "accessModes" (list "ReadWriteOnce") + "resources" (dict "requests" (dict "storage" .Values.computers.persistence.profilesSize)) -}} +{{- $work := dict + "accessModes" (list "ReadWriteOnce") + "resources" (dict "requests" (dict "storage" .Values.computers.persistence.workspaceSize)) -}} +{{- with .Values.computers.persistence.storageClass }} +{{- $_ := set $claim "storageClassName" . }}{{- $_ := set $work "storageClassName" . }} +{{- end }} +{{- /* + A Service, which is the whole reason a computer has a stable address. + + Without it the controller creates the pod and reports no `serviceFQDN`, so the sandbox is Ready and + unreachable: `locate` waits for an address that is never coming and times out. A pod IP would be + the wrong answer anyway, because it changes on every resume, which is exactly what a suspended + computer does. +*/}} +{{- $_ := set $spec "service" true -}} +{{- $_ := set $spec "volumeClaimTemplates" (list + (dict "metadata" (dict "name" "profiles") "spec" $claim) + (dict "metadata" (dict "name" "workspace") "spec" $work)) -}} +{{ toPrettyJson $spec }} +{{- end -}} + +{{/* +Whether the API pod gets a Kubernetes token. + +FALSE UNLESS IT ACTUALLY NEEDS ONE. The API talks to a database and to Bots, not to the cluster, so a +mounted token is a credential sitting in a pod that has no use for it. `computers.mode: sandbox` is +the exception and the only one: there the server asks the API server to create, resume and suspend a +Sandbox per Bot, and without a token it fails on the first browser action with a missing file rather +than anything that names the cause. +*/}} +{{- define "openbot.automountToken" -}} +{{- or .Values.serviceAccount.automountServiceAccountToken (eq .Values.computers.mode "sandbox") -}} +{{- end -}} diff --git a/charts/openbot/templates/computer/culler-cronjob.yaml b/charts/openbot/templates/computer/culler-cronjob.yaml new file mode 100644 index 00000000..40f469f6 --- /dev/null +++ b/charts/openbot/templates/computer/culler-cronjob.yaml @@ -0,0 +1,77 @@ +{{- if and (eq .Values.computers.mode "sandbox") .Values.computers.sandbox.culler.enabled }} +{{- $component := "culler" -}} +{{/* +Suspending computers nobody is using. + +A CronJob rather than a timer in the API, and the difference is the whole reason this file exists. +An interval in the server fires in every replica, so five replicas would each decide independently +to suspend the same computer. The audit-retention sweep gets away with that because deleting old +rows twice is the same as deleting them once; suspending a browser somebody just started using is +not. The work is claimed and leased out of PostgreSQL, so whichever pod runs this takes what nobody +else holds, and a pod that dies mid-suspend hands its work back when the lease expires. + +`concurrencyPolicy: Forbid` on top, because a schedule that overlaps itself is the same problem in +one workload rather than across several. +*/}} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $component) | indent 4 }} +spec: + schedule: {{ .Values.computers.sandbox.culler.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + startingDeadlineSeconds: 120 + jobTemplate: + spec: + backoffLimit: 1 + template: + metadata: + labels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 12 }} + {{- if .Values.postgresql.enabled }} + {{- /* + The bundled database admits pods carrying this label and nothing else, which this chart pins on. + Anything that opens the database needs it, and only the API server had it: this would have been + refused on any cluster that actually enforces a NetworkPolicy. Not caught by hand, because the + cluster it was driven on ships enforcement switched off. + */}} + {{ .Release.Name }}-postgresql-client: "true" + {{- end }} + spec: + restartPolicy: Never + serviceAccountName: {{ include "openbot.serviceAccountName" . }} + {{- /* It reads the cluster's Sandboxes, so it needs the token the API pod does not. */}} + automountServiceAccountToken: true + {{- with .Values.imagePullSecrets }} + imagePullSecrets: +{{ toYaml . | indent 12 }} + {{- end }} + containers: + - name: culler + image: {{ include "openbot.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + workingDir: /app/server + command: ["/usr/local/bin/bun", "scripts/cull-idle-computers.ts"] + env: +{{ include "openbot.databaseUrlEnv" . | indent 16 }} +{{ include "openbot.commonEnv" . | indent 16 }} + {{- /* The same shape of computer the server uses, so the two cannot disagree. */}} + volumeMounts: + - name: sandbox-template + mountPath: /etc/openbot + readOnly: true + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + memory: 512Mi + volumes: + - name: sandbox-template + configMap: + name: {{ include "openbot.componentName" (dict "root" . "component" "computer-template") }} +{{- end }} diff --git a/charts/openbot/templates/computer/pod-template.yaml b/charts/openbot/templates/computer/pod-template.yaml new file mode 100644 index 00000000..67e39a22 --- /dev/null +++ b/charts/openbot/templates/computer/pod-template.yaml @@ -0,0 +1,24 @@ +{{- if eq .Values.computers.mode "sandbox" }} +{{/* +What a Bot's computer looks like, handed to the server as a file. + +`Sandbox` takes its pod inline and has no template reference, so something has to give the server the +shape of a computer. It belongs here rather than in the code: which image a computer runs, what +volumes it keeps, and which RuntimeClass it uses are deployment decisions, and the three clouds do +not agree about the last one. + +MOUNTED, NOT FETCHED. A ConfigMap the server reads through the API would need a credential to read +ConfigMaps, and this needs no permission at all if the pod simply has the file. It also means the +template cannot change under a running server without a rollout, which is what the checksum +annotation on the Deployment already arranges. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" "computer-template") }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" "computer") | indent 4 }} +data: + sandbox-template.json: | +{{ include "openbot.sandboxPodTemplate" . | indent 4 }} +{{- end }} diff --git a/charts/openbot/templates/computer/sandbox-rbac.yaml b/charts/openbot/templates/computer/sandbox-rbac.yaml new file mode 100644 index 00000000..2bf66d53 --- /dev/null +++ b/charts/openbot/templates/computer/sandbox-rbac.yaml @@ -0,0 +1,47 @@ +{{- if eq .Values.computers.mode "sandbox" }} +{{- $ns := default .Release.Namespace .Values.computers.sandbox.namespace -}} +{{/* +What the API may do to computers, and nothing else. + +A ROLE, NOT A CLUSTERROLE. Scoped to the one namespace the computers live in, so the worst this +credential can do is manage Bots' browsers in the place they already are. Compare the Docker +supervisor, which holds the host's Docker socket and is therefore root-equivalent on that host: this +is a smaller blast radius, granted by the cluster rather than by a shared environment variable. +*/}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" "sandbox") }} + namespace: {{ $ns }} + labels: +{{ include "openbot.labels" . | indent 4 }} +rules: + - apiGroups: ["agents.x-k8s.io"] + resources: ["sandboxes"] + verbs: ["get", "list", "watch", "create", "patch", "delete"] + - apiGroups: ["agents.x-k8s.io"] + resources: ["sandboxes/status"] + verbs: ["get"] + {{- if .Values.computers.sandbox.warmPool.enabled }} + {{- /* The extension kinds are in their own API group, which a Role must name exactly. */}} + - apiGroups: ["extensions.agents.x-k8s.io"] + resources: ["sandboxclaims"] + verbs: ["get", "list", "create", "delete"] + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" "sandbox") }} + namespace: {{ $ns }} + labels: +{{ include "openbot.labels" . | indent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "openbot.componentName" (dict "root" . "component" "sandbox") }} +subjects: + - kind: ServiceAccount + name: {{ include "openbot.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/charts/openbot/templates/computer/sandbox-template.yaml b/charts/openbot/templates/computer/sandbox-template.yaml new file mode 100644 index 00000000..90046594 --- /dev/null +++ b/charts/openbot/templates/computer/sandbox-template.yaml @@ -0,0 +1,23 @@ +{{- if and (eq .Values.computers.mode "sandbox") .Values.computers.sandbox.warmPool.enabled }} +{{- $ns := default .Release.Namespace .Values.computers.sandbox.namespace -}} +{{/* +The template a warm pool cuts pre-warmed computers from. + +Only rendered with a warm pool, because that is the only thing that reads it: a `Sandbox` carries its +pod inline and has no template reference, so the on-demand path takes the same shape from the +ConfigMap beside this file. One definition feeds both, so a pre-warmed computer and one created on +demand cannot drift into being different things. + +`extensions.agents.x-k8s.io`, not `agents.x-k8s.io`. The two extension kinds sit in their own API +group, which is easy to get wrong and fails as "no matches for kind" at install. +*/}} +apiVersion: extensions.agents.x-k8s.io/v1beta1 +kind: SandboxTemplate +metadata: + name: {{ $ns }}-computer + namespace: {{ $ns }} + labels: +{{ include "openbot.labels" . | indent 4 }} +spec: +{{ include "openbot.sandboxPodTemplate" . | fromJson | toYaml | indent 2 }} +{{- end }} diff --git a/charts/openbot/templates/computer/service.yaml b/charts/openbot/templates/computer/service.yaml new file mode 100644 index 00000000..8d4719da --- /dev/null +++ b/charts/openbot/templates/computer/service.yaml @@ -0,0 +1,19 @@ +{{- if eq .Values.computers.mode "shared" }} +{{- $component := "computer" -}} +{{/* Headless, because a StatefulSet wants stable per-pod names rather than a load-balanced one. */}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $component) | indent 4 }} +spec: + clusterIP: None + ports: + - port: 4100 + targetPort: http + protocol: TCP + name: http + selector: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 4 }} +{{- end }} diff --git a/charts/openbot/templates/computer/statefulset.yaml b/charts/openbot/templates/computer/statefulset.yaml new file mode 100644 index 00000000..f20fffcd --- /dev/null +++ b/charts/openbot/templates/computer/statefulset.yaml @@ -0,0 +1,170 @@ +{{- if eq .Values.computers.mode "shared" }} +{{- $component := "computer" -}} +{{/* +A Bot's computer: Chromium, a workspace, and a browser profile that has to survive a restart. + +A STATEFULSET, NOT A DEPLOYMENT, and not because there is more than one of them. The profile +directory holds real logins, and a Deployment's pods get no stable volume of their own: two replicas +would fight over one `ReadWriteOnce` claim and a rollout would hand the new pod an empty profile, +which reads as every Bot being signed out of everything at once. + +`replicas: 1` on purpose in this mode. One browser for every Bot is what `shared` means, and it is +the mode that needs no CRD in the cluster. `computers.mode: sandbox` gives each Bot its own, and is +where the idle suspend lives; see the provider RBAC beside this file. + +STORAGE HAS GRAVITY. The volume below is `ReadWriteOnce`, and on all three clouds the ordinary block +volume is zonal, so this pod is pinned to whichever zone its volume was created in for as long as +that profile exists. That is acceptable and worth stating rather than discovering. `storageClass` +stays empty by default, meaning the cluster's default class, because naming `gp3` or `pd-balanced` +here is how a chart stops installing on somebody's bare-metal cluster. +*/}} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $component) | indent 4 }} +spec: + serviceName: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + replicas: 1 + selector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 6 }} + {{- /* + THE DEFAULT IS RETAIN, AND THAT IS WHAT WE WANT, SAID OUT LOUD. + + `whenScaled: Delete` here would erase every Bot's logins the first time this scaled down. It is + written explicitly rather than inherited, so that nobody later "tidies up" a field they think is + missing. + */}} + persistentVolumeClaimRetentionPolicy: + whenDeleted: {{ .Values.computers.persistence.whenDeleted }} + whenScaled: Retain + volumeClaimTemplates: + - metadata: + name: profiles + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.computers.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.computers.persistence.profilesSize }} + - metadata: + name: workspace + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.computers.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.computers.persistence.workspaceSize }} + template: + metadata: + labels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 8 }} + spec: + {{- /* + No cluster credential in the one pod running a browser and a shell. + + A pod carries a service account token unless it says otherwise, so the container that opens + pages a person named and runs commands a model chose was carrying one. It could not do much + with it, which is not the point: this is the last pod in the deployment that should be able + to address the API server, and the default is the wrong way round. + */}} + automountServiceAccountToken: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: +{{ toYaml . | indent 8 }} + {{- end }} + {{- /* + gVisor or Kata, where the cluster has one, and an ordinary pod where it does not. + + The product already has this idea: `COMPUTER_RUNTIME=runsc` runs a computer under gVisor on a + host that supports it. This is the same setting where Kubernetes expects it. Unset by + default, because a chart that assumed a RuntimeClass would fail to install on a cluster that + has none, and the three clouds do not agree: GKE has managed gVisor, AKS offers Kata, and on + EKS it is bring your own node configuration. + */}} + {{- with .Values.computers.runtimeClassName }} + runtimeClassName: {{ . }} + {{- end }} + terminationGracePeriodSeconds: 30 + {{- with .Values.computers.nodeSelector }} + nodeSelector: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.computers.tolerations }} + tolerations: +{{ toYaml . | indent 8 }} + {{- end }} + containers: + - name: computer + image: {{ include "openbot.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- /* + The computer alone, not the whole image. + + One image serves both roles and the command decides which: this skips s6 and runs the + browser process directly, so a computer pod carries no API and no database. + */}} + command: ["/usr/local/bin/bun", "/app/agent-computer/src/index.ts"] + ports: + - name: http + containerPort: 4100 + protocol: TCP + env: + - name: PORT + value: "4100" + - name: WORKSPACE_DIR + value: /workspace + - name: PROFILES_DIR + value: /profiles + - name: COMPUTER_TOKEN + valueFrom: + secretKeyRef: + name: {{ default (include "openbot.secretName" .) .Values.computers.existingTokenSecret }} + key: computer-token + {{- with .Values.computers.maxBrowsers }} + - name: COMPUTER_MAX_BROWSERS + value: {{ . | quote }} + {{- end }} + {{- with .Values.computers.browserIdleMs }} + - name: COMPUTER_BROWSER_IDLE_MS + value: {{ . | quote }} + {{- end }} + {{- with .Values.computers.extraEnv }} +{{ toYaml . | indent 12 }} + {{- end }} + volumeMounts: + - name: profiles + mountPath: /profiles + - name: workspace + mountPath: /workspace + {{- /* + Readiness only, and deliberately no liveness probe. + + A browser under load can be slow to answer without being broken, and a liveness probe + that restarts it takes every signed-in session with it. Readiness keeps traffic away + until it can answer; nothing kills it for being busy. + */}} + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + failureThreshold: 6 + startupProbe: + httpGet: + path: /health + port: http + periodSeconds: 5 + failureThreshold: 60 + {{- with .Values.computers.resources }} + resources: +{{ toYaml . | indent 12 }} + {{- end }} +{{- end }} diff --git a/charts/openbot/templates/computer/warmpool.yaml b/charts/openbot/templates/computer/warmpool.yaml new file mode 100644 index 00000000..1cac665b --- /dev/null +++ b/charts/openbot/templates/computer/warmpool.yaml @@ -0,0 +1,22 @@ +{{- if and (eq .Values.computers.mode "sandbox") .Values.computers.sandbox.warmPool.enabled }} +{{- $ns := default .Release.Namespace .Values.computers.sandbox.namespace -}} +{{/* +Computers waiting, so a Bot's first action after lunch does not wait for Chromium to boot. + +A resume costs a pod schedule and a browser launch, and a `ReadWriteOnce` volume that has to detach +from the old node first. That is a real wait, and this is the upstream mechanism for avoiding most +of it rather than something to reinvent. Off by default, because a pool is browsers nobody is using +yet, which is exactly the cost the suspend is here to remove. +*/}} +apiVersion: extensions.agents.x-k8s.io/v1beta1 +kind: SandboxWarmPool +metadata: + name: {{ $ns }}-computer + namespace: {{ $ns }} + labels: +{{ include "openbot.labels" . | indent 4 }} +spec: + replicas: {{ .Values.computers.sandbox.warmPool.replicas }} + sandboxTemplateRef: + name: {{ $ns }}-computer +{{- end }} diff --git a/charts/openbot/templates/configmap.yaml b/charts/openbot/templates/configmap.yaml new file mode 100644 index 00000000..11bf220b --- /dev/null +++ b/charts/openbot/templates/configmap.yaml @@ -0,0 +1,17 @@ +{{/* +Non-secret configuration, in a ConfigMap so a change to it rolls the pods. + +Everything here is readable by anybody who can read the namespace, which is the test for whether a +value belongs in this file rather than in the Secret beside it. +*/}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "openbot.configMapName" . }} + labels: +{{ include "openbot.labels" . | indent 4 }} +data: + OPENBOT_DEPLOYMENT: kubernetes + {{- if .Values.config.publicUrl }} + TRUSTED_ORIGINS: {{ .Values.config.publicUrl | quote }} + {{- end }} diff --git a/charts/openbot/templates/externalsecret.yaml b/charts/openbot/templates/externalsecret.yaml new file mode 100644 index 00000000..caebbb87 --- /dev/null +++ b/charts/openbot/templates/externalsecret.yaml @@ -0,0 +1,24 @@ +{{- if .Values.externalSecrets.enabled }} +{{/* +The same keys, from whatever the cluster's secret store is. + +Backend-agnostic on purpose: Secrets Manager, Secret Manager and Key Vault are all a `secretStoreRef` +and a list of remote keys, so none of them appears in this chart by name. +*/}} +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: {{ include "openbot.secretName" . }} + labels: +{{ include "openbot.labels" . | indent 4 }} +spec: + refreshInterval: {{ .Values.externalSecrets.refreshInterval }} + secretStoreRef: + name: {{ required "externalSecrets.secretStoreRef.name is required when externalSecrets.enabled is true" .Values.externalSecrets.secretStoreRef.name }} + kind: {{ .Values.externalSecrets.secretStoreRef.kind }} + target: + name: {{ include "openbot.secretName" . }} + creationPolicy: Owner + data: +{{ toYaml (required "externalSecrets.data must name at least key-encryption-key" .Values.externalSecrets.data) | indent 4 }} +{{- end }} diff --git a/charts/openbot/templates/httproute.yaml b/charts/openbot/templates/httproute.yaml new file mode 100644 index 00000000..fdb9a988 --- /dev/null +++ b/charts/openbot/templates/httproute.yaml @@ -0,0 +1,26 @@ +{{- if .Values.httpRoute.enabled }} +{{/* +Gateway API, as an alternative rather than a replacement. + +Where this is going, and not where every cluster is: plenty of self-hosted clusters still run an +Ingress controller, so both exist here and neither is assumed. Turning both on is a mistake rather +than a merge, and `validation.yaml` says so at install time. +*/}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "openbot.fullname" . }} + labels: +{{ include "openbot.labels" . | indent 4 }} +spec: + parentRefs: +{{ toYaml (required "httpRoute.parentRefs is required when httpRoute.enabled is true" .Values.httpRoute.parentRefs) | indent 4 }} + {{- with .Values.httpRoute.hostnames }} + hostnames: +{{ toYaml . | indent 4 }} + {{- end }} + rules: + - backendRefs: + - name: {{ include "openbot.fullname" . }}-server + port: {{ .Values.server.service.port }} +{{- end }} diff --git a/charts/openbot/templates/ingress.yaml b/charts/openbot/templates/ingress.yaml new file mode 100644 index 00000000..53224f2e --- /dev/null +++ b/charts/openbot/templates/ingress.yaml @@ -0,0 +1,44 @@ +{{- if .Values.ingress.enabled }} +{{- $fullname := include "openbot.fullname" . -}} +{{- $port := .Values.server.service.port -}} +{{/* +Getting traffic in, one of two ways. + +`className` has no default because the controller differs on every cluster and always will, and the +annotations that configure it differ with it. Naming one here would be a chart that installs on the +cluster it was written on. +*/}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullname }} + labels: +{{ include "openbot.labels" . | indent 4 }} + {{- with .Values.ingress.annotations }} + annotations: +{{ toYaml . | indent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: +{{ toYaml . | indent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ $fullname }}-server + port: + number: {{ $port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/openbot/templates/migrations/job.yaml b/charts/openbot/templates/migrations/job.yaml new file mode 100644 index 00000000..d62a3afd --- /dev/null +++ b/charts/openbot/templates/migrations/job.yaml @@ -0,0 +1,105 @@ +{{- if .Values.migrations.enabled }} +{{- $component := "migrations" -}} +{{/* +The schema, before anything serves a request. + +A pre-install and pre-upgrade hook rather than an init container on the Deployment: with several +replicas, an init container means every replica races to migrate the same database, and the +migration that loses is a failed pod on an otherwise healthy rollout. One Job runs once. + +WHEN IT RUNS DEPENDS ON WHOSE DATABASE IT IS, and both wrong answers deadlock rather than slow down. + +A pre-install hook runs before the chart's own resources, so with the bundled database the +StatefulSet does not exist yet and never will while the hook is running: waiting cannot help, +because the thing being waited for is behind the wait. A post-install hook has the mirror image of +the same problem, because `--wait` holds it until the Deployment is ready and the Deployment cannot +be ready against a database with no schema in it. + +So on a first install with the bundled database this is not a hook at all. It is an ordinary Job, +created in the same pass as the database and the Deployment, and it waits for the database while the +replicas restart against a schema that is on its way. Nothing is ordered because nothing needs to +be: the Job's own wait is the ordering, and a replica that starts too early is a restart rather than +a failure. + +Every other case is a hook, because in every other case the database already exists. An external +database on install, and any upgrade at all, run `pre`, where the schema is ready before a replica +reaches a version of it that it has not seen. + +Deleted before the next run and on success. A failed one is kept, so the reason is still readable. +*/}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }}-{{ .Release.Revision }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $component) | indent 4 }} + {{- if not (and .Values.postgresql.enabled .Release.IsInstall) }} + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-5" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded + {{- end }} +spec: + activeDeadlineSeconds: {{ .Values.migrations.activeDeadlineSeconds }} + backoffLimit: {{ .Values.migrations.backoffLimit }} + template: + metadata: + labels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 8 }} + {{- if .Values.postgresql.enabled }} + {{- /* + The bundled database admits pods carrying this label and nothing else, which this chart pins on. + Anything that opens the database needs it, and only the API server had it: this would have been + refused on any cluster that actually enforces a NetworkPolicy. Not caught by hand, because the + cluster it was driven on ships enforcement switched off. + */}} + {{ .Release.Name }}-postgresql-client: "true" + {{- end }} + spec: + restartPolicy: Never + {{- /* + NO SERVICE ACCOUNT, WHICH IS BOTH CORRECT AND NECESSARY. + + Correct because this Job talks to a database and never to the cluster, so a cluster + credential in it is one nothing needs. Necessary because as a pre-install hook it runs before + the chart's own resources exist, and naming the ServiceAccount the API pods use fails with + "serviceaccount not found" and a Job that can never schedule. The default account, with no + token mounted, is what a migration actually requires. + */}} + automountServiceAccountToken: false + {{- with .Values.imagePullSecrets }} + imagePullSecrets: +{{ toYaml . | indent 8 }} + {{- end }} + containers: + - name: migrate + image: {{ include "openbot.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + workingDir: /app/server + command: + - /bin/sh + - -ec + - | + # Bounded, so a database that is never coming back fails the install rather than + # holding it open until somebody notices. `pg_isready` reads the same DATABASE_URL the + # migration is about to use, so this cannot wait on the wrong server. + deadline=$(( $(date +%s) + {{ .Values.migrations.waitForDatabaseSeconds }} )) + until pg_isready -d "$DATABASE_URL" -q; do + if [ "$(date +%s)" -ge "$deadline" ]; then + echo "the database did not accept connections within {{ .Values.migrations.waitForDatabaseSeconds }}s" >&2 + exit 1 + fi + sleep 2 + done + exec /usr/local/bin/bun scripts/migrate.ts + env: +{{ include "openbot.databaseUrlEnv" . | indent 12 }} + - name: EMBEDDED_POSTGRES + value: "off" + - name: EMBEDDED_COMPUTER + value: "off" + {{- with .Values.migrations.resources }} + resources: +{{ toYaml . | indent 12 }} + {{- end }} +{{- end }} diff --git a/charts/openbot/templates/networkpolicy.yaml b/charts/openbot/templates/networkpolicy.yaml new file mode 100644 index 00000000..973a093e --- /dev/null +++ b/charts/openbot/templates/networkpolicy.yaml @@ -0,0 +1,140 @@ +{{- if .Values.networkPolicy.enabled }} +{{- $component := "server" -}} +{{/* +What the API may reach, and what may reach it. + +Off by default, because a NetworkPolicy on a cluster with no CNI that enforces one is a resource +that silently does nothing, and on a cluster that does enforce one a wrong rule is an outage. A +deployment that turns this on is saying it knows which of the two it has. + +Egress deliberately allows DNS and the database, and nothing else without being asked: a Bot's +computer reaching the open internet is the computers' own policy, not the API's. +*/}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $component) | indent 4 }} +spec: + podSelector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + - ports: + - port: {{ .Values.server.service.port }} + protocol: TCP + {{- with .Values.networkPolicy.extraIngress }} +{{ toYaml . | indent 4 }} + {{- end }} + egress: + # DNS, or nothing resolves and every failure looks like the database being down. + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + {{- if .Values.postgresql.enabled }} + - to: + - podSelector: + matchLabels: + app.kubernetes.io/name: postgresql + ports: + - port: 5432 + protocol: TCP + {{- end }} + {{- if ne .Values.computers.mode "external" }} + {{- /* + A Bot's computer, which the API has to reach for every browser action. + + Easy to forget, because nothing about the API looks like it talks to another pod: the browser is + behind a Service and reads like an outside address. On a cluster that enforces policy, leaving + this out means every Bot action fails and the API looks broken rather than fenced. + */}} + - to: + - podSelector: + matchLabels: + app.kubernetes.io/component: computer + ports: + - port: 4100 + protocol: TCP + {{- end }} + {{- if eq .Values.computers.mode "sandbox" }} + {{- /* The API server, which is where a per-Bot computer is asked for. */}} + - ports: + - port: 443 + protocol: TCP + - port: 6443 + protocol: TCP + {{- end }} + {{- with .Values.networkPolicy.extraEgress }} +{{ toYaml . | indent 4 }} + {{- end }} +--- +{{- $computer := "computer" -}} +{{/* +What a Bot's computer may reach. + +THE ONE POD THAT RUNS A BROWSER AND A SHELL. It opens pages a person named and runs commands a model +chose, which makes it the pod most likely to be doing something nobody intended, and it was the only +one with no policy at all: on a cluster that enforces them it could open a socket to anything in the +namespace, the deployment's own database included. Compose has kept the database off the Bot's +network since the beginning, and this is that same boundary, said in the other deployment's words. + +Egress is the open internet minus the cluster's own private space, because browsing is the job. The +private ranges are cut out by exception rather than by listing what is allowed: a Bot has no business +addressing another pod, a node, or a cloud metadata endpoint, and naming the exceptions is the only +way to say that without also saying which websites exist. + +Ingress is the API server and nothing else. The gateway is the only thing that may drive it. +*/}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $computer) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $computer) | indent 4 }} +spec: + podSelector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $computer) | indent 6 }} + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" "server") | indent 14 }} + ports: + - port: 4100 + protocol: TCP + egress: + # DNS, or every address a Bot is asked to open fails as if the site were down. + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + # The cluster and everything else on the private network, including the database. + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + # Link-local, which is where every cloud keeps the endpoint that hands out credentials. + - 169.254.0.0/16 + ports: + - port: 80 + protocol: TCP + - port: 443 + protocol: TCP + {{- with .Values.networkPolicy.computerExtraEgress }} +{{ toYaml . | indent 4 }} + {{- end }} +{{- end }} diff --git a/charts/openbot/templates/secret.yaml b/charts/openbot/templates/secret.yaml new file mode 100644 index 00000000..c10439d5 --- /dev/null +++ b/charts/openbot/templates/secret.yaml @@ -0,0 +1,50 @@ +{{- if and (not .Values.secrets.existingSecret) (not .Values.externalSecrets.enabled) }} +{{/* +The fallback, and the default, because a plain Kubernetes Secret is what a self-hosted cluster has. + +A deployment on a cloud points `externalSecrets` at its own store instead and this renders nothing. +Either way the templates that read these keys are identical, which is the point: no vendor appears +anywhere except in a values block. + +`helm.sh/resource-policy: keep` is deliberate. `KEY_ENCRYPTION_KEY` is what the credential vault is +encrypted with, so a `helm uninstall` that took it would leave every stored credential unreadable +even after a reinstall against the same database. +*/}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "openbot.secretName" . }} + labels: +{{ include "openbot.labels" . | indent 4 }} + annotations: + helm.sh/resource-policy: keep +type: Opaque +stringData: + key-encryption-key: {{ required "secrets.keyEncryptionKey is required unless secrets.existingSecret or externalSecrets is used. Generate one with: openssl rand -base64 32" .Values.secrets.keyEncryptionKey | quote }} + intelligence-api-key: {{ required "secrets.intelligenceApiKey is required. OpenBot needs CopilotKit Intelligence and refuses to start without it." .Values.secrets.intelligenceApiKey | quote }} + license-token: {{ required "secrets.licenseToken is required. OpenBot needs CopilotKit Intelligence and refuses to start without it." .Values.secrets.licenseToken | quote }} + {{- with .Values.secrets.betterAuthSecret }} + better-auth-secret: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.modelApiKey }} + model-api-key: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.managedAgentToken }} + managed-agent-token: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.googleClientSecret }} + google-client-secret: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.microsoftClientSecret }} + microsoft-client-secret: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.oktaClientSecret }} + okta-client-secret: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.computerToken }} + computer-token: {{ . | quote }} + {{- end }} + {{- with .Values.secrets.supervisorToken }} + supervisor-token: {{ . | quote }} + {{- end }} +{{- end }} diff --git a/charts/openbot/templates/server/deployment.yaml b/charts/openbot/templates/server/deployment.yaml new file mode 100644 index 00000000..2ed9af3e --- /dev/null +++ b/charts/openbot/templates/server/deployment.yaml @@ -0,0 +1,155 @@ +{{- $component := "server" -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $component) | indent 4 }} + {{- with .Values.commonAnnotations }} + annotations: +{{ toYaml . | indent 4 }} + {{- end }} +spec: + {{- /* Left unset when the HPA owns it, so a chart upgrade cannot undo a scale the HPA decided. */}} + {{- if not .Values.server.autoscaling.enabled }} + replicas: {{ .Values.server.replicaCount }} + {{- end }} + selector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 6 }} + template: + metadata: + labels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 8 }} + {{- if .Values.postgresql.enabled }} + {{- /* + The bundled database's own policy admits pods carrying this label and nothing else. + + Its shipped default is `allowExternal: true`, which renders an ingress rule with no `from` + at all: any pod in any namespace may open 5432, the Bot's browser container included. That + is pinned off in this chart's values, which makes this label the thing that lets the API in, + and the reason a Bot's computer is now refused. + */}} + {{ .Release.Name }}-postgresql-client: "true" + {{- end }} + {{- with .Values.server.podLabels }} +{{ toYaml . | indent 8 }} + {{- end }} + annotations: + {{- /* + Roll the pods when configuration changes. + + Without this a `helm upgrade` that only changes the ConfigMap leaves every replica running + the old values, and the deployment looks upgraded while behaving exactly as it did. + */}} + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- if eq .Values.computers.mode "sandbox" }} + {{- /* + The shape of a computer, which the server reads once and keeps. + + A mounted ConfigMap does update in place, but the file is read on the first computer request + and the provider built from it is held for the life of the process. Without this the + template can change under a running server and every Sandbox it creates is still cut from + the old one, which is a rollout that looks like it worked and changes nothing. + */}} + checksum/computer-template: {{ include (print $.Template.BasePath "/computer/pod-template.yaml") . | sha256sum }} + {{- end }} + {{- with .Values.server.podAnnotations }} +{{ toYaml . | indent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "openbot.serviceAccountName" . }} + automountServiceAccountToken: {{ include "openbot.automountToken" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: +{{ toYaml . | indent 8 }} + {{- end }} + {{- $affinity := include "openbot.podAntiAffinity" (dict "root" . "component" $component) }} + {{- if $affinity }} + affinity: +{{ $affinity | indent 8 }} + {{- end }} + {{- with .Values.server.topologySpreadConstraints }} + topologySpreadConstraints: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.server.nodeSelector }} + nodeSelector: +{{ toYaml . | indent 8 }} + {{- end }} + {{- with .Values.server.tolerations }} + tolerations: +{{ toYaml . | indent 8 }} + {{- end }} + containers: + - name: server + image: {{ include "openbot.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: +{{ toYaml . | indent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.server.service.port }} + protocol: TCP + env: +{{ include "openbot.databaseUrlEnv" . | indent 12 }} +{{ include "openbot.commonEnv" . | indent 12 }} + envFrom: + - configMapRef: + name: {{ include "openbot.configMapName" . }} + {{- with .Values.config.extraEnvFrom }} +{{ toYaml . | indent 12 }} + {{- end }} + {{- /* + Three probes, and the startup one is the reason the other two can be impatient. + + A cold start migrates nothing but does read and validate the tenant package, so first + boot is slower than steady state. Without a startup probe the liveness one has to be + slack enough for the slowest boot, which means a wedged replica stays in the Service for + as long as a healthy slow one would. + */}} + startupProbe: + httpGet: + path: /health + port: http + periodSeconds: {{ .Values.server.startupProbe.periodSeconds }} + failureThreshold: {{ .Values.server.startupProbe.failureThreshold }} + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: {{ .Values.server.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.server.readinessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.server.readinessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.server.readinessProbe.failureThreshold }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: {{ .Values.server.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.server.livenessProbe.periodSeconds }} + timeoutSeconds: {{ .Values.server.livenessProbe.timeoutSeconds }} + failureThreshold: {{ .Values.server.livenessProbe.failureThreshold }} + {{- if eq .Values.computers.mode "sandbox" }} + {{- /* The shape of a Bot's computer, as a file rather than as a permission to read one. */}} + volumeMounts: + - name: sandbox-template + mountPath: /etc/openbot + readOnly: true + {{- end }} + {{- with .Values.server.resources }} + resources: +{{ toYaml . | indent 12 }} + {{- end }} + {{- if eq .Values.computers.mode "sandbox" }} + volumes: + - name: sandbox-template + configMap: + name: {{ include "openbot.componentName" (dict "root" . "component" "computer-template") }} + {{- end }} diff --git a/charts/openbot/templates/server/hpa.yaml b/charts/openbot/templates/server/hpa.yaml new file mode 100644 index 00000000..6a79a421 --- /dev/null +++ b/charts/openbot/templates/server/hpa.yaml @@ -0,0 +1,37 @@ +{{- if .Values.server.autoscaling.enabled }} +{{- $component := "server" -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $component) | indent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + minReplicas: {{ .Values.server.autoscaling.minReplicas }} + maxReplicas: {{ .Values.server.autoscaling.maxReplicas }} + metrics: + {{- if .Values.server.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.server.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.server.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.server.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} + {{- with .Values.server.autoscaling.behavior }} + behavior: +{{ toYaml . | indent 4 }} + {{- end }} +{{- end }} diff --git a/charts/openbot/templates/server/pdb.yaml b/charts/openbot/templates/server/pdb.yaml new file mode 100644 index 00000000..83cd913f --- /dev/null +++ b/charts/openbot/templates/server/pdb.yaml @@ -0,0 +1,30 @@ +{{- if .Values.server.podDisruptionBudget.enabled }} +{{- $component := "server" -}} +{{/* +What a drain may take at once. + +Only meaningful with more than one replica: a budget of `minAvailable: 1` over a single replica means +a node drain blocks forever rather than being safe, so this is rendered only where it can be kept. +*/}} +{{- $replicas := int .Values.server.replicaCount -}} +{{- if .Values.server.autoscaling.enabled }} +{{- $replicas = int .Values.server.autoscaling.minReplicas -}} +{{- end }} +{{- if gt $replicas 1 }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $component) | indent 4 }} +spec: + {{- if .Values.server.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.server.podDisruptionBudget.maxUnavailable }} + {{- else }} + minAvailable: {{ .Values.server.podDisruptionBudget.minAvailable }} + {{- end }} + selector: + matchLabels: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 6 }} +{{- end }} +{{- end }} diff --git a/charts/openbot/templates/server/service.yaml b/charts/openbot/templates/server/service.yaml new file mode 100644 index 00000000..1d60306d --- /dev/null +++ b/charts/openbot/templates/server/service.yaml @@ -0,0 +1,24 @@ +{{- $component := "server" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "openbot.componentName" (dict "root" . "component" $component) }} + labels: +{{ include "openbot.componentLabels" (dict "root" . "component" $component) | indent 4 }} + {{- with .Values.server.service.annotations }} + annotations: +{{ toYaml . | indent 4 }} + {{- end }} +spec: + type: {{ .Values.server.service.type }} + {{- with .Values.server.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: +{{ toYaml . | indent 4 }} + {{- end }} + ports: + - port: {{ .Values.server.service.port }} + targetPort: http + protocol: TCP + name: http + selector: +{{ include "openbot.componentSelectorLabels" (dict "root" . "component" $component) | indent 4 }} diff --git a/charts/openbot/templates/server/serviceaccount.yaml b/charts/openbot/templates/server/serviceaccount.yaml new file mode 100644 index 00000000..83ddfa90 --- /dev/null +++ b/charts/openbot/templates/server/serviceaccount.yaml @@ -0,0 +1,17 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "openbot.serviceAccountName" . }} + labels: +{{ include "openbot.labels" . | indent 4 }} + {{- /* + IRSA, Workload Identity and AKS workload identity are all annotations here, which is why this is + one map and not three code paths. The chart never learns which cloud it is on. + */}} + {{- with .Values.serviceAccount.annotations }} + annotations: +{{ toYaml . | indent 4 }} + {{- end }} +automountServiceAccountToken: {{ include "openbot.automountToken" . }} +{{- end }} diff --git a/charts/openbot/templates/validation.yaml b/charts/openbot/templates/validation.yaml new file mode 100644 index 00000000..7f16e1b0 --- /dev/null +++ b/charts/openbot/templates/validation.yaml @@ -0,0 +1,249 @@ +{{/* +Refused at install time, not discovered in a crash loop. + +Every check here is something the API would fail on at boot, or something that is quietly wrong +rather than loudly broken. A `helm install` that succeeds and leaves pods restarting is worse than +one that refuses and says why, because the second names the value to change. + +This template renders nothing. +*/}} + +{{- if and (not .Values.postgresql.enabled) (not .Values.database.url) (not .Values.database.existingSecret) }} +{{- fail "No database. Either set postgresql.enabled=true to run the bundled one, or set database.url (or database.existingSecret) to point at your own." }} +{{- end }} + +{{- /* + The bundled database, without a password anybody can produce again. + + The subchart generates one on install and then refuses to render on upgrade without being handed + the current value, so a release installed without one cannot be upgraded, only reinstalled. That + failure arrives on the second deploy, which is the worst time to find it. +*/}} +{{- if and .Values.postgresql.enabled (not .Values.postgresql.auth.password) (not .Values.postgresql.auth.existingSecret) }} +{{- fail "The bundled database needs a password you can supply again on upgrade. Set postgresql.auth.password, or postgresql.auth.existingSecret to name one you made. Generate one with: openssl rand -hex 24" }} +{{- end }} + +{{- if and .Values.postgresql.enabled (or .Values.database.url .Values.database.existingSecret) }} +{{- fail "Two databases. postgresql.enabled is true and database.url is also set, so it is not clear which one is meant. Pick one." }} +{{- end }} + +{{- /* + Nobody can get in, which is two separate mistakes rather than one. + + Naming administrators does not create a way to sign in, and configuring sign-in does not make + anybody an administrator: the server refuses to start without a provider, and grants the role to + nobody without the addresses. An earlier version of this check accepted admin emails alone, which + passed the install and then crash-looped on the first boot, so both are checked and named apart. +*/}} +{{- $provider := or .Values.config.auth.google.clientId .Values.config.auth.microsoft.clientId .Values.config.auth.okta.clientId -}} +{{- if and (not .Values.config.singleUser) (not $provider) }} +{{- fail "Nobody could sign in. Configure config.auth.google, config.auth.microsoft or config.auth.okta, or set config.singleUser=true for a local trial where every request is one fixed administrator." }} +{{- end }} + +{{- if and $provider (not .Values.config.initialAdminEmails) }} +{{- fail "Nobody would be an administrator. config.initialAdminEmails is what grants the role; nothing else does." }} +{{- end }} + +{{- if and $provider (not .Values.config.publicUrl) }} +{{- fail "Sign-in needs somewhere to come back to. Set config.publicUrl to the address people reach this deployment at, which is where the OAuth callback lands." }} +{{- end }} + +{{- if and .Values.config.auth.okta.clientId (not .Values.config.auth.okta.issuer) }} +{{- fail "config.auth.okta.issuer is required alongside the Okta client, such as https://example.okta.com/oauth2/default. It is what makes it a particular Okta rather than Okta in general." }} +{{- end }} + +{{- /* + Every visitor as one administrator, on an address the internet can reach. + + `singleUser` is a local trial mode, and a LoadBalancer with no source ranges is the open internet. + Together they are a deployment where anybody who finds the address is an administrator, which is + the exact failure the server refuses to start into when nobody has said they meant it. +*/}} +{{- if and .Values.config.singleUser (eq .Values.server.service.type "LoadBalancer") (not .Values.server.service.loadBalancerSourceRanges) }} +{{- fail "config.singleUser treats every visitor as an administrator, so it must not be put behind a LoadBalancer that anybody can reach. Set server.service.loadBalancerSourceRanges, or configure an identity provider." }} +{{- end }} + +{{- if and .Values.config.singleUser .Values.config.publicUrl }} +{{- fail "config.singleUser serves every visitor as one administrator, so it must not be combined with a public URL. Configure an identity provider and set config.initialAdminEmails instead." }} +{{- end }} + +{{- /* + Intelligence, which is not optional. + + All four values are required together and the server refuses to start on a partial set, so the + same rule is applied here: caught at install with the values named, rather than in a crash loop + whose message is in a log nobody has opened. +*/}} +{{- if or (not .Values.config.intelligence.apiUrl) (not .Values.config.intelligence.gatewayWsUrl) }} +{{- fail "OpenBot requires CopilotKit Intelligence. Set config.intelligence.apiUrl and config.intelligence.gatewayWsUrl, and the matching secrets.intelligenceApiKey and secrets.licenseToken." }} +{{- end }} + +{{- /* + A computer nobody can reach, or one anybody can. + + `agent-computer` refuses every request without `COMPUTER_TOKEN` and permits only `/health`, so a + chart that created one without a token would create a process that answers nothing. And a mode of + `external` with no address is a deployment whose Bots have no computer at all, which fails at the + first browser action rather than at install. +*/}} +{{- if and (ne .Values.computers.mode "external") (not .Values.secrets.computerToken) (not .Values.computers.existingTokenSecret) (not .Values.secrets.existingSecret) (not .Values.externalSecrets.enabled) }} +{{- fail "A computer needs a token, which is the only thing standing between it and anybody who can reach its port. Set secrets.computerToken. Generate one with: openssl rand -hex 32" }} +{{- end }} + +{{- if and (eq .Values.computers.mode "external") (not .Values.computers.url) }} +{{- fail "computers.mode is external but computers.url is empty, so no Bot would have a computer. Set the address, or use mode: shared to have this chart run one." }} +{{- end }} + +{{- /* + Asking for per-Bot computers on a cluster that cannot make them. + + `computers.mode: sandbox` creates `Sandbox` objects, which only exist once the agent-sandbox + controller is installed. Without it the install succeeds, every pod is healthy, and the deployment + looks finished right up until the first Bot asks for a browser and gets a 404 from the API server. + That is the worst time to learn it, so it is a refusal here instead, with the command to run. + + Read from the cluster, so it is a real check rather than a value somebody has to remember to set. + `helm template` with no cluster has no way to know, which is what `--api-versions` is for and what + the chart's own render tests pass. +*/}} +{{- if and (eq .Values.computers.mode "sandbox") .Values.computers.sandbox.requireController }} +{{- if not (.Capabilities.APIVersions.Has "agents.x-k8s.io/v1beta1/Sandbox") }} +{{- fail "computers.mode is sandbox, which gives every Bot its own computer, but this cluster has no Sandbox CRD. Install the controller first:\n\n kubectl apply --server-side -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v0.5.6/sandbox-with-extensions.yaml\n\nOr use computers.mode: shared, which needs nothing installed. Rendering without a cluster? Pass --api-versions agents.x-k8s.io/v1beta1/Sandbox, or set computers.sandbox.requireController=false." }} +{{- end }} +{{- end }} + +{{- if not (has .Values.computers.mode (list "shared" "sandbox" "external")) }} +{{- fail "computers.mode must be one of: shared, sandbox, external." }} +{{- end }} + +{{- /* + A policy that would fence the deployment off from its own database. + + The egress rules name DNS, the computers and, when it is bundled, the database. A managed database + is an address this chart cannot know, so on a cluster that enforces policy the API would resolve it + and then be unable to reach it, which reads as the database being down rather than as a rule. + + Worth knowing either way: a NetworkPolicy on a cluster whose CNI does not enforce one is a resource + that silently does nothing. EKS needs the VPC CNI started with `--enable-network-policy=true`, and + it is off by default, so this can look like it is working when it is not doing anything at all. +*/}} +{{- if and .Values.networkPolicy.enabled (not .Values.postgresql.enabled) (not .Values.networkPolicy.extraEgress) }} +{{- fail "networkPolicy.enabled with an external database, but nothing lets the API reach it. Add the database to networkPolicy.extraEgress, for example a `to: [{ipBlock: {cidr: 10.0.0.0/16}}]` with port 5432." }} +{{- end }} + +{{- if and .Values.ingress.enabled .Values.httpRoute.enabled }} +{{- fail "ingress.enabled and httpRoute.enabled are both set. They are two ways to do the same thing; pick the one your cluster runs." }} +{{- end }} + +{{- /* + A key of the wrong shape. + + `KEY_ENCRYPTION_KEY` must decode to exactly 32 bytes, and the server refuses to start otherwise. + Checked here only when the chart is the one creating the Secret: a key coming from an existing + Secret or an external store is not readable at template time, and guessing about it would mean + refusing installs that are fine. +*/}} +{{- if and .Values.secrets.keyEncryptionKey (not .Values.secrets.existingSecret) (not .Values.externalSecrets.enabled) }} +{{- if ne (len (b64dec .Values.secrets.keyEncryptionKey)) 32 }} +{{- fail "secrets.keyEncryptionKey must be a base64-encoded 32-byte value. Generate one with: openssl rand -base64 32" }} +{{- end }} +{{- /* + The right length and no secret in it. + + The example key in `.env.example` is thirty-two zero bytes, which passes every shape check and is + public. The server refuses to start on it in production and this chart shipped a values file + carrying it, so the target rendered and the deployment it described could never run. Checked as a + property rather than by copying the value, which would mean writing a key-shaped literal into a + template to forbid a key-shaped literal. +*/}} +{{- if not (regexMatch "[^\\x00]" (b64dec .Values.secrets.keyEncryptionKey)) }} +{{- fail "secrets.keyEncryptionKey is the public example key from .env.example. The server refuses to start with it. Generate one with: openssl rand -base64 32" }} +{{- end }} +{{- end }} + +{{- if and .Values.externalSecrets.enabled .Values.secrets.existingSecret }} +{{- fail "externalSecrets.enabled and secrets.existingSecret are both set. The first creates the Secret and the second says one already exists; pick one." }} +{{- end }} + +{{- /* + A replica count that cannot survive the thing replicas are for. + + One replica is a supported way to run this and not the default, so this is a note rather than a + refusal only when somebody has also asked for a disruption budget, where one replica means a node + drain blocks rather than being safe. +*/}} +{{- if and (not .Values.server.autoscaling.enabled) (lt (int .Values.server.replicaCount) 1) }} +{{- fail "server.replicaCount must be at least 1." }} +{{- end }} + +{{- if .Values.server.embeddedComputer }} +{{- if gt (int .Values.server.replicaCount) 1 }} +{{- fail "server.embeddedComputer runs a browser inside every API pod, which cannot be replicated: each replica would hold a profile directory belonging to one Bot. Either set server.replicaCount=1, or leave embeddedComputer off and give the Bots a computer of their own." }} +{{- end }} +{{- end }} + +{{- /* + A Bot endpoint with nothing on the request that says who is calling. + + The server refuses to start in this state, and it is better to hear it from `helm install` than + from a pod's third crash loop. Only checked when the chart holds the token: a value in an existing + Secret or an external store is not readable at template time, so an install that gets it from + there is left alone rather than refused on a guess. +*/}} +{{- if .Values.config.managedAgent.url }} +{{- if and (not .Values.secrets.managedAgentToken) (not .Values.secrets.existingSecret) (not .Values.externalSecrets.enabled) }} +{{- fail "config.managedAgent.url is set without secrets.managedAgentToken. Every call to your Bot carries that token, and an endpoint that accepts unauthenticated calls is an open door to whatever the Bot can reach." }} +{{- end }} +{{- /* + The same requirement, for a deployment whose secrets come from a store. + + The value is not readable at template time, but the LIST of keys is, and a store that never + mentions this key cannot be holding one. Without this the refusal simply went dark on every cloud + target, which is where a Bot endpoint is most likely to be pointed at something real. +*/}} +{{- if .Values.externalSecrets.enabled }} +{{- $named := list }} +{{- range .Values.externalSecrets.data }}{{- $named = append $named .secretKey }}{{- end }} +{{- if not (has "managed-agent-token" $named) }} +{{- fail "config.managedAgent.url is set but externalSecrets.data does not name managed-agent-token. Every call to your Bot carries that token, and an endpoint that accepts unauthenticated calls is an open door to whatever the Bot can reach." }} +{{- end }} +{{- end }} +{{- end }} + +{{- /* + Sign-in configured with nothing to sign sessions with. + + The server throws "Sign-in requires BETTER_AUTH_SECRET" and the pod crash-loops, which is a slow + and confusing way to be told about a value that was missing before anything was created. Every + shipped `ci/` values file was in exactly this state, which is what happens when nothing renders the + chart. Checked only when this chart holds the secret: from an existing Secret or an external store + it is not readable at template time, and refusing on a guess would refuse installs that are fine. +*/}} +{{- if or .Values.config.auth.google.clientId .Values.config.auth.microsoft.clientId .Values.config.auth.okta.clientId }} +{{- if and (not .Values.secrets.betterAuthSecret) (not .Values.secrets.existingSecret) (not .Values.externalSecrets.enabled) }} +{{- fail "An identity provider is configured but secrets.betterAuthSecret is not. Sessions are signed with it and the server refuses to start without it. Generate one with: openssl rand -base64 32" }} +{{- end }} +{{- if and .Values.secrets.betterAuthSecret (lt (len .Values.secrets.betterAuthSecret) 32) }} +{{- fail "secrets.betterAuthSecret must be at least 32 characters. Generate one with: openssl rand -base64 32" }} +{{- end }} +{{- if not .Values.config.publicUrl }} +{{- fail "An identity provider is configured but config.publicUrl is not. Sign-in redirects come back to that address, so the server refuses to start without it." }} +{{- end }} +{{- end }} + +{{- /* + The same requirement, for a deployment whose secrets come from a store. + + The value itself is not readable at template time, but the LIST of keys is, and a store that never + mentions `better-auth-secret` cannot be holding one. Every shipped cloud `ci/` file was in this + state, so the refusal above passed and the pod still crash-looped. +*/}} +{{- if .Values.externalSecrets.enabled }} +{{- if or .Values.config.auth.google.clientId .Values.config.auth.microsoft.clientId .Values.config.auth.okta.clientId }} +{{- $named := list }} +{{- range .Values.externalSecrets.data }}{{- $named = append $named .secretKey }}{{- end }} +{{- if not (has "better-auth-secret" $named) }} +{{- fail "An identity provider is configured but externalSecrets.data does not name better-auth-secret. Sessions are signed with it and the server refuses to start without it." }} +{{- end }} +{{- end }} +{{- end }} diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml new file mode 100644 index 00000000..da8e7596 --- /dev/null +++ b/charts/openbot/values.yaml @@ -0,0 +1,381 @@ +# OpenBot, on any Kubernetes cluster. +# +# THE DEFAULTS ARE WHAT A PLAIN CLUSTER CAN DO. Every place the clouds genuinely differ is a value, +# and its default is whatever a self-hosted cluster with no cloud features does: the cluster's own +# default StorageClass, no RuntimeClass, a plain Kubernetes Secret, bundled PostgreSQL, an Ingress. +# A deployment on EKS, GKE or AKS turns things on; a self-hosted one changes nothing and still works. +# There is no cloud branching anywhere in the templates, and there should never be: a chart that only +# installs cleanly on a managed cluster has failed the thing this repository is for. + +nameOverride: "" +fullnameOverride: "" + +image: + repository: ghcr.io/copilotkit/openbot + # Empty means the chart's appVersion, so an upgrade of the chart moves the image with it. + tag: "" + pullPolicy: IfNotPresent +imagePullSecrets: [] + +server: + replicaCount: 2 + # Two by default because horizontal is the point. Everything that has to survive a replica is in + # PostgreSQL, and one replica hides every bug that is not. + + # THE BROWSER IS NOT IN THIS POD. The image runs a Bot's computer beside the API for the + # one-container case; a replica of the API must not carry one, because a browser is a few hundred + # megabytes holding one Bot's logins, and scaling the API would scale those with it. + embeddedComputer: false + + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + memory: 2Gi + + # Off by default. Turn it on and set the metric you actually want to scale on. + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 + targetMemoryUtilizationPercentage: null + behavior: {} + + podDisruptionBudget: + enabled: true + minAvailable: 1 + maxUnavailable: null + + # Spread replicas so a node or zone going away does not take the deployment with it. Soft by + # default: a single-node cluster still schedules, which is what a first `helm install` runs on. + topologySpreadConstraints: [] + podAntiAffinity: soft + + nodeSelector: {} + tolerations: [] + affinity: {} + podAnnotations: {} + podLabels: {} + + service: + type: ClusterIP + port: 3001 + annotations: {} + # WHO MAY REACH IT, when the type is LoadBalancer. Empty means everybody, which is what a cloud + # load balancer does by default and almost never what somebody wants for an internal tool. Narrow + # it to the addresses your people come from. Not every cloud honours this on every load balancer + # type, so treat it as one layer rather than the only one. + loadBalancerSourceRanges: [] + + # Probes hit the API's own health route. + livenessProbe: + initialDelaySeconds: 20 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + readinessProbe: + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + startupProbe: + periodSeconds: 5 + failureThreshold: 60 + +serviceAccount: + create: true + name: "" + # ONE MAP, THREE CLOUDS. IRSA on EKS, Workload Identity on GKE and on AKS are all annotations on a + # ServiceAccount, so this covers every one of them and the chart needs no idea which it is on. + # eks.amazonaws.com/role-arn: arn:aws:iam:::role/ + # iam.gke.io/gcp-service-account: @.iam.gserviceaccount.com + # azure.workload.identity/client-id: + annotations: {} + automountServiceAccountToken: false + +# The database migrations, run as a Job before the API starts. A pre-upgrade hook, so a rollout +# never puts a new replica in front of a schema it has not seen. +migrations: + enabled: true + backoffLimit: 3 + # How long to wait for the database to accept connections before giving up. A pre-install hook runs + # before the chart's own resources, so on a first install with the bundled database this Job starts + # before the database does. + waitForDatabaseSeconds: 300 + # A ceiling on the whole Job, so a migration that hangs fails the release rather than holding it. + activeDeadlineSeconds: 900 + # Kept on failure so somebody can read why. Helm deletes it before the next run. + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + memory: 1Gi + +# What OpenBot needs to run. Anything secret belongs in `secrets` below, never here. +config: + # Where people reach this deployment. Sets the OAuth callback and the trusted origin. + publicUrl: "" + # Administrators by email address, comma-separated. Required unless singleUser is on. + initialAdminEmails: "" + # Every request is one fixed administrator. Local trials only; the deployment refuses to start + # with no identity provider unless this says somebody meant it. + singleUser: false + + # How people sign in. One of these, or `singleUser` above, and nothing else will do: the server + # refuses to start rather than serve a deployment where every visitor is an administrator. + # + # Client secrets go under `secrets` below. Only the public halves belong here. + auth: + google: + clientId: "" + microsoft: + clientId: "" + # `common` is Microsoft's own default and admits personal accounts as well as work ones. A + # company that means "our staff" puts its directory GUID here. + tenantId: "" + okta: + clientId: "" + issuer: "" + tenantPackageDir: /app/examples/fintech + + # CopilotKit Intelligence, which OpenBot requires. All four values are needed together: the server + # refuses to start on a partial set, deliberately, because a half-configured Intelligence is a + # mistake somebody made rather than a deployment that meant to run without one. + intelligence: + apiUrl: "" + gatewayWsUrl: "" + # The two secret halves live under `secrets` below, never here. + # Your own Bot, over AG-UI. + # + # OpenBot is a shell for somebody else's agent, so this is the seam that matters: point it at a + # service you run and coworkers created here reach it. Left empty, this deployment has only the + # Bots its tenant package declares as built-in, and a package entry whose endpoint resolves to + # nothing is dropped rather than registered as a coworker nobody can talk to. + # + # The address must be reachable from the server pod, so a Service in this cluster is named as + # `http://my-agent.my-namespace:8000/ag-ui`, not as localhost. + managedAgent: + url: "" + # The token travels in `secrets.managedAgentToken`. Required whenever a url is set: the server + # refuses to start with one and not the other, because an unauthenticated Bot endpoint is an + # open door to whatever that Bot can reach. + logLevel: "" + # Free-form additions, for anything this chart has no opinion about. + extraEnv: [] + extraEnvFrom: [] + +# How a Bot gets a computer. +# +# shared One browser for every Bot, run by this chart. Needs nothing installed in the cluster, +# and is what a first install should use. +# sandbox A computer each, as a `Sandbox` from kubernetes-sigs/agent-sandbox, suspended when +# idle and resumed with its logins intact. Needs that controller in the cluster. +# external Neither. `url` points at a computer somebody else runs. +computers: + mode: shared + # Only for `mode: external`. The other modes derive the address from what they created. + url: "" + # Never a literal. See `secrets` below. + existingTokenSecret: "" + + # gVisor or Kata, where the cluster has one. Unset means an ordinary pod, because the three clouds + # do not agree: GKE has managed gVisor, AKS offers Kata and gVisor, and on EKS it is bring your own + # node configuration. A chart that assumed a RuntimeClass would fail to install without one. + runtimeClassName: "" + + # How many browsers one computer holds at once, and how long an untouched one is kept. Empty means + # the process default, which is a handful and thirty minutes. + maxBrowsers: "" + browserIdleMs: "" + + persistence: + # EMPTY MEANS THE CLUSTER'S DEFAULT CLASS. Naming `gp3` or `pd-balanced` is how a chart stops + # installing on a bare-metal cluster. Note that the ordinary block volume on every cloud is + # zonal, so a computer is pinned to the zone its profile was created in. + storageClass: "" + profilesSize: 10Gi + workspaceSize: 10Gi + # What happens to a Bot's logins when the release is deleted. `Retain` keeps them, which is the + # safe default; `Delete` is for a trial you want to leave no trace of. + whenDeleted: Retain + + resources: + requests: + cpu: 500m + # Chromium is roughly 200-500MB headless and up to 2GB with real pages open. + memory: 1Gi + limits: + memory: 4Gi + + nodeSelector: {} + tolerations: [] + extraEnv: [] + + # `mode: sandbox` only. Where the per-Bot computers are created and what may create them. + sandbox: + # Refuse to install when the cluster has no Sandbox CRD, rather than succeeding and failing at + # the first browser action. Turn it off only where the CRD arrives after this chart does, such as + # a GitOps run that applies both together. + requireController: true + namespace: "" + # Pre-warmed sandboxes, so a Bot's first action after lunch does not wait for Chromium to boot. + warmPool: + enabled: false + replicas: 2 + # Suspend a computer nobody has used for this long. The Sandbox CRD has an absolute expiry, which + # is not the same question, so the culler below asks this one. + idleAfter: 30m + culler: + enabled: true + schedule: "*/5 * * * *" + +database: + # Used when `postgresql.enabled` is false. A URL, or a secret holding one. + # + # PUT `?sslmode=require` ON IT. Every managed database refuses an unencrypted connection: RDS has + # `rds.force_ssl` on by default, and Cloud SQL and Azure Database do the same. Without it the + # migration fails with `no pg_hba.conf entry for host ... no encryption`, which names the host and + # the user and not the actual problem. + # + # postgres://user:password@host:5432/openbot?sslmode=require + # + # Keep it in a Secret rather than here: `url` is readable by anybody who can read the release. + url: "" + existingSecret: "" + existingSecretKey: "database-url" + +# The bundled database. Off by default in favour of a managed one. +# The bundled database. +# +# NOT PRODUCTION-GRADE, AND OFF FOR THAT REASON. A database on a pod is a database that goes away +# when the pod does: a rollout, a node drain or an evicted pod is a restart, and while the volume +# survives, nothing about this shape gives you backups, failover, point-in-time recovery or a +# connection that outlives the release. It exists so somebody can try OpenBot in one command. +# +# A REAL DEPLOYMENT POINTS `database` ABOVE AT A MANAGED DATABASE: RDS, Cloud SQL, Azure Database, or +# your own server. That is the same line the Intelligence chart draws, and for the same reason. +postgresql: + enabled: false + auth: + # THE SUPERUSER, DELIBERATELY, AND ONLY FOR THIS BUNDLED DATABASE. + # + # The first migration runs `CREATE EXTENSION vector` and a later one drops it again, and only a + # superuser can do either. An ordinary role fails the install at migration one with "permission + # denied to create extension", which reads like a broken chart rather than a database privilege. + # + # This is the shape `docker-compose.yml` already ships, where the app user is the cluster + # superuser, and it is safe for the same reason: this database exists for this release alone and + # nothing else can reach it. + # + # A MANAGED DATABASE IS NOT THIS. On RDS, Cloud SQL or Azure there is no superuser to hand out, + # so create the extension once as the administrative role and grant the migrating role ownership + # of it. `CREATE EXTENSION IF NOT EXISTS` then passes for an ordinary user. + username: postgres + database: openbot + # SET ONE, OR UPGRADES FAIL. The subchart generates a password on install and then refuses to + # render on upgrade unless it is given the current one, which turns the second `helm upgrade` + # into an error about credentials. A password you chose, or a Secret you made, makes the release + # repeatable. The chart refuses to install without one rather than letting you find out later. + password: "" + existingSecret: "" + image: + # Bitnami moved its unsupported images to this frozen mirror. Nothing new lands here, so the tag + # is named rather than inherited from the subchart: with it inherited, bumping the subchart + # silently asks for an image the mirror does not have, and the chart still renders perfectly. + repository: bitnamilegacy/postgresql + tag: 17.6.0-debian-12-r4 + primary: + # PINNED, BECAUSE THE SHIPPED DEFAULT ADMITS EVERYTHING. + # + # The subchart renders its NetworkPolicy whether or not this chart's own is enabled, and with + # `allowExternal: true` its ingress rule carries no `from` at all: any pod in any namespace may + # open 5432. That includes a Bot's computer, the one pod running a browser on pages a person + # named and a shell on commands a model chose. Compose has kept the database off the Bot's + # network since the beginning; this is the same boundary in the other deployment's words. + # + # With this off, the database admits pods labelled `-postgresql-client`, which the API + # server carries and nothing else does. + networkPolicy: + allowExternal: false + allowExternalEgress: false + persistence: + enabled: true + # EMPTY MEANS THE CLUSTER'S DEFAULT CLASS, and that is deliberate. Naming `gp3` or + # `pd-balanced` here is exactly how a chart stops installing on somebody's bare-metal cluster. + storageClass: "" + size: 20Gi + +# Secrets, without picking a vendor. +# +# A plain Kubernetes Secret is the default because that is what a self-hosted cluster has. +# `externalSecrets` turns the same fields into an ExternalSecret instead, so Secrets Manager, Secret +# Manager and Key Vault are a values block rather than three code paths. +secrets: + # Point at a Secret you made yourself, and the chart creates none. + existingSecret: "" + # Created by the chart when `existingSecret` is empty. Pass with --set-string or a values file that + # is not in version control; `KEY_ENCRYPTION_KEY` and the model credential are the two that matter + # and neither should ever be a literal in a file anybody commits. + keyEncryptionKey: "" + betterAuthSecret: "" + modelApiKey: "" + computerToken: "" + supervisorToken: "" + intelligenceApiKey: "" + licenseToken: "" + # Sent to `config.managedAgent.url` on every call. Required when that url is set. + managedAgentToken: "" + # The client secret for whichever provider is configured above. + googleClientSecret: "" + microsoftClientSecret: "" + oktaClientSecret: "" + +externalSecrets: + enabled: false + # Whatever your cluster's ClusterSecretStore or SecretStore is called. Backend-agnostic on purpose. + secretStoreRef: + name: "" + kind: ClusterSecretStore + refreshInterval: 1h + # remoteRef keys, one per secret this chart reads. + data: [] + +ingress: + enabled: false + # The controller differs on every cluster and always will, so this is a value with no default. + className: "" + annotations: {} + hosts: + - host: openbot.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +# Gateway API, as an alternative rather than a replacement. Plenty of self-hosted clusters still run +# an Ingress controller, so both are here and neither is assumed. +httpRoute: + enabled: false + parentRefs: [] + hostnames: [] + +networkPolicy: + enabled: false + # Where the API may reach out to. A deployment with a managed database adds its CIDR here. + extraEgress: [] + extraIngress: [] + # Where a Bot's computer may reach beyond the public internet. A deployment whose Bots must reach + # an internal site adds it here, one address at a time, rather than reopening the private ranges. + computerExtraEgress: [] + +podSecurityContext: + runAsNonRoot: false + fsGroup: null +securityContext: {} + +# Applied to every pod this chart creates. +commonLabels: {} +commonAnnotations: {} diff --git a/docker/s6/s6-rc.d/computer/run b/docker/s6/s6-rc.d/computer/run index 77989bda..d671a813 100755 --- a/docker/s6/s6-rc.d/computer/run +++ b/docker/s6/s6-rc.d/computer/run @@ -1,8 +1,21 @@ #!/command/with-contenv sh # The Bot's browser. Bound to loopback: its only caller is the API beside it. # +# `EMBEDDED_COMPUTER=on` is the default, because one container that just works is what this image is +# for. Off, this service exits 0 immediately and s6 leaves it alone, the same way `postgres` does, +# and the API reaches a computer elsewhere through `AGENT_COMPUTER_URL` or a supervisor. +# +# That switch is what lets this image be a stateless replica. A browser is a few hundred megabytes of +# memory holding one Bot's logins, so an API pod carrying one is neither stateless nor cheap to run +# several of: scaling the API would scale the browsers with it, and every replica would hold a +# profile directory that matters to exactly one Bot. +# # `with-contenv` is not decoration. Without it s6 starts a service with none of the container's # environment, and the failure is a config error naming a variable that is plainly set. +set -eu +if [ "${EMBEDDED_COMPUTER:-on}" != "on" ]; then + exec /bin/true +fi cd /app/agent-computer export PORT=4100 export WORKSPACE_DIR=/workspace diff --git a/docker/s6/scripts/migrate.sh b/docker/s6/scripts/migrate.sh index 3a71d4df..6776b1e2 100755 --- a/docker/s6/scripts/migrate.sh +++ b/docker/s6/scripts/migrate.sh @@ -8,4 +8,7 @@ set -eu [ "${EMBEDDED_POSTGRES:-off}" = "on" ] || exit 0 cd /app/server -exec s6-setuidgid pwuser /usr/local/bin/bun x drizzle-kit migrate --config=drizzle.config.ts +# `scripts/migrate.ts`, not `drizzle-kit`. The CLI is a development dependency and needs esbuild to +# read its TypeScript config, which `bun install --production` leaves out of this image: asked to +# migrate here it exits 1 without printing why, and the container comes up against an empty database. +exec s6-setuidgid pwuser /usr/local/bin/bun scripts/migrate.ts diff --git a/scripts/check-rendered-chart.ts b/scripts/check-rendered-chart.ts new file mode 100644 index 00000000..12e68145 --- /dev/null +++ b/scripts/check-rendered-chart.ts @@ -0,0 +1,140 @@ +/** + * Is this rendered chart coherent with itself? + * + * Rendering proves the templates run. It does not prove the result works, and the way it fails is + * quiet: a container names a secret key, the chart writes a Secret without it, and nothing says so + * until a pod starts somewhere nobody is watching. Every shipped `ci/` target was in that state, on + * the value that signs sessions, which is why the server could not start on any of them. + * + * Deliberately not a schema validator. Kubernetes already rejects malformed objects and a schema + * check needs a cluster or a pinned bundle of CRDs; what it cannot see is whether the pieces this + * chart writes agree with each other. + */ +const [file] = process.argv.slice(2); +if (!file) { + console.error("usage: check-rendered-chart.ts "); + process.exit(2); +} + +const text = await Bun.file(file).text(); +const documents = text + .split(/^---$/m) + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.length > 0 && !/^(#[^\n]*\n?)*$/.test(chunk)); + +if (documents.length === 0) { + console.error(`${file} rendered nothing.`); + process.exit(1); +} + +const problems: string[] = []; + +/** + * Which Secret holds which keys, read from the text rather than parsed. + * + * A YAML parser is a dependency this check does not need: both halves of the question are single + * lines at known indentation, and a rendered chart is machine-written, so the shapes do not vary. + */ +const written = new Map>(); +for (const document of documents) { + if (!/^kind:\s*Secret\s*$/m.test(document)) continue; + const name = document.match(/^\s{2}name:\s*(\S+)/m)?.[1]; + if (!name) continue; + const keys = new Set(); + // `stringData` or `data`: a subchart writes base64 under the second, and a key is a key either way. + const body = document.split(/^(?:stringData|data):\s*$/m)[1] ?? ""; + for (const line of body.split("\n")) { + const key = line.match(/^\s{2}([a-z0-9-]+):/)?.[1]; + if (key) keys.add(key); + } + written.set(name.replace(/^["']|["']$/g, ""), keys); +} + +/** + * Every key a container asks for, and whether anything writes it. + * + * Only checked against Secrets this chart renders. A Secret that comes from outside, or from a + * store, is not readable here, and refusing on a guess would fail installs that are fine. + */ +const demands = [ + ...text.matchAll( + /secretKeyRef:\s*\n\s*name:\s*(\S+)\s*\n\s*key:\s*([A-Za-z0-9._-]+)(?:\s*\n\s*optional:\s*(true|false))?/g, + ), +]; + +/* + * A check that finds nothing is not a check that passed. + * + * Every one of these questions is asked by matching text, and text moves: rename a field, reindent a + * template, and the pattern quietly stops matching. The result is a green tick that means "I looked + * at nothing", which is worse than no check at all because somebody trusts it. Every target this + * chart has renders a Deployment that reads secrets, so zero is always wrong. + */ +if (demands.length === 0) { + console.error( + "::error::This check found no secretKeyRef at all, which cannot be right. Its patterns have stopped matching the rendered output.", + ); + process.exit(1); +} +/* + * Unless the Secret is somebody else's. A deployment pointing at an existing Secret, or at a store + * through an ExternalSecret, renders none of its own, and there is nothing here to compare against. + * That is a legitimate shape, not a broken pattern. + */ +const secretComesFromOutside = + text.includes("kind: ExternalSecret") || text.includes("existingSecret"); +if (written.size === 0 && !secretComesFromOutside) { + console.error( + "::error::This check found no rendered Secret to compare against. Its patterns have stopped matching the rendered output.", + ); + process.exit(1); +} +let skippedOptional = 0; +for (const [, rawName, rawKey, optional] of demands) { + /* + * An optional key absent from the Secret is the deployment saying it does not need it, which is a + * legitimate shape rather than a fault. It is worth counting out loud: a key that is optional when + * it should not be is exactly how a required value went missing and was invisible here. + */ + if (optional === "true") { + skippedOptional += 1; + continue; + } + const name = rawName.replace(/^["']|["']$/g, ""); + const key = rawKey.replace(/^["']|["']$/g, ""); + const keys = written.get(name); + if (!keys) continue; + if (!keys.has(key)) { + problems.push( + `A container needs "${key}" from Secret "${name}", which this chart renders without it.`, + ); + } +} + +/** + * Anything the chart writes and nothing reads. + * + * The mirror of the above, and the reason a key gets quietly dropped from an environment: the + * Secret keeps carrying it and nobody notices the variable went. + */ +for (const [name, keys] of written) { + for (const key of keys) { + if (!text.includes(`key: ${key}`)) { + problems.push( + `Secret "${name}" carries "${key}", which nothing in this render reads.`, + ); + } + } +} + +if (problems.length > 0) { + for (const problem of problems) console.error(`::error::${problem}`); + process.exit(1); +} + +console.log( + `${documents.length} objects, ${demands.length} secret keys demanded, and every required one is written.` + + (skippedOptional > 0 + ? ` ${skippedOptional} optional key${skippedOptional === 1 ? " was" : "s were"} not checked.` + : ""), +); diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index 24e6775d..a0fdf026 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "./src/db/schema/coworker.ts", "./src/db/schema/components.ts", "./src/db/schema/plugins.ts", + "./src/db/schema/work.ts", ], out: "./drizzle", dbCredentials: { diff --git a/server/drizzle/0017_durable_work.sql b/server/drizzle/0017_durable_work.sql new file mode 100644 index 00000000..c03aaa11 --- /dev/null +++ b/server/drizzle/0017_durable_work.sql @@ -0,0 +1,16 @@ +CREATE TABLE "work_items" ( + "kind" text NOT NULL, + "key" text NOT NULL, + "run_at" timestamp with time zone DEFAULT now() NOT NULL, + "claimed_by" text, + "lease_until" timestamp with time zone, + "attempts" integer DEFAULT 0 NOT NULL, + "finished_at" timestamp with time zone, + "last_error" text, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "work_items_kind_key_pk" PRIMARY KEY("kind","key") +); +--> statement-breakpoint +CREATE INDEX "work_items_claimable_idx" ON "work_items" USING btree ("kind","run_at"); \ No newline at end of file diff --git a/server/drizzle/0018_page_frames.sql b/server/drizzle/0018_page_frames.sql new file mode 100644 index 00000000..1c301bf2 --- /dev/null +++ b/server/drizzle/0018_page_frames.sql @@ -0,0 +1,11 @@ +CREATE TABLE "computer_page_frame" ( + "computer_id" text NOT NULL, + "tool_call_id" text NOT NULL, + "url" text NOT NULL, + "title" text, + "frame" text NOT NULL, + "captured_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "computer_page_frame_computer_id_tool_call_id_pk" PRIMARY KEY("computer_id","tool_call_id") +); +--> statement-breakpoint +CREATE INDEX "computer_page_frame_captured_idx" ON "computer_page_frame" USING btree ("captured_at"); \ No newline at end of file diff --git a/server/drizzle/meta/0017_snapshot.json b/server/drizzle/meta/0017_snapshot.json new file mode 100644 index 00000000..b69915a6 --- /dev/null +++ b/server/drizzle/meta/0017_snapshot.json @@ -0,0 +1,2506 @@ +{ + "id": "eab681f3-28cb-4ef4-a5f0-5e82f53a6f42", + "prevId": "8e5982ba-c8e3-4634-a05c-68a90e066dac", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": ["channel_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": ["user_id", "role"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": ["user_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": ["component_name", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": ["component_name", "function_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": ["server_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": ["server_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": ["kind", "ref", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": ["skill_id", "ref"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": ["kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/0018_snapshot.json b/server/drizzle/meta/0018_snapshot.json new file mode 100644 index 00000000..1c83b0ec --- /dev/null +++ b/server/drizzle/meta/0018_snapshot.json @@ -0,0 +1,2577 @@ +{ + "id": "aa5ec39b-170c-495b-b4a9-e08ed0fd643d", + "prevId": "eab681f3-28cb-4ef4-a5f0-5e82f53a6f42", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": ["channel_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": ["user_id", "role"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": ["computer_id", "tool_call_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": ["user_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": ["component_name", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": ["component_name", "function_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": ["server_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": ["server_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": ["kind", "ref", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": ["skill_id", "ref"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": ["kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index aa1db8f5..8b13de0c 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -120,6 +120,20 @@ "when": 1787581516968, "tag": "0016_pin_and_soft_delete_channels", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1787684818682, + "tag": "0017_durable_work", + "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1787688017645, + "tag": "0018_page_frames", + "breakpoints": true } ] } diff --git a/server/scripts/cull-idle-computers.ts b/server/scripts/cull-idle-computers.ts new file mode 100644 index 00000000..626e55ae --- /dev/null +++ b/server/scripts/cull-idle-computers.ts @@ -0,0 +1,100 @@ +/** + * One sweep: notice which computers have gone idle, and suspend whatever this pod can claim. + * + * Run from a CronJob rather than from a timer inside the API. Every replica would fire its own timer + * and each would decide, independently, to suspend the same computer. Deleting old audit rows twice + * is harmless, which is why the retention sweep may work that way; taking a browser away from + * somebody who has just come back is not. + * + * Exits non-zero only when the sweep itself could not run. A computer that refused to suspend is + * reported and left for the next sweep, because a computer still running costs money rather than + * losing anything, and a failing CronJob that pages somebody at 3am should mean something worse. + */ +import { randomUUID } from "node:crypto"; +import { createPageFrameStore } from "../src/computer/page-frames"; +import { createComputerProvider } from "../src/computer/provider"; +import { loadConfig } from "../src/config"; +import { createDatabase } from "../src/db/client"; +import { + CULL_KIND, + offerIdleComputers, + suspendClaimedComputers, +} from "../src/work/culler"; +import { createWorkQueue } from "../src/work/queue"; + +const config = loadConfig(process.env); +if (!config.computer) { + throw new Error( + "No computer provider is configured, so there are no computers to suspend.", + ); +} +if (config.computer.provider !== "sandbox") { + throw new Error( + `The culler only has something to do where each Bot has its own computer, and this deployment uses the "${config.computer.provider}" provider.`, + ); +} + +const database = createDatabase(config.databaseUrl); +const queue = createWorkQueue(database); +const pageFrames = createPageFrameStore(database); +const provider = createComputerProvider(config.computer); + +// A name for the lease, so a stuck claim can be traced back to the pod that took it. +const owner = `culler/${process.env.HOSTNAME ?? randomUUID().slice(0, 8)}`; + +/** + * How long a turn's screenshot is kept. + * + * A month, because reading back a conversation is the thing these exist for and people do that long + * after the run. Past that the transcript names the page it opened instead, which is the same + * sentence with less in it rather than a broken one. + */ +const FRAME_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +try { + const options = { + database, + queue, + provider, + idleAfterMs: config.computer.idleAfterMs, + owner, + }; + const { offered } = await offerIdleComputers(options); + const report = await suspendClaimedComputers(options); + /* + * Sweep what has been done for a day. + * + * A finished row is what stops the same key being run twice, so it has to outlive the run by long + * enough for any late replica to collide with it. It does not have to outlive that by a week: a + * queue is not an archive, and the audit trail is where "what happened" lives. + */ + const purged = await queue.purge({ + kind: CULL_KIND, + olderThanMs: 24 * 60 * 60 * 1000, + }); + /* + * And the screenshots, which had a reaper and nothing calling it. + * + * A page is a row and a Bot that browses makes them for as long as it runs, so this table only + * ever grew: written on every navigation, taken out by a profile wipe and by nothing else. Kept + * long enough that reading back a conversation from last month still shows what it opened, and not + * for ever, because these are the largest thing this deployment stores and the least useful once + * nobody is reading that conversation any more. + * + * Here rather than in the API server because this is already the sweep that runs on a schedule + * with a claim under it, and a second timer would be a second thing to get wrong. + */ + const framesPurged = await pageFrames.purge(FRAME_RETENTION_MS); + console.info( + JSON.stringify({ + type: "computer-cull", + offered, + suspended: report.suspended, + skipped: report.skipped, + purged, + framesPurged, + }), + ); +} finally { + await database.$client.end({ timeout: 5 }); +} diff --git a/server/scripts/migrate.ts b/server/scripts/migrate.ts new file mode 100644 index 00000000..e4dddbed --- /dev/null +++ b/server/scripts/migrate.ts @@ -0,0 +1,43 @@ +/** + * Apply the migrations, using only what a running deployment already has. + * + * NOT `drizzle-kit migrate`, and that is the whole point of this file. The CLI is a development + * dependency: it reads `drizzle.config.ts`, which means compiling TypeScript, which means the esbuild + * that `bun install --production` correctly leaves out of a runtime image. Asked to migrate there it + * prints "Reading config file", exits 1, and says nothing at all, so a deployment looks like it + * migrated and comes up against an empty database complaining that `users` does not exist. + * + * The migrator underneath it is part of `drizzle-orm`, which is a runtime dependency because the + * server imports it anyway. It needs a connection and the folder of SQL files, both of which are in + * the image, and it keeps the same `drizzle.__drizzle_migrations` journal the CLI does, so the two + * are interchangeable and a database migrated by either is migrated. + */ +import { join } from "node:path"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error( + "DATABASE_URL must be configured before running a database migration command", + ); +} + +/* + * One connection, and `max: 1`. + * + * Migrations are a single ordered conversation with the database, and a pool would let two + * statements that must be ordered land on different connections. + */ +const client = postgres(databaseUrl, { max: 1, onnotice: () => {} }); + +try { + await migrate(drizzle(client), { + migrationsFolder: join(import.meta.dir, "..", "drizzle"), + }); + console.info(JSON.stringify({ type: "migrations-applied", status: "ok" })); +} finally { + // Released whatever happened, so a failure exits rather than hanging on an open socket. + await client.end({ timeout: 5 }); +} diff --git a/server/src/app.ts b/server/src/app.ts index fa8f1722..744a80c4 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -5,8 +5,6 @@ import { authoriseAgentCall } from "./agents/callback-token"; import type { BotAccessCheck } from "./agents/profile-policy"; import type { AgentProfileStore } from "./agents/profile-store"; import { createAgentRoutes } from "./agents/routes"; -import { createRoutingRoutes } from "./routing/routes"; -import type { IntentRouter } from "./routing/classify"; import { type AuditReader, type AuditStore, @@ -34,6 +32,7 @@ import type { ComponentStore } from "./components/store"; import type { ComputerGateway } from "./computer/gateway"; import type { PolicyStore } from "./computer/policy-store"; import { createComputerRoutes } from "./computer/routes"; +import type { PageFrameStore } from "./computer/page-frames"; import { configuredAuthProviders, type DeploymentConfig } from "./config"; import type { CredentialAdminService, CredentialInput } from "./credentials"; import { createIntelligenceClient } from "./intelligence-client"; @@ -41,6 +40,8 @@ import type { PeopleStore } from "./people/store"; import { createPluginRoutes } from "./plugins/routes"; import type { PluginStore } from "./plugins/store"; import { REFUSAL_MARKER } from "./plugins/tools"; +import type { IntentRouter } from "./routing/classify"; +import { createRoutingRoutes } from "./routing/routes"; import type { PackageStatusReader } from "./tenant-package"; /** @@ -155,6 +156,17 @@ export function createApp( * the default coworker, which is exactly the failsafe the router itself falls back to. */ intentRouter?: IntentRouter, + /** + * Where the frame a browsing turn ended on is kept. + * + * Appended last on purpose: these are positional, so inserting one anywhere else silently + * shifts every existing call site's arguments by one. + * + * Absent leaves the transcript working and past turns without a picture, which is the correct + * degraded behaviour: a conversation that cannot show what it saw is better than one that shows + * the wrong thing. + */ + pageFrames?: PageFrameStore, ) { const app = new Hono<{ Variables: AppVariables }>(); @@ -630,6 +642,7 @@ export function createApp( computerPolicy, requireUser, canUseBot, + pageFrames, ), ); } diff --git a/server/src/computer/gateway.ts b/server/src/computer/gateway.ts index bf6a8920..b2fa548d 100644 --- a/server/src/computer/gateway.ts +++ b/server/src/computer/gateway.ts @@ -35,6 +35,7 @@ export { WorkspaceRequestError, } from "./client"; +import type { PageFrameStore } from "./page-frames"; import { type ActionPolicy, evaluateActionPolicy, @@ -112,6 +113,13 @@ export type ComputerGatewayOptions = { * is correct in one process and is what a unit test wants. See snapshot-store.ts. */ snapshots?: SnapshotStore; + /** + * Where the frame a page was opened on is kept, so a reset can take them with the profile. + * + * Absent, a reset clears the profile and leaves the pictures, which is the wrong half of a promise + * this deployment makes in as many words. + */ + pageFrames?: PageFrameStore; }; export interface ComputerGateway { @@ -231,6 +239,7 @@ export function createComputerGateway( * ref from a superseded page from resolving to whatever now holds it. See snapshot-store.ts. */ const snapshots = options.snapshots ?? createInMemorySnapshotStore(); + const pageFrames = options.pageFrames; /** * Where this Bot's computer is, checked before anything is sent to it. @@ -667,6 +676,15 @@ export function createComputerGateway( // The refs the last snapshot handed out describe a page that no longer exists, and a fresh // computer counts generations from one again, so the row has to go with the profile. await snapshots.clear(botId); + /* + * And the pictures, which are the part that made the promise above untrue. + * + * "Every login the Bot had is gone" was said while screenshots of the signed-in pages stayed + * in the database: an inbox, an admin console, a bank statement, still readable from the + * transcript by anybody who could reach that Bot. A reset that leaves those has not reset + * anything a person would recognise as private. + */ + await pageFrames?.clear(botId); await writeControlEvent(auditStore, "computer.reset", { botId, actor, @@ -1037,19 +1055,26 @@ async function write( * is the file body of this pair, and stays out. */ ...(entry.command ? { command: entry.command } : {}), + /* + * The element, where the action named one. + * + * KEYED ON THE REF, not on the kind of action. An unresolved element is worth recording + * plainly rather than as an absent field that reads like a logging gap, but that only applies + * when the Bot pointed at something and the server could not say what: a ref it holds and the + * snapshot no longer does. Deciding it by elimination instead put "not in the current + * snapshot" on every navigation, every file read and every command, which is the reverse of + * the intent. Those actions did not fail to identify an element; they never had one, and a + * trail that says otherwise sends a reader looking for a snapshot that was never taken. + */ element: entry.element ? { role: entry.element.role, name: entry.element.name, ...(entry.element.type ? { type: entry.element.type } : {}), } - : entry.filePath || entry.command - ? // A file or command action has no element and never will. Those rows leave the element - // field absent rather than describing a browser snapshot. - undefined - : // An action on an element the server cannot identify is worth recording plainly, rather - // than as an absent field that reads like a logging gap. - "not in the current snapshot", + : entry.ref + ? "not in the current snapshot" + : undefined, ...(entry.failure ? { failure: entry.failure } : {}), decision: { allowed: entry.decision.allowed, diff --git a/server/src/computer/page-frames.ts b/server/src/computer/page-frames.ts new file mode 100644 index 00000000..727dbd0d --- /dev/null +++ b/server/src/computer/page-frames.ts @@ -0,0 +1,139 @@ +/** + * What a Bot's screen looked like when it opened a page. + * + * Written where the navigation happens, which is the one moment the screen is certainly showing the + * page that was asked for, and read back when somebody reopens the conversation that asked for it. + */ +import { and, eq, lt, sql } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { computerPageFrame } from "../db/schema"; + +/** + * A screenshot, and the ceiling on one. + * + * Generous enough for a full page at the sizes a computer runs, small enough that nothing can push + * megabytes into the store. Refused rather than truncated, because half a PNG is not a smaller + * picture, it is a broken one. + */ +const MAX_FRAME_BYTES = 4 * 1024 * 1024; + +/** + * How big a base64 string actually is, in bytes. + * + * `String.length` counts characters, which is the same number only while every character is ASCII. + * Base64 is ASCII, so the two agree today; measuring the thing the limit is named for means they + * cannot quietly stop agreeing. + */ +function byteLength(value: string): number { + return Buffer.byteLength(value, "utf8"); +} + +function tooLarge(value: string): boolean { + return byteLength(value) > MAX_FRAME_BYTES; +} + +export type PageFrameStore = { + save: (frame: { + computerId: string; + toolCallId: string; + url: string; + title?: string; + frame: string; + }) => Promise; + load: ( + computerId: string, + toolCallId: string, + ) => Promise<{ url: string; title: string | null; frame: string } | null>; + /** + * Everything kept for one computer. Returns how many went. + * + * Called when a profile is wiped, because "every login the Bot had is gone" is not true while + * pictures of the signed-in pages are still readable from the transcript. + */ + clear: (computerId: string) => Promise; + /** + * Frames older than the retention window, across every computer. Returns how many went. + * + * A page is a distinct row, so a Bot that browses grows this table for as long as it runs and + * nothing ever took anything out of it. Screenshots are the largest thing this deployment stores + * and the least useful once nobody is reading that conversation any more. + */ + purge: (olderThanMs: number) => Promise; +}; + +export function createPageFrameStore(database: Database): PageFrameStore { + return { + async save(input) { + if (!input.toolCallId || !input.url) return; + if (tooLarge(input.frame)) { + /* + * Said out loud rather than dropped in silence. A turn with no picture falls back to naming + * its page, which looks exactly like a turn nobody photographed, so without this line a + * deployment whose pages are all too big has no way to find that out. + */ + console.warn( + `[computer] a frame of ${input.url} was ${byteLength(input.frame)} bytes and was not kept; the limit is ${MAX_FRAME_BYTES}.`, + ); + return; + } + await database + .insert(computerPageFrame) + .values({ + computerId: input.computerId, + toolCallId: input.toolCallId, + url: input.url, + ...(input.title ? { title: input.title } : {}), + frame: input.frame, + }) + /* + * Written once. A turn happens once and is then over for good, so a second write for the + * same turn is either a retry of the same thing or a mistake, and neither should change what + * a past turn shows. Letting the newer win is what made the record mutable before. + */ + .onConflictDoNothing(); + }, + + async load(computerId, toolCallId) { + const [row] = await database + .select({ + url: computerPageFrame.url, + title: computerPageFrame.title, + frame: computerPageFrame.frame, + }) + .from(computerPageFrame) + /* + * Both, always. A caller who may reach one Bot must not be able to read another Bot's screen + * by naming a turn: the Bot in the path is what the route already checked, so it is what this + * is keyed on too. + */ + .where( + and( + eq(computerPageFrame.computerId, computerId), + eq(computerPageFrame.toolCallId, toolCallId), + ), + ); + return row ?? null; + }, + + async clear(computerId) { + const gone = await database + .delete(computerPageFrame) + .where(eq(computerPageFrame.computerId, computerId)) + .returning({ url: computerPageFrame.url }); + return gone.length; + }, + + async purge(olderThanMs) { + const gone = await database + .delete(computerPageFrame) + .where( + lt( + computerPageFrame.capturedAt, + sql`now() - make_interval(secs => ${olderThanMs / 1000})`, + ), + ) + .returning({ url: computerPageFrame.url }); + return gone.length; + }, + }; +} diff --git a/server/src/computer/provider.ts b/server/src/computer/provider.ts index 7993227c..6dd30336 100644 --- a/server/src/computer/provider.ts +++ b/server/src/computer/provider.ts @@ -1,11 +1,15 @@ import type { ComputerConfig } from "../config"; +import { + createSandboxComputerProvider, + inClusterConfig, + readSandboxTemplate, +} from "./sandbox"; +import type { ComputerStatus } from "./schema"; import { createDockerSupervisorProvider, type SupervisorOptions, } from "./supervisor"; -import type { ComputerStatus } from "./schema"; - /** The address and lifecycle details for one Bot's computer. */ export type ComputerLocation = { botId: string; @@ -232,5 +236,67 @@ export function createComputerProvider( baseUrl: config.baseUrl, ...(config.token ? { token: config.token } : {}), }); + case "sandbox": + /* + * Built lazily, because reading the service account is asynchronous and this factory is not. + * Every method needs the same credentials, so the promise is created once and awaited by each + * rather than the file being read on every call. + */ + return createLazySandboxProvider(config); } } + +/** + * The sandbox provider, built on first use. + * + * Its credentials come off disk, which is asynchronous, and `createComputerProvider` is not. Rather + * than make every caller await a factory, the work happens once behind a promise and each method + * waits on the same one. A failure to read them surfaces on the first computer request, naming what + * is missing, instead of taking the whole deployment down at boot over a feature it may not use. + */ +function createLazySandboxProvider( + config: Extract, +): ComputerProvider { + let built: Promise | undefined; + + const provider = async (): Promise => { + /* + * A FAILURE IS NOT REMEMBERED, only a success. + * + * `??=` holds whatever the first call produced, and a rejected promise is something. One + * unreadable token file or one blip reading the template at the wrong moment, and every computer + * request for the rest of the pod's life failed with that same stale error: no probe notices, + * because the pod is serving happily, and nothing recovers short of a restart. Clearing the memo + * on rejection makes the next request try again, which is what a transient failure deserves. + */ + built ??= (async () => { + const [cluster, template] = await Promise.all([ + inClusterConfig(), + readSandboxTemplate(config.templateFile), + ]); + return createSandboxComputerProvider({ + namespace: config.namespace, + idleAfterMs: config.idleAfterMs, + template, + apiServer: cluster.apiServer, + token: cluster.token, + ca: cluster.ca, + }); + })().catch((error: unknown) => { + built = undefined; + throw error; + }); + return built; + }; + + return { + name: "sandbox", + isolation: "per-bot", + locate: async (botId) => (await provider()).locate(botId), + status: async (botId) => (await provider()).status(botId), + stop: async (botId) => (await provider()).stop(botId), + reset: async (botId) => (await provider()).reset(botId), + list: async () => (await provider()).list(), + sessionOf: async (botId) => (await provider()).sessionOf?.(botId), + }; +} diff --git a/server/src/computer/routes.ts b/server/src/computer/routes.ts index e8a88be6..e2e1dd74 100644 --- a/server/src/computer/routes.ts +++ b/server/src/computer/routes.ts @@ -3,19 +3,20 @@ import { Hono } from "hono"; import type { BotAccessCheck } from "../agents/profile-policy"; import type { AppVariables } from "../auth/guards"; import { requireAdmin } from "../auth/guards"; +import { DEPLOYMENT_ROUTES } from "./deployment-routes"; import { type ActionActor, ActionRefusedError, type ComputerGateway, ComputerUnavailableError, ElementNotFoundError, - NavigationRefusedError, HumanHasControlError, + NavigationRefusedError, StaleSnapshotError, WorkspaceRefusedError, WorkspaceRequestError, } from "./gateway"; -import { DEPLOYMENT_ROUTES } from "./deployment-routes"; +import type { PageFrameStore } from "./page-frames"; import { type PolicyStore, parseActionPolicy } from "./policy-store"; /** @@ -38,6 +39,8 @@ export function createComputerRoutes( * deployment cannot be wired up without an answer to it. */ canUseBot: BotAccessCheck, + /** Where the frame a page was opened on is kept. Absent leaves the transcript as it was. */ + pageFrames?: PageFrameStore, ) { const routes = new Hono<{ Variables: AppVariables }>(); @@ -99,27 +102,152 @@ export function createComputerRoutes( } }); + /** + * Whether the same page is on both, ignoring the two ways one page spells itself. + * + * The trailing slash a browser adds and the fragment it keeps are not different pages. A URL that + * cannot be parsed is compared as written. + */ + function samePage(a: string, b: string): boolean { + const tidy = (value: string) => { + try { + const parsed = new URL(value); + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); + } catch { + return value.replace(/\/$/, ""); + } + }; + return tidy(a) === tidy(b); + } + + /** + * Whether a screenshot can be trusted to be of the page this turn opened. + * + * A SCREENSHOT THAT DOES NOT SAY WHAT IT IS OF IS THE ORDINARY CASE ON AN OLD COMPUTER. The url on + * a screenshot was added after the first computers shipped, so an `agent-computer` that has not + * been redeployed sends none. Refusing on a missing url therefore did not fail safe, it failed + * silently and completely: on a fleet part-way through a rollout the feature kept no frames at all + * and said nothing about why. + * + * So the question is asked where it means something. With a computer each there is no second Bot + * to race with, nothing can have moved the page between the navigation and the picture, and an + * unknown page is this turn's page. On one shared computer another Bot's navigation lands in + * exactly that gap, which is the case this guard exists for, and there an unknown page is refused. + */ + function frameIsOfThisPage( + shotUrl: string | undefined, + pageUrl: string, + ): { ok: true } | { ok: false; why: string } { + if (shotUrl === undefined) { + if (gateway.provider.isolation === "per-bot") return { ok: true }; + return { + ok: false, + why: "this computer is shared and the screenshot did not say which page it is of, so it cannot be told apart from another Bot's", + }; + } + if (!samePage(shotUrl, pageUrl)) { + return { ok: false, why: `the screen showed ${shotUrl}` }; + } + return { ok: true }; + } + + /** + * Photograph the page this turn just opened. + * + * Awaited rather than left running, so a surface asking for the frame the moment the turn ends + * finds it there. A navigation already costs seconds; one screenshot inside the cluster does not + * change how the tool feels, and a frame that arrives after the reader has gone is no frame at all. + * + * CHECKED AGAINST THE PAGE THE NAVIGATION REPORTED, because the screenshot is a second round trip + * and nothing holds the browser still between them. With one computer shared by every Bot, another + * Bot's navigation lands in that gap and this would file its page under this turn. The guard used + * to live in the browser and was deleted when the capture moved here; it belongs on whichever side + * does the capturing. + * + * Never fatal. A stored picture is a convenience for reading a conversation back, and failing the + * navigation the Bot was asked to do because the convenience failed would be the wrong trade every + * time. EVERY REFUSAL SAYS SO, including the two that used to return quietly: a deployment that + * keeps no frames has to be able to find out why from its own logs rather than by reading this file. + */ + async function keepFrameOf( + botId: string, + toolCallId: string, + url: string, + title: string, + ) { + if (!pageFrames) return; + if (!toolCallId) { + console.warn( + `[computer] not keeping a frame of ${url}: the caller did not say which turn it was for.`, + ); + return; + } + try { + /* + * Only if the computer is already up. `screenshot` goes through `locate`, which resumes a + * suspended computer, so a convenience picture could wake a machine the culler had just put to + * sleep and hold a navigation open for the length of a pod schedule while it did. + */ + const status = await gateway.status(botId); + if (status.state !== "ready") { + console.warn( + `[computer] not keeping a frame for ${toolCallId}: the computer is ${status.state}, and photographing it would wake it.`, + ); + return; + } + const shot = await gateway.screenshot(botId); + const verdict = frameIsOfThisPage(shot.url, url); + if (!verdict.ok) { + console.warn( + `[computer] not keeping a frame for ${toolCallId}: ${verdict.why} rather than ${url}.`, + ); + return; + } + await pageFrames.save({ + computerId: botId, + toolCallId, + url, + title, + frame: shot.base64, + }); + } catch (error) { + console.warn( + `[computer] could not keep a frame of ${url}:`, + error instanceof Error ? error.message : error, + ); + } + } + routes.post("/:botId/navigate", async (context) => { const body = (await context.req.json().catch(() => null)) as { url?: unknown; + toolCallId?: unknown; } | null; if (typeof body?.url !== "string" || !body.url.trim()) { return context.json({ error: "A web address is required." }, 400); } + /* + * Which turn asked, so the picture can be filed under it. Optional: a caller that does not know + * (the side panel, a script) still navigates, and simply keeps no frame. + */ + const toolCallId = + typeof body.toolCallId === "string" ? body.toolCallId : ""; try { - return context.json( - await gateway.navigate( - context.req.param("botId") ?? "default", - { - id: context.var.actor.id, - ...(context.var.actor.email === DEV_ACTOR_EMAIL - ? {} - : { userId: context.var.actor.id }), - }, - body.url.trim(), - ), + const botId = context.req.param("botId") ?? "default"; + const result = await gateway.navigate( + botId, + { + id: context.var.actor.id, + ...(context.var.actor.email === DEV_ACTOR_EMAIL + ? {} + : { userId: context.var.actor.id }), + }, + body.url.trim(), ); + await keepFrameOf(botId, toolCallId, result.url, result.title); + return context.json(result); } catch (error) { if (error instanceof ActionRefusedError) { return context.json({ error: error.message, rule: error.rule }, 403); @@ -361,6 +489,23 @@ export function createComputerRoutes( }); /** The Bot's files. Through the gateway, like every other acting call. */ + /** + * The frame this turn was showing when it opened its page. + * + * Read only. Nothing writes through a route: the frame is taken where the navigation happens, + * which is the one moment the screen is certainly showing the page that was asked for. The surface + * used to capture it itself, after the turn, and lost the race often enough to show the wrong page + * or a blank one. + */ + routes.get("/:botId/page-frame/:toolCallId", async (context) => { + if (!pageFrames) return context.json({ frame: null }); + const stored = await pageFrames.load( + context.req.param("botId"), + context.req.param("toolCallId"), + ); + return context.json({ frame: stored }); + }); + routes.post("/:botId/files/list", (context) => act(context, (botId, actor, body) => gateway.listFiles(botId, actor, { diff --git a/server/src/computer/sandbox.ts b/server/src/computer/sandbox.ts new file mode 100644 index 00000000..3169a973 --- /dev/null +++ b/server/src/computer/sandbox.ts @@ -0,0 +1,416 @@ +/** + * A computer each, as a `Sandbox` on Kubernetes. + * + * `kubernetes-sigs/agent-sandbox` defines a CRD for "isolated, stateful singleton workloads with + * stable identity and persistent storage", aimed at agents that run untrusted code and drive + * graphical interfaces. That is a description of a Bot's computer, so this provider maps onto it + * rather than hand-rolling a StatefulSet per Bot and owning suspend, resume and identity ourselves. + * + * The part that would have been the hard build is a field. `spec.operatingMode` is `Running` or + * `Suspended`, and Suspended terminates the pod while keeping the volumes, so "spin down when idle, + * come back with the logins intact" is one patch and a condition to wait on. + * + * NO CLIENT LIBRARY. The Kubernetes API is HTTP and JSON, this needs five verbs of it, and a + * generated client would be a large dependency in an image that already ships a browser. The + * in-cluster service account gives a token and a CA, which is what `inClusterConfig` reads. + */ +import { readFile } from "node:fs/promises"; +import type { ComputerLocation, ComputerProvider } from "./provider"; +import type { ComputerStatus } from "./schema"; + +const SERVICE_ACCOUNT = "/var/run/secrets/kubernetes.io/serviceaccount"; +const GROUP = "agents.x-k8s.io"; +const VERSION = "v1beta1"; + +export class SandboxError extends Error { + constructor(message: string) { + super(message); + this.name = "SandboxError"; + } +} + +/** One Sandbox, in the shape the parts of it this file reads. */ +type Sandbox = { + metadata?: { name?: string; creationTimestamp?: string }; + spec?: { operatingMode?: "Running" | "Suspended" }; + status?: { + serviceFQDN?: string; + conditions?: { + type?: string; + status?: string; + reason?: string; + /** When this condition last changed, which is how one run of a computer is told from the next. */ + lastTransitionTime?: string; + }[]; + podIPs?: string[]; + nodeName?: string; + }; +}; + +export type SandboxProviderOptions = { + /** Where the Bots' computers live. The provider is scoped to exactly this namespace. */ + namespace: string; + /** + * The pod and volumes every computer is cut from. + * + * `Sandbox` carries its pod inline and has no template reference, so this has to come from + * somewhere. It comes from a file the chart mounts, which means the server needs no permission to + * read ConfigMaps and the shape of a computer stays a deployment decision rather than a constant + * in this file. + */ + template: Record; + /** How long a computer may go untouched before the culler suspends it. */ + idleAfterMs: number; + apiServer?: string; + /** + * How this pod proves who it is, or a way to ask for the current one. + * + * A FUNCTION IS THE HONEST SHAPE, because a projected service account token is not a constant. The + * kubelet rewrites the file well before the token expires, and the expiry is the cluster's to set: + * an hour on a hardened cluster, a day by default. Read once and held for the life of the process, + * it works right up until the first rotation and then every sandbox call returns 401, which reads + * like the cluster broke rather than like a credential going stale. + */ + token?: string | (() => Promise); + ca?: string; + fetchImpl?: typeof fetch; + /** How long `locate` waits for a suspended computer to come back before giving up. */ + resumeTimeoutMs?: number; +}; + +/** + * The service account this pod was given, which is how it reaches the API server. + * + * Absent outside a cluster, and that is not an error here: `createComputerProvider` only asks for + * this provider when a deployment configured it, and a clear message about a missing token beats a + * connection refused to an address nobody set. + */ +/** + * Read the pod template the chart mounted. + * + * A missing file is a deployment that asked for per-Bot computers without saying what one looks + * like, which is worth failing on by name rather than creating a Sandbox the API server rejects for + * a missing required field. + */ +export async function readSandboxTemplate( + path: string, +): Promise> { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch { + throw new SandboxError( + `COMPUTER_SANDBOX_TEMPLATE_FILE points at ${path}, which cannot be read. That file is what a Bot's computer is cut from; the chart mounts it when computers.mode is sandbox.`, + ); + } + const parsed = JSON.parse(raw) as Record; + if (!parsed.podTemplate) { + throw new SandboxError( + `${path} has no podTemplate, so there is nothing to make a computer from.`, + ); + } + return parsed; +} + +/** How long a read token is reused before the file is consulted again. */ +const TOKEN_MEMO_MS = 30_000; + +export async function inClusterConfig(): Promise<{ + apiServer: string; + token: () => Promise; + ca: string; +}> { + const host = process.env.KUBERNETES_SERVICE_HOST; + const port = process.env.KUBERNETES_SERVICE_PORT ?? "443"; + if (!host) { + throw new SandboxError( + "COMPUTER_PROVIDER=sandbox needs to run inside a cluster: KUBERNETES_SERVICE_HOST is not set, so there is no API server to ask for a Bot's computer.", + ); + } + /* + * The CA is read once and the token is not. + * + * A cluster CA changes when the cluster is rebuilt, which is not a thing that happens under a + * running pod. The token changes on a schedule the cluster chooses, so it is re-read, with a short + * memo so an ordinary burst of sandbox calls does not become a burst of disk reads. Half a minute + * is far inside any rotation window and far outside any burst. + */ + const ca = await readFile(`${SERVICE_ACCOUNT}/ca.crt`, "utf8"); + let cached: { value: string; readAt: number } | undefined; + const token = async () => { + if (cached && Date.now() - cached.readAt < TOKEN_MEMO_MS) + return cached.value; + const value = (await readFile(`${SERVICE_ACCOUNT}/token`, "utf8")).trim(); + cached = { value, readAt: Date.now() }; + return value; + }; + // Read once here so a missing or unreadable token is an error at start-up rather than on the + // first Bot to ask for a computer. + await token(); + return { apiServer: `https://${host}:${port}`, token, ca }; +} + +/** + * A Kubernetes name for a Bot. + * + * Bot ids are ours and may hold anything a person typed; a resource name may hold lowercase + * alphanumerics and dashes, and is refused rather than truncated by the API server. Anything else + * becomes a dash, and a short hash keeps two ids that differ only in punctuation from colliding on + * one computer, which would be one Bot reading another's logins. + */ +export function sandboxNameFor(botId: string): string { + const slug = botId + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/^-+|-+$/g, ""); + let hash = 5381; + for (let index = 0; index < botId.length; index += 1) { + hash = ((hash << 5) + hash + botId.charCodeAt(index)) >>> 0; + } + const suffix = hash.toString(36); + return `bot-${slug.slice(0, 40) || "unnamed"}-${suffix}`; +} + +function conditionOf(sandbox: Sandbox, type: string): string | undefined { + return sandbox.status?.conditions?.find((c) => c.type === type)?.status; +} + +/** Ready means the pod is up and the service answers; anything else is not somewhere to send a Bot. */ +function isReady(sandbox: Sandbox): boolean { + return conditionOf(sandbox, "Ready") === "True"; +} + +function isSuspended(sandbox: Sandbox): boolean { + return ( + sandbox.spec?.operatingMode === "Suspended" || + conditionOf(sandbox, "Suspended") === "True" + ); +} + +export function createSandboxComputerProvider( + options: SandboxProviderOptions, +): ComputerProvider { + const doFetch = options.fetchImpl ?? fetch; + const resumeTimeoutMs = options.resumeTimeoutMs ?? 120_000; + const base = () => + `${options.apiServer}/apis/${GROUP}/${VERSION}/namespaces/${options.namespace}/sandboxes`; + + async function call( + path: string, + init: RequestInit & { contentType?: string } = {}, + ): Promise { + const { contentType, ...rest } = init; + const token = + typeof options.token === "function" + ? await options.token() + : options.token; + const response = await doFetch(`${base()}${path}`, { + ...rest, + /* + * The cluster's own CA, which is the only thing that signs the API server's certificate. + * + * It is not in any public trust store, so without this every call fails with "unable to verify + * the first certificate" and a Bot simply never gets a computer. The alternative some reach for + * is to stop verifying, which would leave the token in every one of these requests open to + * anything that can answer on that address. + */ + ...(options.ca ? { tls: { ca: options.ca } } : {}), + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(contentType ? { "content-type": contentType } : {}), + accept: "application/json", + ...(rest.headers ?? {}), + }, + } as RequestInit); + if (response.status === 404) return undefined; + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new SandboxError( + `The cluster refused a sandbox request (${response.status}): ${body.slice(0, 300)}`, + ); + } + return response.json(); + } + + const read = (botId: string) => + call(`/${sandboxNameFor(botId)}`) as Promise; + + /** The desired body for a Bot's computer. Created once, then only ever patched. */ + function desired(botId: string): Record { + return { + apiVersion: `${GROUP}/${VERSION}`, + kind: "Sandbox", + metadata: { + name: sandboxNameFor(botId), + namespace: options.namespace, + labels: { + "app.kubernetes.io/managed-by": "openbot", + "openbot.dev/component": "computer", + }, + // The Bot id as written, which the name above cannot always carry. The culler reads this + // rather than trying to reverse the slug. + annotations: { "openbot.dev/bot-id": botId }, + }, + spec: { + operatingMode: "Running", + // `podTemplate` is required and `volumeClaimTemplates` is what makes a suspend worth doing, + // and both arrive together from the mounted template. + ...options.template, + }, + }; + } + + async function waitForReady(botId: string): Promise { + const deadline = Date.now() + resumeTimeoutMs; + for (;;) { + const sandbox = await read(botId); + if (sandbox && isReady(sandbox) && sandbox.status?.serviceFQDN) { + return sandbox; + } + if (Date.now() >= deadline) { + throw new SandboxError( + `The computer for ${botId} did not become ready within ${Math.round(resumeTimeoutMs / 1000)}s. A resume costs a pod schedule and a browser launch, so this is a real wait rather than a failure, but it has to end somewhere.`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + } + + return { + name: "sandbox", + isolation: "per-bot", + + async locate(botId: string): Promise { + const existing = await read(botId); + if (!existing) { + await call("", { + method: "POST", + contentType: "application/json", + body: JSON.stringify(desired(botId)), + }); + } else if (isSuspended(existing)) { + /* + * Woken, because somebody is asking for it. + * + * `locate` runs immediately before an action, so reaching a suspended computer here means a + * person is waiting. A merge patch rather than a replace: the controller owns most of this + * object and writing the whole thing back would fight it. + */ + await call(`/${sandboxNameFor(botId)}`, { + method: "PATCH", + contentType: "application/merge-patch+json", + body: JSON.stringify({ spec: { operatingMode: "Running" } }), + }); + } + + const ready = await waitForReady(botId); + const fqdn = ready.status?.serviceFQDN; + if (!fqdn) { + throw new SandboxError( + `The computer for ${botId} is ready but reported no address, so it cannot be reached.`, + ); + } + return `http://${fqdn}:4100`; + }, + + async status(botId: string): Promise { + try { + const sandbox = await read(botId); + if (!sandbox) return { botId, state: "absent" }; + /* + * A SUSPENDED COMPUTER IS DOWN AND FINE, and reading it any other way is how scale-to-zero + * is lost. Answering this by dialling the pod would wake it, so every computer anything ever + * asked about would come back up and the bill would never fall. The conditions are the whole + * answer and nothing here touches the browser. + */ + if (isSuspended(sandbox)) return { botId, state: "absent" }; + if (isReady(sandbox)) return { botId, state: "ready" }; + return { botId, state: "starting" }; + } catch (error) { + return { + botId, + state: "unreachable", + reason: + error instanceof Error && error.message.length > 0 + ? error.message + : "The cluster could not be asked about this computer.", + }; + } + }, + + async stop(botId: string): Promise<{ wasRunning: boolean }> { + const sandbox = await read(botId); + if (!sandbox || isSuspended(sandbox)) return { wasRunning: false }; + // Suspended, not deleted: the pod goes and the volumes stay, which is the difference between + // stopping a computer and wiping a Bot's logins. + await call(`/${sandboxNameFor(botId)}`, { + method: "PATCH", + contentType: "application/merge-patch+json", + body: JSON.stringify({ spec: { operatingMode: "Suspended" } }), + }); + return { wasRunning: true }; + }, + + async reset(botId: string): Promise<{ cleared: boolean }> { + const sandbox = await read(botId); + if (!sandbox) return { cleared: false }; + // Deleted, which takes the volumes with it. This is the one that is meant to lose the logins. + await call(`/${sandboxNameFor(botId)}`, { method: "DELETE" }); + return { cleared: true }; + }, + + async list(): Promise { + const body = (await call("")) as { items?: Sandbox[] } | undefined; + return (body?.items ?? []).map((sandbox) => { + const botId = + (sandbox.metadata as { annotations?: Record }) + ?.annotations?.["openbot.dev/bot-id"] ?? + sandbox.metadata?.name ?? + ""; + return { + botId, + status: + isSuspended(sandbox) || !isReady(sandbox) ? "stopped" : "running", + url: sandbox.status?.serviceFQDN + ? `http://${sandbox.status.serviceFQDN}:4100` + : "", + ...(sandbox.metadata?.creationTimestamp + ? { startedAt: sandbox.metadata.creationTimestamp } + : {}), + }; + }); + }, + + async sessionOf(botId: string): Promise { + /* + * Which run of this computer this is, and it has to change across a suspend and resume. + * + * A snapshot's generation only orders snapshots within one run of a browser: a resumed computer + * counts from one again, so a ref the model still holds from before the suspend would match a + * row nothing has overwritten, and the boundary would decide about an element on a page that no + * longer exists. + * + * NOT THE NODE AND NOT THE POD IP, which is what this used and what testing a real resume + * disproved. A suspended sandbox is very often rescheduled onto the same node and handed the + * same address back, because nothing else has taken it: measured on EKS, both were byte for + * byte identical across a suspend and resume, so the check would have said "same run" for the + * exact case it exists to catch. + * + * The `Ready` condition's transition time does move, because suspending drives Ready to False + * and resuming drives it back to True. It is the moment this run of the browser started + * serving, which is precisely the question, and it needs no permission beyond the sandbox this + * already reads. The pod's own UID would be exact too, and would cost a second read and the + * right to list pods. + * + * Reading, never ensuring: this must not be the thing that wakes a computer up. + */ + const sandbox = await read(botId); + if (!sandbox || isSuspended(sandbox)) return undefined; + const ready = sandbox.status?.conditions?.find( + (condition) => condition.type === "Ready", + ); + if (ready?.status !== "True") return undefined; + return ready.lastTransitionTime; + }, + }; +} diff --git a/server/src/computer/supervisor.ts b/server/src/computer/supervisor.ts index 37c388f7..76ac0201 100644 --- a/server/src/computer/supervisor.ts +++ b/server/src/computer/supervisor.ts @@ -13,14 +13,22 @@ * honest about being one shared computer. */ -import type { ComputerStatus } from "./schema"; import type { ComputerLocation, ComputerProvider } from "./provider"; +import type { ComputerStatus } from "./schema"; /** * The last container start time seen for each Bot, from the `/ensure` that located it. * * Not a cache in front of the supervisor: `locate` still calls it every time. This only carries the * answer the few lines to whoever needs to know which run of the computer they are talking to. + * + * PROCESS-LOCAL, AND THEREFORE NEVER THE ONLY ANSWER. On one replica the snapshot and the click that + * follows it are the same process, so this is always populated by the time anything asks. On several + * they are usually not, and a replica that has never located this Bot has nothing here. `resolve` + * reads an unknown session as "no opinion" and skips the generation check, so an empty map does not + * fail — it silently stops checking, on exactly the deployment shape the check was written for. So + * `sessionOf` falls back to asking, and this stays what it always was: a way to skip the round trip + * on the replica that just did the work. */ const sessions = new Map(); @@ -157,11 +165,34 @@ export function createDockerSupervisorProvider( async sessionOf(botId: string): Promise { /* * Read from the same `/ensure` every action already makes, and remembered rather than asked - * for again: `locate` runs immediately before the call that needs this, so the value is as - * fresh as the address it was fetched with. Asking twice would double the supervisor's work on - * the hot path to learn something it just told us. + * for again: on the replica that located this Bot, `locate` ran immediately before the call + * that needs this, so the value is as fresh as the address it was fetched with. Asking twice + * would double the supervisor's work on the hot path to learn something it just told us. */ - return sessions.get(botId); + const known = sessions.get(botId); + if (known) return known; + + /* + * Nothing here means another replica did the work, not that there is nothing to know. + * + * LISTING, NOT ENSURING, and the difference is the whole feature. `/ensure` starts a computer + * that is not running, so answering "which run is this" with it would wake every idle Bot that + * anything asked about, and a deployment that suspends idle computers would quietly never + * suspend one. Listing is a read: a Bot with no computer answers undefined, which is the same + * answer as before and leaves the check exactly where it was. + */ + try { + const computers = await listRaw(); + const startedAt = computers.find( + (computer) => computer.botId === botId, + )?.startedAt; + if (startedAt) sessions.set(botId, startedAt); + return startedAt; + } catch { + // Unknown, not mismatched. A supervisor that cannot be reached must not turn every ref into + // a refusal; the generation check goes back to being skipped, which is where it started. + return undefined; + } }, async locate(botId: string): Promise { diff --git a/server/src/config.ts b/server/src/config.ts index c4a71671..3dced0e6 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -38,7 +38,28 @@ export type SharedComputerConfig = { policy?: ActionPolicy; }; -export type ComputerConfig = DockerComputerConfig | SharedComputerConfig; +/** + * A computer each, created by the cluster. + * + * The namespace is the whole scope: the service account this runs under may manage Sandboxes there + * and nowhere else, which is a smaller blast radius than the Docker supervisor's, since that one + * holds a socket that is root-equivalent on its host. + */ +export type SandboxComputerConfig = { + provider: "sandbox"; + namespace: string; + idleAfterMs: number; + /** Where the chart mounted the shape of a computer. */ + templateFile: string; + token?: string; + allowPrivateHosts: boolean; + policy?: ActionPolicy; +}; + +export type ComputerConfig = + | DockerComputerConfig + | SharedComputerConfig + | SandboxComputerConfig; /** * Who a deployment lets in, and through which front door. @@ -560,10 +581,39 @@ function privateHostsAllowed(environment: Environment): boolean { return true; } +/** + * A duration a person would write, as milliseconds. + * + * `30m` rather than `1800000`, because this one is read and edited by whoever is deciding how long a + * computer may sit idle, and a wrong number of zeroes there is either a computer that never sleeps + * or one that vanishes mid-task. Plain digits are still milliseconds, so anything already set keeps + * its meaning. + */ +export function durationMs(value: string): number { + const match = /^(\d+)\s*(ms|s|m|h)?$/.exec(value.trim()); + if (!match) { + throw new Error( + `"${value}" is not a duration. Write it as 30s, 30m, 2h, or a plain number of milliseconds.`, + ); + } + const amount = Number(match[1]); + switch (match[2]) { + case "h": + return amount * 3_600_000; + case "m": + return amount * 60_000; + case "s": + return amount * 1_000; + default: + return amount; + } +} + function computerConfig(environment: Environment): ComputerConfig | undefined { const supervisorAddress = optional(environment, "COMPUTER_SUPERVISOR_URL"); const sharedAddress = optional(environment, "AGENT_COMPUTER_URL"); - if (!supervisorAddress && !sharedAddress) { + const sandboxNamespace = optional(environment, "COMPUTER_SANDBOX_NAMESPACE"); + if (!supervisorAddress && !sharedAddress && !sandboxNamespace) { return undefined; } @@ -577,6 +627,27 @@ function computerConfig(environment: Environment): ComputerConfig | undefined { const allowPrivateHosts = privateHostsAllowed(environment); const policy = actionPolicy(environment); + /* + * Checked before the other two, because a deployment that named a namespace means the cluster to + * make the computers, and a stray `AGENT_COMPUTER_URL` left in an environment would otherwise + * quietly put every Bot back on one shared browser. + */ + if (sandboxNamespace) { + return { + provider: "sandbox", + namespace: sandboxNamespace, + idleAfterMs: durationMs( + optional(environment, "COMPUTER_SANDBOX_IDLE_AFTER") ?? "30m", + ), + templateFile: + optional(environment, "COMPUTER_SANDBOX_TEMPLATE_FILE") ?? + "/etc/openbot/sandbox-template.json", + allowPrivateHosts, + ...(computerToken ? { token: computerToken } : {}), + ...(policy ? { policy } : {}), + }; + } + const supervisorUrl = url(environment, "COMPUTER_SUPERVISOR_URL"); if (supervisorUrl) { const supervisorToken = optional(environment, "SUPERVISOR_TOKEN"); diff --git a/server/src/db/client.ts b/server/src/db/client.ts index d197dc0e..2aa57a45 100644 --- a/server/src/db/client.ts +++ b/server/src/db/client.ts @@ -12,6 +12,20 @@ export function createDatabase( databaseUrl: string, options: { max?: number } = {}, ) { + /* + * Loud rather than silent when the arguments are the wrong way round. + * + * Bun's `SQL` takes either a URL or an options object as its one argument, so a caller passing the + * pool options where the address belongs gets a working database from `$DATABASE_URL` and no + * complaint. Two tests were doing exactly that, green for a reason that had nothing to do with + * what they were checking, and the test tree is not type-checked so nothing else was going to say + * so. A connection string is a string. + */ + if (typeof databaseUrl !== "string" || databaseUrl.trim() === "") { + throw new TypeError( + "createDatabase needs a connection string as its first argument. Pool options go second.", + ); + } const client = options.max === undefined ? new SQL(databaseUrl) diff --git a/server/src/db/schema/computer.ts b/server/src/db/schema/computer.ts index 87b508d5..6892f8c6 100644 --- a/server/src/db/schema/computer.ts +++ b/server/src/db/schema/computer.ts @@ -4,7 +4,14 @@ * Split by owner so two people can add tables all day without touching the same lines. Add tables * here; never edit core.ts or coworker.ts to do it. */ -import { integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { + index, + integer, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; import { jsonb } from "./json"; /** @@ -80,3 +87,47 @@ export const computerSnapshot = pgTable("computer_snapshot", { */ session: text("session"), }); + +/** + * What a Bot's screen looked like on the turn that opened it. + * + * A conversation is a record, and a record must not change its mind. The transcript used to fetch + * the live screen for every past turn, so an answer about one page sat under a picture of whichever + * page the Bot had open by the time somebody read it back. + * + * KEYED ON THE TURN, which is the identity of the thing being remembered. Keying on the page instead + * was a mistake with a plausible reason: two visits to one address collided, and letting the newer + * win made a past turn's picture change under it, which is the exact mutability this table exists to + * remove. A turn happens once and is then over for good, so the row is written once and never + * updated. + * + * The computer is in the key as well as the turn, so a caller who may reach one Bot cannot read + * another Bot's screen by naming a tool call. + */ +export const computerPageFrame = pgTable( + "computer_page_frame", + { + /** Whose computer it was. */ + computerId: text("computer_id").notNull(), + /** The turn that opened it. */ + toolCallId: text("tool_call_id").notNull(), + /** The page, as the browser reported it after the navigation settled. */ + url: text("url").notNull(), + title: text("title"), + /** + * The frame itself, base64 PNG. + * + * Bounded by the code that writes it rather than by the column, because the useful limit is "a + * screenshot" and the honest failure is a refusal at the boundary rather than a database error. + */ + frame: text("frame").notNull(), + capturedAt: timestamp("captured_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.computerId, table.toolCallId] }), + // The reaper's query: everything older than the retention window, whoever it belongs to. + index("computer_page_frame_captured_idx").on(table.capturedAt), + ], +); diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts index b924af64..68ee5d83 100644 --- a/server/src/db/schema/index.ts +++ b/server/src/db/schema/index.ts @@ -3,5 +3,6 @@ export * from "./components"; export * from "./computer"; export * from "./core"; -export * from "./plugins"; export * from "./coworker"; +export * from "./plugins"; +export * from "./work"; diff --git a/server/src/db/schema/work.ts b/server/src/db/schema/work.ts new file mode 100644 index 00000000..6f6cb18a --- /dev/null +++ b/server/src/db/schema/work.ts @@ -0,0 +1,98 @@ +import { + index, + integer, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; +import { jsonb } from "./json"; + +const createdAt = () => + timestamp("created_at", { withTimezone: true }).notNull().defaultNow(); +const updatedAt = () => + timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(); + +/** + * Durable work, claimed by whichever replica gets there first, leased so a dead one's work comes + * back. + * + * ONE MECHANISM, THREE FEATURES. Suspending idle computers needs it, scheduled routines need it, and + * a hop from one Bot to another needs it. Written once with all three in view rather than three + * times slightly differently, because the parts that are easy to get wrong are the same every time: + * who owns an item, what happens when the owner dies, and whether a recovery can run something + * twice. + * + * POSTGRES, NOT A QUEUE. It is already the thing every replica shares, and `for update skip locked` + * is exactly this problem: each replica takes rows nobody else holds, no coordinator, no leader + * election, no single point of failure, and adding a replica adds throughput rather than contention. + * + * NOT `setInterval`. The audit-retention sweep uses one and is safe only because deleting old rows + * twice is the same as deleting them once. Anything that spends money, calls a tool or posts a + * message is not that: fired by every replica it happens N times, and on a cluster that is Tuesday. + */ +export const workItems = pgTable( + "work_items", + { + /** What kind of work. `computer.suspend` today; routines and bot-to-bot hops later. */ + kind: text("kind").notNull(), + /** + * What the work is about, unique within its kind. + * + * The Bot id for a computer to suspend, and for a routine the routine and the minute it was due, + * because IDEMPOTENCE LIVES HERE. A routine due at 07:00 must run once even if three replicas + * wake together and a lease is reclaimed mid-flight; making the key carry the scheduled time is + * what turns "fire it again" into "insert that already exists" rather than a second run. Without + * it every recovery path is also a duplicate-run path. + */ + key: text("key").notNull(), + /** When this becomes eligible. A claim never sees an item before its time. */ + runAt: timestamp("run_at", { withTimezone: true }).notNull().defaultNow(), + /** + * Which replica holds it, and until when. + * + * Null owner means nobody has it. A lease in the past means whoever had it stopped renewing, and + * the row is free again: that is the whole recovery story, and it needs no process to notice a + * death, only the next claim to look at the clock. + */ + claimedBy: text("claimed_by"), + leaseUntil: timestamp("lease_until", { withTimezone: true }), + /** + * How many times this has been handed out. + * + * A RECLAIMED ITEM IS NOT A FRESH ONE, and the difference matters enough to count rather than + * infer. An item on its first attempt has certainly not run; one on its second may already have + * called a tool and spent money before its owner died. Whatever picks it up has to be able to + * tell those apart, so it is a number here rather than a state folded into failure. + */ + attempts: integer("attempts").notNull().default(0), + /** + * When it was done, or null while it still wants doing. + * + * KEPT RATHER THAN DELETED, because the idempotence this table promises has to survive + * completion. Finishing used to remove the row, so a routine due at 07:00 that ran and finished + * was re-offered cleanly by the next replica to wake late and ran a second time: the insert that + * was supposed to collide had nothing left to collide with. A finished row is the collision. + * + * Swept on a retention window rather than kept forever, because a queue is not an archive. + */ + finishedAt: timestamp("finished_at", { withTimezone: true }), + /** + * Why the last attempt gave up. + * + * An item that has run out of attempts stops being handed out and stays here with its count and + * its reason. That is the terminal state: visible in the table somebody can query rather than a + * row that quietly retries until the end of time. + */ + lastError: text("last_error"), + /** Anything the work needs that is not in the key. */ + payload: jsonb("payload").notNull().default({}), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (table) => [ + primaryKey({ columns: [table.kind, table.key] }), + // The claim's own query: due, unclaimed or expired, not yet finished, oldest first. + index("work_items_claimable_idx").on(table.kind, table.runAt), + ], +); diff --git a/server/src/index.ts b/server/src/index.ts index 7e2c87bb..228be1bb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -22,6 +22,7 @@ import { createThreadIdentity } from "./channels/thread-identity"; import { createSandboxedStore } from "./components/sandboxed"; import { createComponentStore } from "./components/store"; import { createComputerGateway } from "./computer/gateway"; +import { createPageFrameStore } from "./computer/page-frames"; import { startPolicyListener } from "./computer/policy-listener"; import { createPolicyStore, @@ -254,6 +255,9 @@ const computerGateway = computerProvider // when the snapshot was taken by another server. A Map here would be blank on every replica // but the one that snapshotted, and the boundary would decide with no element to look at. snapshots: createSnapshotStore(database), + // So wiping a profile takes the pictures of its signed-in pages with it, which is what the + // sentence on that button already promised. + pageFrames: createPageFrameStore(database), allowPrivateHosts: config.computer?.allowPrivateHosts, token: config.computer?.token, }) @@ -551,6 +555,8 @@ const app = createApp( identityProviderStore, // Chooses the coworker for an untagged message, on the deployment's own model and key. intentRouter, + // What a browsing turn's screen looked like when it finished, so the transcript can show it later. + createPageFrameStore(database), ); /** diff --git a/server/src/work/culler.ts b/server/src/work/culler.ts new file mode 100644 index 00000000..4ceab968 --- /dev/null +++ b/server/src/work/culler.ts @@ -0,0 +1,229 @@ +/** + * Suspending computers nobody is using. + * + * A `Sandbox` has `shutdownTime`, an absolute expiry, which is not the question anybody is asking: + * "nobody has touched this for thirty minutes" is. So this asks that one, and it has to survive the + * replica that started it, which is why the work is claimed and leased out of PostgreSQL rather than + * held in a timer. + * + * NOTHING HERE TOUCHES A BROWSER. Deciding whether a computer is idle by dialling it would wake the + * computer, so every idle Bot anything asked about would come back up and the bill would never fall. + * That is the known, invisible way to lose scale-to-zero: everything works, nothing ever suspends. + * Idleness is read from the audit trail, which is a record of what a Bot did rather than a question + * put to the thing that did it. + */ +import { and, inArray, like, sql } from "drizzle-orm"; +import type { ComputerProvider } from "../computer/provider"; +import type { Database } from "../db/client"; +import { auditEvents } from "../db/schema"; +import { DEFAULT_MAX_ATTEMPTS, type WorkQueue } from "./queue"; + +export const CULL_KIND = "computer.suspend"; + +export type CullerOptions = { + database: Database; + queue: WorkQueue; + provider: ComputerProvider; + /** A computer untouched for this long is idle. */ + idleAfterMs: number; + /** Who this replica is, for the lease. */ + owner: string; + leaseMs?: number; + /** How many goes an item gets before it stops being offered. */ + maxAttempts?: number; + now?: () => Date; +}; + +export type CullReport = { + considered: number; + suspended: string[]; + skipped: { botId: string; reason: string }[]; +}; + +/** + * Which Bots have a computer running, and when each was last asked to do anything. + * + * The audit trail is the source: every acting call writes a row before the computer is touched, so + * "last used" is already recorded, server-side, and survives a restart. A computer that has never + * acted has no row and reads as idle since it started, which is the answer wanted. + */ +async function lastActedAt( + database: Database, + botIds: string[], +): Promise> { + if (botIds.length === 0) return new Map(); + /* + * The Bot is in the payload rather than in a column, so the grouping key is an expression. Written + * through the query builder rather than as one raw string, because a list parameter has to be + * bound as a list: handed to `= any($1)` as a JavaScript array it arrives as one opaque value and + * the query fails rather than matching nothing, which at least says so. + */ + const bot = sql`${auditEvents.payload}->>'bot'`; + const rows = await database + .select({ bot, last: sql`max(${auditEvents.createdAt})` }) + .from(auditEvents) + .where(and(like(auditEvents.eventType, "computer.%"), inArray(bot, botIds))) + .groupBy(bot); + + return new Map( + rows + .filter((row) => row.bot) + .map((row) => [row.bot, new Date(row.last)] as const), + ); +} + +/** + * Offer every idle computer for suspension. + * + * Offering rather than suspending: the decision and the act are separated so that whichever replica + * runs this does not have to be the one that carries it out, and so a suspension that fails halfway + * is retried by whoever picks the item up next rather than lost with the process that noticed. + */ +export async function offerIdleComputers( + options: CullerOptions, +): Promise<{ offered: string[] }> { + const now = options.now?.() ?? new Date(); + const computers = await options.provider.list(); + const running = computers.filter((computer) => computer.status === "running"); + const used = await lastActedAt( + options.database, + running.map((computer) => computer.botId), + ); + + const offered: string[] = []; + for (const computer of running) { + const since = + used.get(computer.botId) ?? + (computer.startedAt ? new Date(computer.startedAt) : undefined); + // No row and no start time means nothing is known about it, and suspending on no evidence is + // how somebody's session disappears mid-task. Left alone. + if (!since) continue; + if (now.getTime() - since.getTime() < options.idleAfterMs) continue; + await options.queue.offer({ + kind: CULL_KIND, + key: computer.botId, + payload: { botId: computer.botId, idleSince: since.toISOString() }, + }); + offered.push(computer.botId); + } + return { offered }; +} + +/** + * Carry out whatever suspensions this replica can claim. + * + * Re-checked at the moment of acting, because the decision was made by another replica at another + * time and a person may have started working in between. Suspending a computer somebody is using is + * worse than leaving an idle one running for another five minutes. + */ +export async function suspendClaimedComputers( + options: CullerOptions, +): Promise { + const leaseMs = options.leaseMs ?? 60_000; + const claimed = await options.queue.claim({ + kind: CULL_KIND, + owner: options.owner, + leaseMs, + limit: 20, + ...(options.maxAttempts === undefined + ? {} + : { maxAttempts: options.maxAttempts }), + }); + + const report: CullReport = { + considered: claimed.length, + suspended: [], + skipped: [], + }; + + for (const item of claimed) { + const botId = String(item.payload.botId ?? item.key); + /* + * Renewed before each one, because the batch is twenty and the lease is one. + * + * Twenty suspensions is twenty calls to an API server, and nothing renewed while they ran: on a + * slow cluster the lease expired part-way down the list and another replica claimed the tail + * this one was still working through. A lease nobody renews is a timer, and a timer is what this + * queue exists not to be. + * + * False means it is already somebody else's. Stopping is then the correct answer, not an error: + * the item is being handled, just not here. + */ + if ( + !(await options.queue.renew({ + kind: CULL_KIND, + key: item.key, + owner: options.owner, + leaseMs, + })) + ) { + report.skipped.push({ + botId, + reason: "the lease went to another replica", + }); + continue; + } + try { + const now = options.now?.() ?? new Date(); + const used = await lastActedAt(options.database, [botId]); + const since = used.get(botId); + if (since && now.getTime() - since.getTime() < options.idleAfterMs) { + // Somebody came back. Drop the item rather than releasing it, because the next sweep will + // offer it again if it goes quiet, and a released one would just be reclaimed and re-checked. + await options.queue.finish({ + kind: CULL_KIND, + key: item.key, + owner: options.owner, + }); + report.skipped.push({ + botId, + reason: "used again before it was suspended", + }); + continue; + } + + await options.provider.stop(botId); + await options.queue.finish({ + kind: CULL_KIND, + key: item.key, + owner: options.owner, + }); + report.suspended.push(botId); + } catch (error) { + /* + * Released rather than dropped, and pushed out rather than retried immediately: a cluster that + * refused this once will probably refuse it again in the next second, and a computer left + * running costs money rather than losing anything. + */ + const reason = + error instanceof Error ? error.message : "could not be suspended"; + await options.queue.release({ + kind: CULL_KIND, + key: item.key, + owner: options.owner, + delayMs: 5 * 60_000, + reason, + }); + /* + * Said out loud when it gives up, because otherwise it stops silently. + * + * At the cap the item is no longer claimed, so this loop simply never sees that Bot again and + * every sweep looks clean while one computer stays awake indefinitely. The row carries the + * count and the reason for anybody who queries it; this is for the person reading the logs. + */ + if (item.attempts >= (options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)) { + console.warn( + JSON.stringify({ + type: "computer-cull-gave-up", + botId, + attempts: item.attempts, + reason, + }), + ); + } + report.skipped.push({ botId, reason }); + } + } + + return report; +} diff --git a/server/src/work/queue.ts b/server/src/work/queue.ts new file mode 100644 index 00000000..ce30b41b --- /dev/null +++ b/server/src/work/queue.ts @@ -0,0 +1,286 @@ +/** + * Claiming durable work, so several replicas can share it without a coordinator. + * + * `select ... for update skip locked` inside a transaction: each replica takes rows nobody else + * holds and never waits behind another's. No leader election, no single point of failure, and a + * replica added is throughput added rather than contention added. + * + * A claim carries a lease. While the work runs its owner renews; one that stops being renewed is + * free again the moment anything looks, so recovery needs no process to notice a death, only the + * next claim to read the clock. + * + * WHOSE CLOCK. The database's, everywhere, and this is the load-bearing part. Leases used to be + * computed as `Date.now() + leaseMs` on the replica and compared against `now()` in Postgres, which + * is two clocks pretending to be one: a node ninety seconds behind wrote a sixty-second lease that + * Postgres considered expired on arrival, and the next replica to look took the item straight out + * from under the first. Both then ran it. Every time this file names a moment it names it in SQL. + */ +import { and, eq, gte, isNull, lt, or, sql } from "drizzle-orm"; +import type { Database } from "../db/client"; +import { workItems } from "../db/schema"; + +export type WorkItem = { + kind: string; + key: string; + payload: Record; + /** + * How many times this has been handed out, including now. + * + * ONE MEANS IT HAS CERTAINLY NOT RUN. More than one means a previous owner stopped renewing, and + * may already have called a tool or spent money before it did. A caller that cannot tell those + * apart cannot safely retry anything with an outside effect, so this is a number rather than a + * state folded into failure. + */ + attempts: number; +}; + +/** + * How many times one item may be handed out before it stops being offered. + * + * Without a cap a permanently failing item is retried until somebody notices, which on a queue with + * no dashboard is never. At the cap it stops being claimed and stays in the table with its count and + * its last error, which is a terminal state a person can query rather than a silence. + */ +export const DEFAULT_MAX_ATTEMPTS = 5; + +export type WorkQueue = { + /** Put work on the queue, or leave what is there. Idempotent on (kind, key). */ + offer: (item: { + kind: string; + key: string; + payload?: Record; + runAt?: Date; + }) => Promise; + /** Take up to `limit` due items, leased to `owner`. */ + claim: (input: { + kind: string; + owner: string; + leaseMs: number; + limit?: number; + maxAttempts?: number; + }) => Promise; + /** Keep a claim alive while the work runs. False means it was already taken away. */ + renew: (input: { + kind: string; + key: string; + owner: string; + leaseMs: number; + }) => Promise; + /** + * Done. False means the lease had already gone to somebody else, so this was not ours to finish. + */ + finish: (input: { + kind: string; + key: string; + owner: string; + }) => Promise; + /** Not done, and worth another go after `delayMs`. False means it was no longer ours. */ + release: (input: { + kind: string; + key: string; + owner: string; + delayMs: number; + reason?: string; + }) => Promise; + /** + * Drop what is done with, older than the retention window. Returns how many went. + * + * Both kinds of done: finished, and given up on. An item at its attempt cap is not finished and was + * reaped by nothing, so its key stayed occupied for ever and the work could never be offered again. + */ + purge: (input: { + kind: string; + olderThanMs: number; + maxAttempts?: number; + }) => Promise; +}; + +/** A moment `ms` from now, named in SQL so it is the database's clock and not the caller's. */ +function fromNow(ms: number) { + return sql`now() + make_interval(secs => ${ms / 1000})`; +} + +export function createWorkQueue(database: Database): WorkQueue { + /** + * Still ours, and still wanting doing. + * + * `finish` and `release` used to match on the key alone, so a replica whose lease had quietly + * expired could delete or reschedule an item another replica was in the middle of executing. Only + * `renew` got this right; all three ask the same question now. + */ + const ours = (kind: string, key: string, owner: string) => + and( + eq(workItems.kind, kind), + eq(workItems.key, key), + eq(workItems.claimedBy, owner), + isNull(workItems.finishedAt), + ); + + return { + async offer({ kind, key, payload = {}, runAt }) { + await database + .insert(workItems) + .values({ kind, key, payload, ...(runAt ? { runAt } : {}) }) + /* + * Nothing on conflict, deliberately. + * + * The key is the identity of the work, so a second offer of the same thing is the same + * thing, not a new one. For a routine the key carries the minute it was due, which is what + * makes "three replicas woke at 07:00" produce one run instead of three. A finished row + * still counts as a conflict, which is what makes that true after the run as well as during + * it. + */ + .onConflictDoNothing(); + }, + + async claim({ + kind, + owner, + leaseMs, + limit = 1, + maxAttempts = DEFAULT_MAX_ATTEMPTS, + }) { + return database.transaction(async (transaction) => { + /* + * `skip locked` is what makes this concurrent rather than merely correct. Without it a + * second replica blocks on the first replica's rows and the queue serialises; with it, it + * walks past them and takes the next free ones. + */ + const due = await transaction.execute(sql` + select "kind", "key" + from "work_items" + where "kind" = ${kind} + and "finished_at" is null + and "attempts" < ${maxAttempts} + and "run_at" <= now() + and ("lease_until" is null or "lease_until" <= now()) + order by "run_at" asc + limit ${limit} + for update skip locked + `); + + const rows = ( + Array.isArray(due) ? due : ((due as { rows?: unknown[] })?.rows ?? []) + ) as { + kind: string; + key: string; + }[]; + if (rows.length === 0) return []; + + const claimed: WorkItem[] = []; + for (const row of rows) { + const [updated] = await transaction + .update(workItems) + .set({ + claimedBy: owner, + leaseUntil: fromNow(leaseMs), + attempts: sql`${workItems.attempts} + 1`, + updatedAt: sql`now()`, + }) + .where( + and(eq(workItems.kind, row.kind), eq(workItems.key, row.key)), + ) + .returning({ + kind: workItems.kind, + key: workItems.key, + payload: workItems.payload, + attempts: workItems.attempts, + }); + if (updated) { + claimed.push({ + kind: updated.kind, + key: updated.key, + payload: (updated.payload ?? {}) as Record, + attempts: updated.attempts, + }); + } + } + return claimed; + }); + }, + + async renew({ kind, key, owner, leaseMs }) { + const [renewed] = await database + .update(workItems) + .set({ leaseUntil: fromNow(leaseMs), updatedAt: sql`now()` }) + /* + * Only while still ours. A lease that expired and was taken by somebody else must not be + * renewed back out from under them, which would put two replicas on one item believing they + * each held it. + */ + .where(ours(kind, key, owner)) + .returning({ key: workItems.key }); + return Boolean(renewed); + }, + + async finish({ kind, key, owner }) { + const [finished] = await database + .update(workItems) + /* + * Marked, not deleted. The row is what a later offer of the same key collides with, and + * deleting it handed that key back to anybody who re-offered it: the recovery path was also + * a duplicate-run path. Swept later by `purge`. + */ + .set({ + finishedAt: sql`now()`, + claimedBy: null, + leaseUntil: null, + updatedAt: sql`now()`, + }) + .where(ours(kind, key, owner)) + .returning({ key: workItems.key }); + return Boolean(finished); + }, + + async release({ kind, key, owner, delayMs, reason }) { + // Freed and pushed out, rather than finished: the work still wants doing, just not immediately + // and not by whoever just gave up on it. The reason stays on the row so an item that runs out + // of attempts says why rather than simply stopping. + const [released] = await database + .update(workItems) + .set({ + claimedBy: null, + leaseUntil: null, + runAt: fromNow(delayMs), + updatedAt: sql`now()`, + ...(reason === undefined ? {} : { lastError: reason }), + }) + .where(ours(kind, key, owner)) + .returning({ key: workItems.key }); + return Boolean(released); + }, + + async purge({ kind, olderThanMs, maxAttempts = DEFAULT_MAX_ATTEMPTS }) { + const cutoff = fromNow(-olderThanMs); + const gone = await database + .delete(workItems) + .where( + and( + eq(workItems.kind, kind), + or( + lt(workItems.finishedAt, cutoff), + /* + * AND THE ONES THAT GAVE UP, which is the half this forgot. + * + * An item at its attempt cap is not finished, so it was reaped by nothing: `claim` + * skipped it, `purge` did not match it, and `offer` cannot replace a row that is still + * there. Its key was wedged for good. The culler keys on the Bot id, so five failed + * suspends meant that Bot never scaled to zero again, silently and for ever. + * + * Reaped on the same window rather than kept, because the window is also how long it + * waits before anything tries again: whatever was broken has had a day to be fixed, + * and the next sweep offers the work afresh. The audit trail is where "this failed" + * lives; this table is what still wants doing. + */ + and( + gte(workItems.attempts, maxAttempts), + lt(workItems.updatedAt, cutoff), + ), + ), + ), + ) + .returning({ key: workItems.key }); + return gone.length; + }, + }; +} diff --git a/server/tests/computer-culler.integration.test.ts b/server/tests/computer-culler.integration.test.ts new file mode 100644 index 00000000..6f8555e0 --- /dev/null +++ b/server/tests/computer-culler.integration.test.ts @@ -0,0 +1,222 @@ +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq, like } from "drizzle-orm"; +import type { + ComputerLocation, + ComputerProvider, +} from "../src/computer/provider"; +import { createDatabase } from "../src/db/client"; +import { auditEvents, workItems } from "../src/db/schema"; +import { + CULL_KIND, + offerIdleComputers, + suspendClaimedComputers, +} from "../src/work/culler"; +import { createWorkQueue } from "../src/work/queue"; +import { TEST_POOL } from "./support/database"; + +/** + * Spinning a computer down when nobody is using it, which is the whole reason the fleet does not + * cost a hundred idle browsers. + * + * The interesting cases are the ones where it must NOT act: a computer somebody just used, and a + * computer nothing is known about. Suspending either takes a person's session away mid-task, and + * both are easy to get wrong in a way no error ever reports. + */ +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); +const queue = createWorkQueue(database); +const suite = randomUUID().slice(0, 8); +const botOf = (name: string) => `cull-${suite}-${name}`; + +function providerWith(computers: ComputerLocation[]) { + const stopped: string[] = []; + const provider: ComputerProvider = { + name: "fake", + isolation: "per-bot", + locate: async () => "http://unused", + status: async (botId) => ({ botId, state: "ready" }), + stop: async (botId) => { + stopped.push(botId); + return { wasRunning: true }; + }, + reset: async () => ({ cleared: false }), + list: async () => computers, + }; + return { provider, stopped }; +} + +const ran = (botId: string, when: Date) => ({ + eventType: "computer.action_allowed", + targetType: "computer", + targetId: botId, + payload: { bot: botId, action: "computer_navigate" }, + createdAt: when, +}); + +/* + * This suite's own rows and nobody else's. + * + * The kind is fixed by the culler, so scoping on it alone deleted every queued suspension in the + * database. Against a shared development database that is somebody's real work, thrown away by a + * test run. The keys are this suite's Bot ids, so that is what the sweep matches. + */ +const mine = () => + and(eq(workItems.kind, CULL_KIND), like(workItems.key, `cull-${suite}-%`)); + +afterAll(async () => { + await database.delete(workItems).where(mine()); + await database.$client.end({ timeout: 5 }); +}); + +beforeEach(async () => { + await database.delete(workItems).where(mine()); +}); + +const idleAfterMs = 30 * 60_000; +const now = () => new Date("2026-08-24T12:00:00Z"); +const minutesAgo = (minutes: number) => + new Date(now().getTime() - minutes * 60_000); + +describe("suspending computers nobody is using", () => { + test("a computer idle longer than the threshold is offered, and then suspended", async () => { + const botId = botOf("idle"); + await database.insert(auditEvents).values(ran(botId, minutesAgo(45))); + const { provider, stopped } = providerWith([ + { botId, status: "running", url: "http://c" }, + ]); + const options = { + database, + queue, + provider, + idleAfterMs, + owner: "replica-1", + now, + }; + + expect((await offerIdleComputers(options)).offered).toEqual([botId]); + const report = await suspendClaimedComputers(options); + + expect(report.suspended).toEqual([botId]); + expect(stopped).toEqual([botId]); + }); + + test("a computer used a minute ago is left alone", async () => { + const botId = botOf("busy"); + await database.insert(auditEvents).values(ran(botId, minutesAgo(1))); + const { provider, stopped } = providerWith([ + { botId, status: "running", url: "http://c" }, + ]); + + const offered = await offerIdleComputers({ + database, + queue, + provider, + idleAfterMs, + owner: "replica-1", + now, + }); + + expect(offered.offered).toEqual([]); + expect(stopped).toEqual([]); + }); + + /* + * The race the lease exists for. One replica decides a computer is idle; before anything acts on + * that, the person comes back. Suspending them mid-task is worse than paying for another five + * minutes of an idle browser, so the decision is re-checked at the moment of acting. + */ + test("a computer used after being offered is not suspended", async () => { + const botId = botOf("returned"); + await database.insert(auditEvents).values(ran(botId, minutesAgo(45))); + const { provider, stopped } = providerWith([ + { botId, status: "running", url: "http://c" }, + ]); + const options = { + database, + queue, + provider, + idleAfterMs, + owner: "replica-1", + now, + }; + + await offerIdleComputers(options); + // They came back while the item sat on the queue. + await database.insert(auditEvents).values(ran(botId, minutesAgo(2))); + const report = await suspendClaimedComputers(options); + + expect(stopped).toEqual([]); + expect(report.skipped[0]?.reason).toContain("used again"); + }); + + test("a computer nothing is known about is left alone", async () => { + // No audit row and no start time. Suspending on no evidence is how a session disappears. + const botId = botOf("unknown"); + const { provider, stopped } = providerWith([ + { botId, status: "running", url: "http://c" }, + ]); + + const offered = await offerIdleComputers({ + database, + queue, + provider, + idleAfterMs, + owner: "replica-1", + now, + }); + + expect(offered.offered).toEqual([]); + expect(stopped).toEqual([]); + }); + + test("a computer already stopped is not offered again", async () => { + const botId = botOf("stopped"); + await database.insert(auditEvents).values(ran(botId, minutesAgo(90))); + const { provider } = providerWith([ + { botId, status: "stopped", url: "http://c" }, + ]); + + expect( + ( + await offerIdleComputers({ + database, + queue, + provider, + idleAfterMs, + owner: "replica-1", + now, + }) + ).offered, + ).toEqual([]); + }); + + test("two replicas culling together suspend each computer once", async () => { + const ids = ["a", "b", "c", "d"].map(botOf); + for (const botId of ids) { + await database.insert(auditEvents).values(ran(botId, minutesAgo(60))); + } + const { provider, stopped } = providerWith( + ids.map((botId) => ({ + botId, + status: "running" as const, + url: "http://c", + })), + ); + const base = { database, queue, provider, idleAfterMs, now }; + + await offerIdleComputers({ ...base, owner: "replica-1" }); + const [first, second] = await Promise.all([ + suspendClaimedComputers({ ...base, owner: "replica-1" }), + suspendClaimedComputers({ ...base, owner: "replica-2" }), + ]); + + const all = [...first.suspended, ...second.suspended]; + expect(all.sort()).toEqual([...ids].sort()); + // Each exactly once, which is the point of claiming rather than sweeping. + expect(new Set(stopped).size).toBe(stopped.length); + }); +}); diff --git a/server/tests/computer-gateway.test.ts b/server/tests/computer-gateway.test.ts index bdbfd7b4..e2b4c255 100644 --- a/server/tests/computer-gateway.test.ts +++ b/server/tests/computer-gateway.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { AuditEventInput, AuditStore } from "../src/audit"; +import { StaleSnapshotError } from "../src/computer/client"; import { ActionRefusedError, createComputerGateway, @@ -10,7 +11,6 @@ import type { ComputerLocation, ComputerProvider, } from "../src/computer/provider"; -import { StaleSnapshotError } from "../src/computer/client"; import type { SnapshotResult } from "../src/computer/schema"; import { createInMemorySnapshotStore, @@ -600,6 +600,20 @@ describe("the computer gateway", () => { expect(calls).toEqual(["navigate"]); }); + /* + * "not in the current snapshot" is a statement about a ref that could not be resolved, and it used + * to be written on every action that never named an element at all. A reader looking at a + * navigation row went hunting for a snapshot that was never taken. + */ + test("an action that never named an element records no element", async () => { + const { gateway, rows } = await gatewayWith(PERMISSIVE); + + await gateway.navigate("bot-1", ACTOR, "https://example.com/"); + + expect(rows.at(-1)?.payload.action).toBe("computer_navigate"); + expect(rows.at(-1)?.payload.element).toBeUndefined(); + }); + test("an action on an unresolvable ref is still decided and still recorded", async () => { const { gateway, rows } = await gatewayWith(PERMISSIVE); // The assertion this test was written for is unchanged: the decision is taken and the row says diff --git a/server/tests/computer-page-frame-route.test.ts b/server/tests/computer-page-frame-route.test.ts new file mode 100644 index 00000000..130c59c6 --- /dev/null +++ b/server/tests/computer-page-frame-route.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, test } from "bun:test"; +import type { MiddlewareHandler } from "hono"; +import type { AppVariables, AuthenticatedActor } from "../src/auth/guards"; +import type { ComputerGateway } from "../src/computer/gateway"; +import type { PageFrameStore } from "../src/computer/page-frames"; +import type { PolicyStore } from "../src/computer/policy-store"; +import { createComputerRoutes } from "../src/computer/routes"; + +/** + * The frame is taken where the navigation happens. + * + * The surface used to capture it after the turn and file it under the tool call, which lost a race it + * could not win: the same computer is driven by other conversations between the turn ending and the + * tile asking, and a resumed computer starts blank. So the transcript showed the wrong page, or none. + * + * What these cover is the seam that replaced it: navigating photographs the page it just opened, and + * a screenshot that cannot be taken never fails the navigation the Bot was actually asked to do. + */ + +const actor: AuthenticatedActor = { + id: "user-1", + email: "member@openbot.test", + role: "user", +}; + +const asActor: MiddlewareHandler<{ Variables: AppVariables }> = async ( + context, + next, +) => { + context.set("actor", actor); + await next(); +}; + +function harness(options?: { + screenshot?: () => Promise<{ base64: string; url?: string }>; + navigate?: () => Promise<{ url: string; title: string }>; + status?: (botId: string) => Promise<{ botId: string; state: string }>; + isolation?: "per-bot" | "shared"; +}) { + const saved: Array<{ + computerId: string; + toolCallId: string; + url: string; + frame: string; + }> = []; + const gateway = { + provider: { isolation: options?.isolation ?? "per-bot" }, + navigate: + options?.navigate ?? + (async () => ({ + url: "https://example.com/story", + title: "A story", + text: "", + truncated: false, + elapsedMs: 1, + })), + status: + options?.status ?? + (async (botId: string) => ({ botId, state: "ready" as const })), + screenshot: + options?.screenshot ?? + (async () => ({ base64: "PNGBYTES", url: "https://example.com/story" })), + } as unknown as ComputerGateway; + + const pageFrames: PageFrameStore = { + async save(frame) { + saved.push({ + computerId: frame.computerId, + toolCallId: frame.toolCallId, + url: frame.url, + frame: frame.frame, + }); + }, + async load(computerId, toolCallId) { + const found = saved.find( + (row) => row.computerId === computerId && row.toolCallId === toolCallId, + ); + return found ? { url: found.url, title: null, frame: found.frame } : null; + }, + async clear() { + return saved.splice(0).length; + }, + async purge() { + return 0; + }, + }; + + const routes = createComputerRoutes( + gateway, + {} as PolicyStore, + asActor, + async () => true, + pageFrames, + ); + return { routes, saved }; +} + +function navigate( + routes: ReturnType["routes"], + url: string, + // `null` means "send no turn at all". `undefined` would take the default, which is the opposite. + toolCallId: string | null = "call-1", +) { + return routes.request("http://openbot.test/bot-9/navigate", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ url, ...(toolCallId ? { toolCallId } : {}) }), + }); +} + +describe("the frame a page was opened on", () => { + test("navigating keeps a frame of the page it landed on", async () => { + const { routes, saved } = harness(); + + expect((await navigate(routes, "https://example.com/story")).status).toBe( + 200, + ); + + expect(saved).toEqual([ + { + computerId: "bot-9", + toolCallId: "call-1", + url: "https://example.com/story", + frame: "PNGBYTES", + }, + ]); + }); + + /* + * The address the browser ended on, not the one that was asked for. A redirect, a canonical host or + * an added trailing slash all mean the two differ, and the transcript asks by the page the tool + * reported, so filing under the request would file under a key nothing ever looks up. + */ + test("the frame is filed under the page the browser reached", async () => { + const { routes, saved } = harness({ + navigate: async () => ({ + url: "https://www.example.com/story/", + title: "A story", + }), + screenshot: async () => ({ + base64: "PNGBYTES", + url: "https://www.example.com/story/", + }), + }); + + await navigate(routes, "https://example.com/story"); + + expect(saved[0]?.url).toBe("https://www.example.com/story/"); + }); + + test("the kept frame is read back by the page it was taken of", async () => { + const { routes } = harness(); + await navigate(routes, "https://example.com/story"); + + const response = await routes.request( + "http://openbot.test/bot-9/page-frame/call-1", + ); + + expect(await response.json()).toEqual({ + frame: { + url: "https://example.com/story", + title: null, + frame: "PNGBYTES", + }, + }); + }); + + test("a turn nobody photographed reads back as no frame", async () => { + const { routes } = harness(); + + const response = await routes.request( + "http://openbot.test/bot-9/page-frame/call-never", + ); + + expect(await response.json()).toEqual({ frame: null }); + }); + + /* + * The picture is a convenience for reading the conversation back. Failing the navigation the Bot + * was asked to do because the convenience failed would be the wrong trade every time. + */ + test("a screenshot that cannot be taken does not fail the navigation", async () => { + const { routes, saved } = harness({ + screenshot: async () => { + throw new Error("computer is suspended"); + }, + }); + + const response = await navigate(routes, "https://example.com/story"); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + url: "https://example.com/story", + }); + expect(saved).toEqual([]); + }); + + /* + * The screenshot is a second round trip and nothing holds the browser still between them. With one + * computer shared by every Bot, another Bot's navigation lands in that gap, and this used to file + * its page under this turn. + */ + test("a frame of a different page than the one opened is refused", async () => { + const { routes, saved } = harness({ + screenshot: async () => ({ + base64: "SOMEBODY-ELSES-PAGE", + url: "https://payroll.example/salaries", + }), + }); + + expect((await navigate(routes, "https://example.com/story")).status).toBe( + 200, + ); + + expect(saved).toEqual([]); + }); + + /* + * A convenience picture must not wake a machine the culler has just put to sleep, nor hold a + * navigation open for the length of a pod schedule while it does. + */ + test("a suspended computer is not resumed to photograph it", async () => { + let asked = false; + const { routes, saved } = harness({ + status: async (botId: string) => ({ botId, state: "suspended" }), + screenshot: async () => { + asked = true; + return { base64: "PNGBYTES", url: "https://example.com/story" }; + }, + }); + + expect((await navigate(routes, "https://example.com/story")).status).toBe( + 200, + ); + + expect(asked).toBe(false); + expect(saved).toEqual([]); + }); + + /* + * A computer built before screenshots said which page they were of. + * + * Refusing on a missing url did not fail safe, it failed silently and completely: a fleet part-way + * through a rollout kept no frames at all. With a computer each there is nobody to race with, so + * the picture can only be this turn's. + */ + test("an old computer's unlabelled frame is kept when the Bot has its own", async () => { + const { routes, saved } = harness({ + isolation: "per-bot", + screenshot: async () => ({ base64: "PNGBYTES" }), + }); + + await navigate(routes, "https://example.com/story"); + + expect(saved).toHaveLength(1); + }); + + /* And on one shared browser it cannot be told apart from another Bot's, so it is refused. */ + test("an old computer's unlabelled frame is refused when the computer is shared", async () => { + const { routes, saved } = harness({ + isolation: "shared", + screenshot: async () => ({ base64: "PNGBYTES" }), + }); + + await navigate(routes, "https://example.com/story"); + + expect(saved).toEqual([]); + }); + + /* A caller that does not know which turn it is still navigates, and simply keeps no frame. */ + test("a navigation with no turn named keeps no frame", async () => { + const { routes, saved } = harness(); + + expect( + (await navigate(routes, "https://example.com/story", null)).status, + ).toBe(200); + + expect(saved).toEqual([]); + }); + + /* + * The store is optional so a deployment can be wired without one. Navigation must not notice. + */ + test("a deployment keeping no frames still navigates", async () => { + const gateway = { + navigate: async () => ({ url: "https://example.com/story", title: "T" }), + status: async (botId: string) => ({ botId, state: "ready" as const }), + screenshot: async () => { + throw new Error("should not be asked"); + }, + } as unknown as ComputerGateway; + const routes = createComputerRoutes( + gateway, + {} as PolicyStore, + asActor, + async () => true, + ); + + expect((await navigate(routes, "https://example.com/story")).status).toBe( + 200, + ); + expect( + await ( + await routes.request("http://openbot.test/bot-9/page-frame/call-1") + ).json(), + ).toEqual({ frame: null }); + }); +}); diff --git a/server/tests/computer-page-frame-store.integration.test.ts b/server/tests/computer-page-frame-store.integration.test.ts new file mode 100644 index 00000000..55fe0952 --- /dev/null +++ b/server/tests/computer-page-frame-store.integration.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { eq } from "drizzle-orm"; +import { createPageFrameStore } from "../src/computer/page-frames"; +import { createDatabase } from "../src/db/client"; +import { computerPageFrame } from "../src/db/schema"; +import { TEST_POOL } from "./support/database"; + +/** + * A conversation is a record, and a record must not change its mind. + * + * The transcript used to fetch the live screen for every past turn, so an answer about one page sat + * under a picture of whichever page the Bot had open by the time somebody read it back. The frame is + * kept per computer and TURN, which is the identity of the thing being remembered; keying it on the + * page instead let a second visit to one address rewrite an earlier turn's picture, which is the + * same mutability wearing a different hat. + * + * Driven against the database rather than a fake, because the properties under test are the + * database's: which rows collide, and what happens when they do. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const store = createPageFrameStore(database); +const computers: string[] = []; + +function computerId(): string { + const id = `frame-probe-${crypto.randomUUID().slice(0, 8)}`; + computers.push(id); + return id; +} + +afterEach(async () => { + for (const id of computers.splice(0)) { + await database + .delete(computerPageFrame) + .where(eq(computerPageFrame.computerId, id)); + } +}); + +describe("kept page frames", () => { + test("a turn is read back with the frame it opened its page on", async () => { + const id = computerId(); + await store.save({ + computerId: id, + toolCallId: "call-1", + url: "https://example.com/one", + title: "One", + frame: "AAAA", + }); + + const stored = await store.load(id, "call-1"); + expect(stored?.frame).toBe("AAAA"); + expect(stored?.title).toBe("One"); + expect(stored?.url).toBe("https://example.com/one"); + }); + + test("a turn nobody photographed has no frame", async () => { + expect(await store.load(computerId(), "call-missing")).toBe(null); + }); + + test("two turns on one computer keep their own frames", async () => { + const id = computerId(); + await store.save({ + computerId: id, + toolCallId: "call-a", + url: "https://a.example", + frame: "A", + }); + await store.save({ + computerId: id, + toolCallId: "call-b", + url: "https://b.example", + frame: "B", + }); + + expect((await store.load(id, "call-a"))?.frame).toBe("A"); + expect((await store.load(id, "call-b"))?.frame).toBe("B"); + }); + + /* + * The whole reason the key is the turn. Two visits to one address are two turns and each keeps its + * own picture; keyed on the page, the second rewrote the first and a past turn changed under the + * person reading it. + */ + test("visiting the same page again leaves the earlier turn alone", async () => { + const id = computerId(); + await store.save({ + computerId: id, + toolCallId: "call-first", + url: "https://news.example", + title: "Before", + frame: "OLD", + }); + await store.save({ + computerId: id, + toolCallId: "call-second", + url: "https://news.example", + title: "After", + frame: "NEW", + }); + + expect((await store.load(id, "call-first"))?.frame).toBe("OLD"); + expect((await store.load(id, "call-second"))?.frame).toBe("NEW"); + }); + + /* A turn happens once. A second write for it is a retry or a mistake, and neither may change it. */ + test("a turn's frame is written once and never rewritten", async () => { + const id = computerId(); + await store.save({ + computerId: id, + toolCallId: "call-1", + url: "https://example.com", + frame: "FIRST", + }); + await store.save({ + computerId: id, + toolCallId: "call-1", + url: "https://example.com", + frame: "SECOND", + }); + + expect((await store.load(id, "call-1"))?.frame).toBe("FIRST"); + }); + + /* + * The computer is part of the key, not decoration. A caller who may reach one Bot must not be able + * to read another Bot's screen by naming a turn. + */ + test("one computer cannot read another computer's frame", async () => { + const mine = computerId(); + const theirs = computerId(); + await store.save({ + computerId: theirs, + toolCallId: "call-1", + url: "https://payroll.example", + frame: "SECRET", + }); + + expect(await store.load(mine, "call-1")).toBe(null); + }); + + /* + * "Every login the Bot had is gone" is not true while pictures of the signed-in pages are still + * readable from the transcript. + */ + test("wiping a computer takes its pictures with it", async () => { + const mine = computerId(); + const theirs = computerId(); + await store.save({ + computerId: mine, + toolCallId: "call-1", + url: "https://inbox.example", + frame: "MINE", + }); + await store.save({ + computerId: theirs, + toolCallId: "call-1", + url: "https://inbox.example", + frame: "THEIRS", + }); + + expect(await store.clear(mine)).toBe(1); + expect(await store.load(mine, "call-1")).toBe(null); + // And nobody else's. + expect((await store.load(theirs, "call-1"))?.frame).toBe("THEIRS"); + }); + + /* A page is a row, so a Bot that browses grows this table for as long as it runs. */ + test("frames past the retention window are swept", async () => { + const id = computerId(); + await store.save({ + computerId: id, + toolCallId: "call-1", + url: "https://example.com", + frame: "AAAA", + }); + + expect(await store.purge(60_000)).toBe(0); + expect(await store.purge(0)).toBeGreaterThanOrEqual(1); + expect(await store.load(id, "call-1")).toBe(null); + }); + + /* + * Refused rather than truncated. Half a PNG is not a smaller picture, it is a broken one, and the + * turn falls back to naming the page it opened. + */ + test("a frame too large to be a screenshot is not kept", async () => { + const id = computerId(); + await store.save({ + computerId: id, + toolCallId: "call-1", + url: "https://huge.example", + frame: "x".repeat(4 * 1024 * 1024 + 1), + }); + + expect(await store.load(id, "call-1")).toBe(null); + }); + + test("a frame with no turn to file it under is not kept", async () => { + const id = computerId(); + await store.save({ + computerId: id, + toolCallId: "", + url: "https://example.com", + frame: "AAAA", + }); + + expect(await store.load(id, "")).toBe(null); + }); +}); diff --git a/server/tests/computer-provider.test.ts b/server/tests/computer-provider.test.ts index 3285c844..be508c90 100644 --- a/server/tests/computer-provider.test.ts +++ b/server/tests/computer-provider.test.ts @@ -1,11 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import type { ComputerConfig } from "../src/config"; import { createComputerProvider, createSharedComputerProvider, describeComputerIsolation, ProviderError, } from "../src/computer/provider"; +import type { ComputerConfig } from "../src/config"; const servers: { stop(closeActiveConnections?: boolean): void }[] = []; @@ -302,3 +302,45 @@ describe("computer provider factory", () => { expect(createComputerProvider(config).name).toBe("shared"); }); }); + +/** + * A transient failure must not become a permanent one. + * + * The provider is built once behind a promise, and `??=` remembers whatever that first call + * produced. A rejected promise is something: one unreadable token file at the wrong moment and every + * computer request for the rest of the pod's life failed with the same stale error, while the pod + * served happily and no probe noticed. + */ +describe("building the sandbox provider", () => { + test("a failed first attempt is not remembered", async () => { + const original = process.env.KUBERNETES_SERVICE_HOST; + delete process.env.KUBERNETES_SERVICE_HOST; + + try { + const provider = createComputerProvider({ + provider: "sandbox", + namespace: "openbot", + idleAfterMs: 60_000, + templateFile: "/nowhere/sandbox-template.json", + }); + + const first = await provider.status("bot-1").catch((e: unknown) => e); + const second = await provider.status("bot-1").catch((e: unknown) => e); + + expect(first).toBeInstanceOf(Error); + expect(second).toBeInstanceOf(Error); + /* + * DIFFERENT OBJECTS, which is the whole assertion. + * + * A memo holding the rejected promise hands back the identical Error every time, because + * nothing runs again. Two distinct instances mean the second call re-entered the build, so a + * deployment whose token file was briefly unreadable recovers on the next request instead of + * needing a restart. + */ + expect(second).not.toBe(first); + } finally { + if (original === undefined) delete process.env.KUBERNETES_SERVICE_HOST; + else process.env.KUBERNETES_SERVICE_HOST = original; + } + }); +}); diff --git a/server/tests/computer-sandbox.test.ts b/server/tests/computer-sandbox.test.ts new file mode 100644 index 00000000..11950f52 --- /dev/null +++ b/server/tests/computer-sandbox.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test"; +import { + createSandboxComputerProvider, + sandboxNameFor, +} from "../src/computer/sandbox"; + +/** + * A computer each, as a Sandbox, and the two questions that decide whether it is safe. + * + * Which run of a computer this is, because a resumed browser counts generations from one again and a + * ref from before the suspend must not resolve against the page after it. And whether asking a + * question can wake a computer, because one that wakes on being asked about never suspends and the + * bill never falls. + */ +function providerWith(sandbox: unknown, seen: string[] = []) { + return createSandboxComputerProvider({ + namespace: "openbot", + template: { podTemplate: { spec: { containers: [] } } }, + idleAfterMs: 60_000, + apiServer: "https://kubernetes.default", + token: "t", + fetchImpl: (async (url: string | URL | Request, init?: RequestInit) => { + seen.push(`${init?.method ?? "GET"} ${new URL(String(url)).pathname}`); + return Response.json(sandbox); + }) as unknown as typeof fetch, + }); +} + +const running = (readyAt: string, node: string, ip: string) => ({ + metadata: { name: "bot-knowledge-abc" }, + spec: { operatingMode: "Running" }, + status: { + serviceFQDN: "bot-knowledge-abc.openbot.svc.cluster.local", + nodeName: node, + podIPs: [ip], + conditions: [ + { type: "Suspended", status: "False" }, + { type: "Ready", status: "True", lastTransitionTime: readyAt }, + ], + }, +}); + +describe("telling one run of a Bot's computer from the next", () => { + /* + * THE CASE A REAL RESUME DISPROVED THE OLD ANSWER WITH. + * + * A suspended sandbox is very often rescheduled onto the same node and handed the same address + * back, because nothing else has taken it. Measured on EKS: both identical across a suspend and + * resume. Anything built from them says "same run" for the exact case the check exists to catch. + */ + test("changes across a resume even when the node and address do not", async () => { + const before = await providerWith( + running("2026-08-24T23:14:04Z", "node-a", "192.168.49.27"), + ).sessionOf?.("knowledge"); + const after = await providerWith( + running("2026-08-25T00:40:04Z", "node-a", "192.168.49.27"), + ).sessionOf?.("knowledge"); + + expect(before).toBeDefined(); + expect(after).toBeDefined(); + expect(after).not.toBe(before); + }); + + test("is the same while one run keeps serving", async () => { + const sandbox = running("2026-08-25T00:40:04Z", "node-a", "192.168.49.27"); + expect(await providerWith(sandbox).sessionOf?.("knowledge")).toBe( + await providerWith(sandbox).sessionOf?.("knowledge"), + ); + }); + + test("a suspended computer has no run to name", async () => { + // Unknown rather than mismatched: there is no page behind a suspended computer to resolve against. + const suspended = { + metadata: { name: "bot-knowledge-abc" }, + spec: { operatingMode: "Suspended" }, + status: { conditions: [{ type: "Suspended", status: "True" }] }, + }; + expect( + await providerWith(suspended).sessionOf?.("knowledge"), + ).toBeUndefined(); + }); + + test("asking which run it is never starts a computer", async () => { + /* + * The invisible way to lose scale-to-zero: everything works, nothing ever suspends, and only the + * bill says otherwise. Reading is a GET; anything that creates or patches would wake it. + */ + const seen: string[] = []; + await providerWith( + running("2026-08-25T00:40:04Z", "n", "1.2.3.4"), + seen, + ).sessionOf?.("knowledge"); + expect(seen.every((call) => call.startsWith("GET "))).toBe(true); + }); + + test("status reads a suspended computer as down and fine, without touching it", async () => { + const seen: string[] = []; + const suspended = { + metadata: { name: "bot-knowledge-abc" }, + spec: { operatingMode: "Suspended" }, + status: { conditions: [{ type: "Suspended", status: "True" }] }, + }; + const status = await providerWith(suspended, seen).status("knowledge"); + + expect(status.state).toBe("absent"); + expect(seen.every((call) => call.startsWith("GET "))).toBe(true); + }); +}); + +describe("naming a Bot's computer in a cluster", () => { + test("a bot id that is not a legal name still gets one, and a unique one", () => { + // Bot ids are ours and hold anything a person typed; a resource name may not. Two ids that differ + // only in punctuation must not land on one computer, which would be one Bot reading another's + // logins. + const a = sandboxNameFor("Sales Bot"); + const b = sandboxNameFor("sales-bot"); + expect(a).toMatch(/^[a-z0-9-]+$/); + expect(b).toMatch(/^[a-z0-9-]+$/); + expect(a).not.toBe(b); + }); + + test("the same id always names the same computer", () => { + expect(sandboxNameFor("knowledge")).toBe(sandboxNameFor("knowledge")); + }); +}); + +/** + * A projected service account token is not a constant. + * + * The kubelet rewrites the file well before the token expires, and how long that is belongs to the + * cluster: an hour where somebody hardened it, a day by default. Read once and held for the life of + * the process, sandbox calls work right up to the first rotation and then every one returns 401, + * which reads like the cluster broke rather than like a credential going stale. + */ +describe("the credential a sandbox call carries", () => { + test("is asked for again rather than captured once", async () => { + const sent: string[] = []; + let current = "first"; + const provider = createSandboxComputerProvider({ + namespace: "openbot", + idleAfterMs: 60_000, + template: { podTemplate: {} }, + apiServer: "https://cluster.test", + token: async () => current, + fetchImpl: (async (_url: string, init: RequestInit) => { + sent.push( + String((init.headers as Record).authorization), + ); + return new Response("null", { status: 404 }); + }) as unknown as typeof fetch, + }); + + await provider.status("bot-1"); + current = "rotated"; + await provider.status("bot-1"); + + expect(sent).toEqual(["Bearer first", "Bearer rotated"]); + }); +}); diff --git a/server/tests/computer-supervisor.test.ts b/server/tests/computer-supervisor.test.ts index 919d4cc3..ddc38ced 100644 --- a/server/tests/computer-supervisor.test.ts +++ b/server/tests/computer-supervisor.test.ts @@ -236,3 +236,79 @@ describe("Docker supervisor provider", () => { expect(await provider.reset("bot")).toEqual({ cleared: false }); }); }); + +/** + * Which run of a computer this is, asked by a replica that did not start it. + * + * `sessionOf` is what stops a ref from a dead container resolving against a live one: a replaced + * computer counts generations from one again, so the generation alone cannot tell them apart. It + * answers from what the last `/ensure` reported, which is free and correct while one process does + * both halves of the work. + * + * On more than one replica it is neither. The replica that took the snapshot is very often not the + * replica handling the click, and the second one has never called `/ensure` for that Bot, so it has + * nothing to answer with. `resolve` treats an unknown session as "no opinion" and skips the check by + * design, which is right for a provider that cannot tell and wrong here: the check is simply absent, + * silently, on exactly the deployment shape it was written for. + */ +describe("telling one run of a computer from the next, across replicas", () => { + /* + * A bot id of its own per test, because the map this reads is module scope. + * + * Two providers in one process are not two replicas: they share it. A test that located the + * computer under one name and then asked under the same name would pass whatever the code did, + * which is the shape of a test that proves nothing. + */ + const startedAt = "2026-08-24T09:00:00.000Z"; + + function replica(botId: string, seen: string[] = []) { + const running = [ + { + botId, + container: `openbot-computer-${botId}`, + status: "running", + url: `http://openbot-computer-${botId}:4100`, + startedAt, + }, + ]; + return createDockerSupervisorProvider({ + baseUrl: "http://supervisor:4300", + token: "t", + fetchImpl: (async (url: string | URL | Request) => { + const path = new URL(String(url)).pathname; + seen.push(path); + if (path.endsWith("/ensure")) return Response.json(running[0]); + return Response.json({ computers: running }); + }) as unknown as typeof fetch, + }); + } + + test("a replica that located the computer knows the run", async () => { + const client = replica("located"); + await client.locate("located"); + expect(await client.sessionOf?.("located")).toBe(startedAt); + }); + + test("a replica that never located it still knows the run", async () => { + /* + * The regression. This replica is serving the click; another one took the snapshot. Without an + * answer here the generation check is skipped and a ref from a computer that has since been + * replaced resolves against the new one, which is the case the check exists for. + */ + const client = replica("never-located"); + expect(await client.sessionOf?.("never-located")).toBe(startedAt); + }); + + test("asking does not start a computer that is not running", async () => { + /* + * The other half, and the easier one to get wrong. `/ensure` starts a computer; answering this + * question with it would mean every idle Bot is woken by being asked about, which is how a + * deployment ends up never suspending anything and never noticing, because everything works and + * only the bill says otherwise. + */ + const seen: string[] = []; + const client = replica("asked-about", seen); + await client.sessionOf?.("asked-about"); + expect(seen.some((path) => path.endsWith("/ensure"))).toBe(false); + }); +}); diff --git a/server/tests/work-queue.integration.test.ts b/server/tests/work-queue.integration.test.ts new file mode 100644 index 00000000..a7397e6a --- /dev/null +++ b/server/tests/work-queue.integration.test.ts @@ -0,0 +1,326 @@ +import { afterAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { createDatabase } from "../src/db/client"; +import { workItems } from "../src/db/schema"; +import { createWorkQueue } from "../src/work/queue"; +import { TEST_POOL } from "./support/database"; + +/** + * The one mechanism suspending idle computers, running routines and handing work between Bots all + * need, driven against a real PostgreSQL rather than a fake. + * + * A fake cannot answer the only question worth asking here. `for update skip locked` is a promise + * the database makes about two transactions racing, and a stub that returns rows in order would pass + * every test below while the real thing handed one item to two replicas. + */ +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); +const queue = createWorkQueue(database); +const kind = `test.${randomUUID().slice(0, 8)}`; + +afterAll(async () => { + await database.delete(workItems).where(eq(workItems.kind, kind)); + await database.$client.end({ timeout: 5 }); +}); + +beforeEach(async () => { + await database.delete(workItems).where(eq(workItems.kind, kind)); +}); + +describe("claiming durable work", () => { + test("one replica takes a due item, and it comes back with what it is about", async () => { + await queue.offer({ kind, key: "bot-a", payload: { botId: "bot-a" } }); + + const claimed = await queue.claim({ + kind, + owner: "replica-1", + leaseMs: 30_000, + }); + + expect(claimed).toHaveLength(1); + expect(claimed[0]?.key).toBe("bot-a"); + expect(claimed[0]?.payload).toEqual({ botId: "bot-a" }); + // First time out, so whatever runs this knows it has certainly not run before. + expect(claimed[0]?.attempts).toBe(1); + }); + + test("a second replica does not get an item the first is holding", async () => { + await queue.offer({ kind, key: "bot-a" }); + + const first = await queue.claim({ + kind, + owner: "replica-1", + leaseMs: 30_000, + }); + const second = await queue.claim({ + kind, + owner: "replica-2", + leaseMs: 30_000, + }); + + expect(first).toHaveLength(1); + expect(second).toHaveLength(0); + }); + + /* + * THE TEST THIS FILE EXISTS FOR. + * + * Ten replicas reaching for ten items at the same moment must between them take each item once. + * Anything less than `skip locked` fails here: plain `for update` serialises and one transaction + * waits behind another, and no locking at all hands the same row to several claimants, which for a + * routine is the same run billed N times. + */ + test("ten replicas racing for ten items take each of them exactly once", async () => { + const keys = Array.from({ length: 10 }, (_, index) => `bot-${index}`); + for (const key of keys) await queue.offer({ kind, key }); + + const results = await Promise.all( + Array.from({ length: 10 }, (_, index) => + queue.claim({ + kind, + owner: `replica-${index}`, + leaseMs: 30_000, + limit: 3, + }), + ), + ); + + const taken = results.flat().map((item) => item.key); + expect(taken).toHaveLength(10); + expect(new Set(taken).size).toBe(10); + }); + + test("a lease that stopped being renewed comes back to whoever asks next", async () => { + await queue.offer({ kind, key: "bot-a" }); + // Claimed by a replica that then dies: nothing renews, and the lease is already in the past. + await queue.claim({ kind, owner: "replica-1", leaseMs: -1 }); + + const recovered = await queue.claim({ + kind, + owner: "replica-2", + leaseMs: 30_000, + }); + + expect(recovered).toHaveLength(1); + /* + * Second time out, which is the number that matters. Whatever picks this up has to be able to + * tell "this never started" from "this started and we lost the process", because the second may + * already have called a tool and spent money. + */ + expect(recovered[0]?.attempts).toBe(2); + }); + + test("renewing keeps a claim, and cannot steal one back after it was lost", async () => { + await queue.offer({ kind, key: "bot-a" }); + await queue.claim({ kind, owner: "replica-1", leaseMs: -1 }); + await queue.claim({ kind, owner: "replica-2", leaseMs: 30_000 }); + + // Replica 1 wakes up and tries to keep a claim that is no longer its own. + const stale = await queue.renew({ + kind, + key: "bot-a", + owner: "replica-1", + leaseMs: 30_000, + }); + const current = await queue.renew({ + kind, + key: "bot-a", + owner: "replica-2", + leaseMs: 30_000, + }); + + expect(stale).toBe(false); + expect(current).toBe(true); + }); + + test("offering the same work twice leaves one item", async () => { + /* + * Idempotence, which is where every recovery path stops being a duplicate-run path. A routine + * due at 07:00 is offered by every replica that wakes; the key carries the minute, so they are + * all offering the same thing. + */ + await queue.offer({ kind, key: "routine:daily:2026-08-24T07:00" }); + await queue.offer({ kind, key: "routine:daily:2026-08-24T07:00" }); + await queue.offer({ kind, key: "routine:daily:2026-08-24T07:00" }); + + const rows = await database + .select({ key: workItems.key }) + .from(workItems) + .where(eq(workItems.kind, kind)); + expect(rows).toHaveLength(1); + }); + + test("an item that is not due yet is not claimed", async () => { + await queue.offer({ + kind, + key: "later", + runAt: new Date(Date.now() + 60_000), + }); + + expect( + await queue.claim({ kind, owner: "replica-1", leaseMs: 30_000 }), + ).toHaveLength(0); + }); + + /* + * The idempotence this table promises has to survive completion. + * + * Finishing used to delete the row, so the insert a re-offer was supposed to collide with had + * nothing left to collide with: a routine due at 07:00 that had already run was handed straight + * back to the next replica to wake late, and ran twice. Every recovery path was a duplicate-run + * path. + */ + test("a finished item stays, so re-offering the same key runs nothing", async () => { + await queue.offer({ kind, key: "bot-a" }); + await queue.claim({ kind, owner: "replica-1", leaseMs: 30_000 }); + expect(await queue.finish({ kind, key: "bot-a", owner: "replica-1" })).toBe( + true, + ); + + await queue.offer({ kind, key: "bot-a" }); + + expect( + await queue.claim({ kind, owner: "replica-2", leaseMs: 30_000 }), + ).toHaveLength(0); + }); + + test("finished rows are swept once they are past their retention", async () => { + await queue.offer({ kind, key: "bot-a" }); + await queue.claim({ kind, owner: "replica-1", leaseMs: 30_000 }); + await queue.finish({ kind, key: "bot-a", owner: "replica-1" }); + + expect(await queue.purge({ kind, olderThanMs: 60_000 })).toBe(0); + expect(await queue.purge({ kind, olderThanMs: 0 })).toBe(1); + }); + + test("releasing frees the item and holds it back for a while", async () => { + await queue.offer({ kind, key: "bot-a" }); + await queue.claim({ kind, owner: "replica-1", leaseMs: 30_000 }); + await queue.release({ + kind, + key: "bot-a", + owner: "replica-1", + delayMs: 60_000, + }); + + // Free, but not yet due, so nobody picks it straight back up and spins on it. + expect( + await queue.claim({ kind, owner: "replica-2", leaseMs: 30_000 }), + ).toHaveLength(0); + }); + + /* + * Both of these are the same bug from two ends: the lease says who may act on an item, and only + * `renew` used to ask. A replica whose lease had quietly gone could delete or reschedule work + * another replica was in the middle of doing. + */ + test("a replica that lost its lease cannot finish somebody else's work", async () => { + await queue.offer({ kind, key: "bot-a" }); + await queue.claim({ kind, owner: "replica-1", leaseMs: 1 }); + await Bun.sleep(30); + await queue.claim({ kind, owner: "replica-2", leaseMs: 30_000 }); + + expect(await queue.finish({ kind, key: "bot-a", owner: "replica-1" })).toBe( + false, + ); + + const [row] = await database + .select({ by: workItems.claimedBy, done: workItems.finishedAt }) + .from(workItems) + .where(and(eq(workItems.kind, kind), eq(workItems.key, "bot-a"))); + expect(row?.by).toBe("replica-2"); + expect(row?.done).toBeNull(); + }); + + test("a replica that lost its lease cannot reschedule somebody else's work", async () => { + await queue.offer({ kind, key: "bot-a" }); + await queue.claim({ kind, owner: "replica-1", leaseMs: 1 }); + await Bun.sleep(30); + await queue.claim({ kind, owner: "replica-2", leaseMs: 30_000 }); + + expect( + await queue.release({ + kind, + key: "bot-a", + owner: "replica-1", + delayMs: 60_000, + }), + ).toBe(false); + + const [row] = await database + .select({ by: workItems.claimedBy }) + .from(workItems) + .where(and(eq(workItems.kind, kind), eq(workItems.key, "bot-a"))); + expect(row?.by).toBe("replica-2"); + }); + + /* + * Two clocks pretending to be one. + * + * The lease was computed as `Date.now() + leaseMs` on the replica and compared against `now()` in + * Postgres. A node behind the database wrote a live lease that arrived already expired, and the + * next replica to look took the item out from under it. Both ran it. Every moment is named in SQL + * now, so a wrong local clock cannot produce one. + */ + test("a replica whose clock is behind still holds a real lease", async () => { + await queue.offer({ kind, key: "bot-a" }); + + const realNow = Date.now; + Date.now = () => realNow() - 90_000; + try { + await queue.claim({ kind, owner: "replica-1", leaseMs: 60_000 }); + } finally { + Date.now = realNow; + } + + expect( + await queue.claim({ kind, owner: "replica-2", leaseMs: 60_000 }), + ).toHaveLength(0); + }); + + /* + * A permanently failing item has to stop somewhere a person can see, rather than retrying until + * somebody notices, which on a queue with no dashboard is never. + */ + test("an item stops being offered once it runs out of attempts", async () => { + await queue.offer({ kind, key: "bot-a" }); + + for (let attempt = 0; attempt < 3; attempt += 1) { + const [item] = await queue.claim({ + kind, + owner: "replica-1", + leaseMs: 30_000, + maxAttempts: 3, + }); + expect(item?.attempts).toBe(attempt + 1); + await queue.release({ + kind, + key: "bot-a", + owner: "replica-1", + delayMs: 0, + reason: "the cluster said no", + }); + } + + expect( + await queue.claim({ + kind, + owner: "replica-1", + leaseMs: 30_000, + maxAttempts: 3, + }), + ).toHaveLength(0); + + // Still here, with its count and its reason, which is the terminal state rather than a silence. + const [row] = await database + .select({ attempts: workItems.attempts, why: workItems.lastError }) + .from(workItems) + .where(and(eq(workItems.kind, kind), eq(workItems.key, "bot-a"))); + expect(row?.attempts).toBe(3); + expect(row?.why).toBe("the cluster said no"); + }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json index 4bd6962d..8858dd28 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,4 +1,11 @@ { "extends": "../tsconfig.base.json", - "include": ["src"] + // `scripts` too: the migration runner and the culler entrypoint ship in the image, so they are + // product code that happens to live outside `src`. + // + // `tests` is NOT here yet, and that is a known gap rather than a decision: the suite has never been + // type-checked and turning it on surfaces a few hundred years of accumulated `any`. It is what let + // a test pass an options object where a connection string belongs and hear nothing back. Its own + // change, because it is a sweep and not a fix. + "include": ["src", "scripts"] } From 291bae6dc7b79821d10bc2719a22bcc995af613a Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Wed, 26 Aug 2026 10:30:59 -0300 Subject: [PATCH 03/13] Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin --- CHANGELOG.md | 10 + .../components/app-sidebar/app-sidebar.tsx | 39 +- app/src/components/app-sidebar/channel.tsx | 12 +- app/src/lib/channels/mutations.ts | 56 +- app/src/lib/channels/queries.ts | 2 + .../_authed/_app/channel/$channelId.tsx | 44 +- app/src/routes/_authed/admin/route.tsx | 6 +- app/src/routes/_authed/settings/route.tsx | 6 +- app/tests/channel-menu-mutations.test.ts | 88 +- app/tests/channel-order.test.ts | 1 + app/tests/channel-unread.test.ts | 62 + server/drizzle/0019_channel_read_marker.sql | 1 + server/drizzle/meta/0019_snapshot.json | 2583 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/channels/routes.ts | 50 + server/src/db/schema/core.ts | 5 + .../channel-activity.integration.test.ts | 1 + server/tests/channel-routes.test.ts | 163 ++ 18 files changed, 3123 insertions(+), 13 deletions(-) create mode 100644 app/tests/channel-unread.test.ts create mode 100644 server/drizzle/0019_channel_read_marker.sql create mode 100644 server/drizzle/meta/0019_snapshot.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b90ea999..7bdc7671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### A channel a Bot has spoken in unseen shows a dot + +The sidebar marks a channel when a Bot has said something since you last had it open: a dot beside +the preview, the name a touch heavier. Opening the channel clears it, your own messages never set +it, and the channel you are looking at never shows it. The marker is yours alone — per member, on +the membership row like the pin — so one person reading does not clear anybody else's dot. + +The deployment gains one nullable column, via migration `0019`. + + ### A finished turn shows the page it opened, not the one open now Reopening a conversation made every past turn fetch the screen as it is now, so an answer about diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx index 68c0676d..99946b00 100644 --- a/app/src/components/app-sidebar/app-sidebar.tsx +++ b/app/src/components/app-sidebar/app-sidebar.tsx @@ -13,7 +13,12 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; -import { Link, type LinkOptions, useNavigate } from "@tanstack/react-router"; +import { + Link, + type LinkOptions, + useNavigate, + useParams, +} from "@tanstack/react-router"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type * as React from "react"; import { useState } from "react"; @@ -123,6 +128,30 @@ export function pinnedFirst(channels: ChannelSummary[]): ChannelSummary[] { return [...channels].sort((a, b) => Number(b.pinned) - Number(a.pinned)); } +/** + * Whether a Bot has said something this member has not had on screen yet. + * + * A Bot's message, and only a Bot's: your own message carries a null agent id and reading your own + * words needs no marker. ISO-8601 strings compare correctly as strings, which is the same bet the + * server's recency sort already makes. + */ +export function hasUnseenActivity(channel: ChannelSummary): boolean { + if (channel.lastMessageAgentId === null || channel.lastMessageAt === null) { + return false; + } + return ( + channel.lastReadAt === null || channel.lastMessageAt > channel.lastReadAt + ); +} + +/** Unseen activity somewhere you are not looking. The open channel never shows the dot. */ +export function isUnread( + channel: ChannelSummary, + openChannelId: string | undefined, +): boolean { + return channel.id !== openChannelId && hasUnseenActivity(channel); +} + /** * A roster row that can animate. * @@ -138,6 +167,13 @@ function ChannelRow({ animateOrder: boolean; }) { const shouldReduceMotion = useReducedMotion(); + // Whether this row is unread, as a boolean, for the same reason `Channel` computes `isOpen` + // that way: navigating re-renders the rows whose answer changed, not the whole roster. + const unread = useParams({ + strict: false, + select: (params) => + isUnread(channel, (params as { channelId?: string }).channelId), + }); return ( ); diff --git a/app/src/components/app-sidebar/channel.tsx b/app/src/components/app-sidebar/channel.tsx index f4fa2edf..18733576 100644 --- a/app/src/components/app-sidebar/channel.tsx +++ b/app/src/components/app-sidebar/channel.tsx @@ -42,6 +42,7 @@ export const Channel = memo(function Channel({ lastMessage, lastMessageAt, pinned, + unread, }: { channelId: string; participantIds: string[]; @@ -49,6 +50,7 @@ export const Channel = memo(function Channel({ lastMessage?: string; lastMessageAt?: string; pinned: boolean; + unread: boolean; }) { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -112,7 +114,11 @@ export const Channel = memo(function Channel({
- + {name}
@@ -123,6 +129,10 @@ export const Channel = memo(function Channel({ {lastMessage} + {unread ? ( + /* State about the message beats state about the row, so it sits first. */ + + ) : null} {pinned ? ( ) : null} diff --git a/app/src/lib/channels/mutations.ts b/app/src/lib/channels/mutations.ts index cf116ed8..2d10c4e4 100644 --- a/app/src/lib/channels/mutations.ts +++ b/app/src/lib/channels/mutations.ts @@ -1,6 +1,10 @@ -import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { + mutationOptions, + type InfiniteData, + type QueryClient, +} from "@tanstack/react-query"; import { client, tryClient } from "@/lib/client"; -import { type AgentChannel, channelKeys } from "./queries"; +import { type AgentChannel, type ChannelPage, channelKeys } from "./queries"; /** * Start a new channel with one or more coworkers. @@ -66,6 +70,54 @@ export function setChannelPinnedMutationOptions(queryClient: QueryClient) { }); } +/** + * Stamp a channel read for this member, patching the cache before the wire answers. + * + * Patched in onMutate rather than refetched on success: the dot must clear the instant the channel + * opens, not a round-trip later. No rollback on failure and no invalidation — a mark-read that did + * not land is a dot that returns on the next refetch, which is the truth reasserting itself, and a + * refetch here would race the socket's own patches for nothing. + */ +export function markChannelReadMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (channelId: string) => { + await client(`/api/channels/${channelId}/read`, { + method: "PUT", + fallback: "Could not mark this channel read", + }); + }, + onMutate: (channelId) => { + const now = new Date().toISOString(); + queryClient.setQueryData( + channelKeys.list(), + (data: InfiniteData | undefined) => + data && { + ...data, + pages: data.pages.map((page) => ({ + ...page, + channels: page.channels.map((row) => + row.id === channelId + ? { + ...row, + /* + * The later of now and the row's own lastMessageAt: lastMessageAt comes from + * another clock, and a marker stamped "now" by a clock running behind it + * would leave the row still reading as unseen — and the dot still lit. + */ + lastReadAt: + row.lastMessageAt && row.lastMessageAt > now + ? row.lastMessageAt + : now, + } + : row, + ), + })), + }, + ); + }, + }); +} + /** Soft-delete a channel for everyone in it. The server keeps the transcript; the roster forgets. */ export function deleteChannelMutationOptions(queryClient: QueryClient) { return mutationOptions({ diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts index 2c292946..6da66bf1 100644 --- a/app/src/lib/channels/queries.ts +++ b/app/src/lib/channels/queries.ts @@ -26,6 +26,8 @@ export type ChannelSummary = AgentChannel & { createdAt: string; /** Whether this member pinned the channel. Pinned channels sort first in the roster. */ pinned: boolean; + /** ISO-8601 when this member last had the channel open, or null for never. The caller's, only. */ + lastReadAt: string | null; }; export const channelKeys = { diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index d9030a43..1c282746 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -1,10 +1,16 @@ import { IconDeviceDesktop, IconSettings } from "@tabler/icons-react"; -import { useQuery } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { motion, useReducedMotion } from "motion/react"; import { useEffect, useRef } from "react"; import { z } from "zod"; import { AgentProfile } from "@/components/agents/agent-profile"; +import { hasUnseenActivity } from "@/components/app-sidebar/app-sidebar"; import { ChannelAvatar } from "@/components/channels/avatar"; import { ChannelChat } from "@/components/channels/channel-chat"; import { ActivityLog } from "@/components/computer/activity-log"; @@ -12,7 +18,12 @@ import { ComputerView } from "@/components/computer/computer-view"; import { useNeedsYou } from "@/components/computer/needs-you"; import { DetailPanel } from "@/components/layout/detail-panel"; import { Button } from "@/components/ui/button"; -import { type AgentChannel, channelQueryOptions } from "@/lib/channels/queries"; +import { markChannelReadMutationOptions } from "@/lib/channels/mutations"; +import { + type AgentChannel, + channelListQueryOptions, + channelQueryOptions, +} from "@/lib/channels/queries"; import { onComputerActivity } from "@/lib/copilot/computer-activity"; const chatSearchSchema = z.object({ @@ -77,6 +88,35 @@ function RouteComponent() { /** Only polled while the screen is closed; the screen panel polls control itself. */ const needsYou = useNeedsYou(agentId, !isWatching); + const queryClient = useQueryClient(); + const markRead = useMutation(markChannelReadMutationOptions(queryClient)); + /* + * This channel's roster summary, read out of the same infinite query the sidebar renders. + * The detail query deliberately knows nothing about activity; the roster is where the socket + * keeps lastMessageAt live, so it is the one honest source for "has something new been said". + */ + const roster = useInfiniteQuery(channelListQueryOptions()); + const summary = roster.data?.find((row) => row.id === channelId); + + /* + * Opening the channel marks it read; the Bot replying while it is open marks it read again. + * One effect covers both: the dep changes on navigation and on every activity patch, and the + * unseen check keeps it from writing a row per render. No dependency on the mutation object — + * its identity changes per render and the effect must not re-fire for that. + * + * Keyed on primitives, deliberately. The optimistic mark-read patch changes the summary OBJECT's + * identity without changing these values, so an object dep would re-fire the effect on its own + * write — and when lastMessageAt sits ahead of this browser's clock (another device wrote it), + * that re-fire loops into a PUT per render. Primitives hold still under the patch: one PUT. + */ + const unseen = summary !== undefined && hasUnseenActivity(summary); + const markReadMutate = markRead.mutate; + useEffect(() => { + if (unseen) { + markReadMutate(channelId); + } + }, [channelId, unseen, markReadMutate]); + /* * Needs-you prompts auto-open the screen panel, because the prompt with the reason on it — the * amber "the assistant needs you" row, and the masked field for a credential — is drawn on the diff --git a/app/src/routes/_authed/admin/route.tsx b/app/src/routes/_authed/admin/route.tsx index d83cfe74..e92175c3 100644 --- a/app/src/routes/_authed/admin/route.tsx +++ b/app/src/routes/_authed/admin/route.tsx @@ -19,12 +19,12 @@ function RouteComponent() { return ( { "This channel is defined by the deployment package, so it cannot be deleted here.", ); }); + +test("marking read PUTs the read route and patches lastReadAt in place", async () => { + const seen = capturingFetch(204, undefined); + const queryClient = new QueryClient(); + queryClient.setQueryData(channelKeys.list(), { + pages: [ + { + channels: [ + { + id: "channel-1", + name: "Assistant channel", + agentIds: ["agent-1"], + threadId: "thread-1", + active: true, + lastMessage: "hello", + lastMessageAt: "2026-08-25T12:00:00.000Z", + lastMessageAgentId: "agent-1", + createdAt: "2026-08-25T11:00:00.000Z", + pinned: false, + lastReadAt: null, + }, + ], + nextCursor: null, + }, + ], + pageParams: [""], + } satisfies InfiniteData); + const options = markChannelReadMutationOptions(queryClient); + + options.onMutate?.("channel-1"); + await options.mutationFn?.("channel-1"); + + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe("/api/channels/channel-1/read"); + expect(seen[0]?.init?.method).toBe("PUT"); + const patched = queryClient.getQueryData>( + channelKeys.list(), + ); + // The dot clears from the cache before the wire answered, and nothing was invalidated: + // there is no onSuccess to queue a refetch that would race the socket's own patches. + expect(patched?.pages[0]?.channels[0]?.lastReadAt).not.toBeNull(); + expect(options.onSuccess).toBeUndefined(); +}); + +test("a message stamped by a clock ahead of ours still reads as seen after marking", async () => { + capturingFetch(204, undefined); + const queryClient = new QueryClient(); + const futureLastMessageAt = new Date(Date.now() + 60_000).toISOString(); + queryClient.setQueryData(channelKeys.list(), { + pages: [ + { + channels: [ + { + id: "channel-1", + name: "Assistant channel", + agentIds: ["agent-1"], + threadId: "thread-1", + active: true, + lastMessage: "hello", + lastMessageAt: futureLastMessageAt, + lastMessageAgentId: "agent-1", + createdAt: "2026-08-25T11:00:00.000Z", + pinned: false, + lastReadAt: null, + }, + ], + nextCursor: null, + }, + ], + pageParams: [""], + } satisfies InfiniteData); + const options = markChannelReadMutationOptions(queryClient); + + options.onMutate?.("channel-1"); + + const patched = queryClient.getQueryData>( + channelKeys.list(), + ); + const row = patched?.pages[0]?.channels[0]; + // A reader's clock running behind the writer's must not leave the row still reading as unseen: + // the patched lastReadAt has to catch up to (or pass) lastMessageAt, not just "now". + expect(row?.lastReadAt).not.toBeNull(); + expect((row?.lastReadAt as string) >= futureLastMessageAt).toBe(true); +}); diff --git a/app/tests/channel-order.test.ts b/app/tests/channel-order.test.ts index 3544a81a..ad283696 100644 --- a/app/tests/channel-order.test.ts +++ b/app/tests/channel-order.test.ts @@ -15,6 +15,7 @@ function channel(id: string, pinned: boolean): ChannelSummary { lastMessageAgentId: null, createdAt: "2024-01-01T00:00:00.000Z", pinned, + lastReadAt: null, }; } diff --git a/app/tests/channel-unread.test.ts b/app/tests/channel-unread.test.ts new file mode 100644 index 00000000..40192e3e --- /dev/null +++ b/app/tests/channel-unread.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { + hasUnseenActivity, + isUnread, +} from "../src/components/app-sidebar/app-sidebar"; +import type { ChannelSummary } from "../src/lib/channels/queries"; + +/** A minimal but fully-typed summary, so tests build real objects rather than casts. */ +function channel(overrides: Partial): ChannelSummary { + return { + id: "channel-1", + name: "Assistant channel", + agentIds: ["agent-1"], + threadId: "thread-1", + active: true, + lastMessage: "hello", + lastMessageAt: "2026-08-25T12:00:00.000Z", + lastMessageAgentId: "agent-1", + createdAt: "2026-08-25T11:00:00.000Z", + pinned: false, + lastReadAt: null, + ...overrides, + }; +} + +test("a Bot message in a never-opened channel is unseen", () => { + expect(hasUnseenActivity(channel({}))).toBe(true); +}); + +test("a Bot message newer than the read marker is unseen", () => { + expect( + hasUnseenActivity(channel({ lastReadAt: "2026-08-25T11:30:00.000Z" })), + ).toBe(true); +}); + +test("a read marker after the last message means nothing is unseen", () => { + expect( + hasUnseenActivity(channel({ lastReadAt: "2026-08-25T12:30:00.000Z" })), + ).toBe(false); +}); + +test("your own last message never counts as unseen", () => { + expect(hasUnseenActivity(channel({ lastMessageAgentId: null }))).toBe(false); +}); + +test("a silent channel has nothing unseen", () => { + expect( + hasUnseenActivity( + channel({ + lastMessage: null, + lastMessageAt: null, + lastMessageAgentId: null, + }), + ), + ).toBe(false); +}); + +test("the open channel is never unread, however unseen its activity", () => { + expect(isUnread(channel({}), "channel-1")).toBe(false); + expect(isUnread(channel({}), "channel-2")).toBe(true); + expect(isUnread(channel({}), undefined)).toBe(true); +}); diff --git a/server/drizzle/0019_channel_read_marker.sql b/server/drizzle/0019_channel_read_marker.sql new file mode 100644 index 00000000..81128352 --- /dev/null +++ b/server/drizzle/0019_channel_read_marker.sql @@ -0,0 +1 @@ +ALTER TABLE "channel_memberships" ADD COLUMN "last_read_at" timestamp with time zone; \ No newline at end of file diff --git a/server/drizzle/meta/0019_snapshot.json b/server/drizzle/meta/0019_snapshot.json new file mode 100644 index 00000000..e899ec13 --- /dev/null +++ b/server/drizzle/meta/0019_snapshot.json @@ -0,0 +1,2583 @@ +{ + "id": "9f46b81a-bfe4-4c29-ab95-e08d00506767", + "prevId": "aa5ec39b-170c-495b-b4a9-e08ed0fd643d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_account_idx": { + "name": "accounts_provider_account_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agents_package_id_deployment_packages_id_fk": { + "name": "agents_package_id_deployment_packages_id_fk", + "tableFrom": "agents", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_events": { + "name": "audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_events_created_at_idx": { + "name": "audit_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_type_time_idx": { + "name": "audit_events_type_time_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_actor_time_idx": { + "name": "audit_events_actor_time_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_events_target_time_idx": { + "name": "audit_events_target_time_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agents": { + "name": "channel_agents", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agents_channel_id_channels_id_fk": { + "name": "channel_agents_channel_id_channels_id_fk", + "tableFrom": "channel_agents", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_agents_agent_id_agents_id_fk": { + "name": "channel_agents_agent_id_agents_id_fk", + "tableFrom": "channel_agents", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_agents_channel_id_agent_id_pk": { + "name": "channel_agents_channel_id_agent_id_pk", + "columns": ["channel_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_memberships": { + "name": "channel_memberships", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_memberships_channel_id_channels_id_fk": { + "name": "channel_memberships_channel_id_channels_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_memberships_user_id_users_id_fk": { + "name": "channel_memberships_user_id_users_id_fk", + "tableFrom": "channel_memberships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "channel_memberships_channel_id_user_id_pk": { + "name": "channel_memberships_channel_id_user_id_pk", + "columns": ["channel_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channels": { + "name": "channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggested_prompts": { + "name": "suggested_prompts", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "allowed_groups": { + "name": "allowed_groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "package_id": { + "name": "package_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "override": { + "name": "override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_message": { + "name": "last_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_agent_id": { + "name": "last_message_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "channels_recent_activity_idx": { + "name": "channels_recent_activity_idx", + "columns": [ + { + "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "channels_package_id_deployment_packages_id_fk": { + "name": "channels_package_id_deployment_packages_id_fk", + "tableFrom": "channels", + "tableTo": "deployment_packages", + "columnsFrom": ["package_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "channels_last_message_agent_id_agents_id_fk": { + "name": "channels_last_message_agent_id_agents_id_fk", + "tableFrom": "channels", + "tableTo": "agents", + "columnsFrom": ["last_message_agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "credential_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credentials_active_key_idx": { + "name": "credentials_active_key_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credentials\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_packages": { + "name": "deployment_packages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "loaded_at": { + "name": "loaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployment_packages_tenant_id_unique": { + "name": "deployment_packages_tenant_id_unique", + "nullsNotDistinct": false, + "columns": ["tenant_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.intelligence_channel_mappings": { + "name": "intelligence_channel_mappings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "intelligence_channel_mappings_thread_idx": { + "name": "intelligence_channel_mappings_thread_idx", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "intelligence_channel_mappings_user_id_users_id_fk": { + "name": "intelligence_channel_mappings_user_id_users_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "intelligence_channel_mappings_channel_id_channels_id_fk": { + "name": "intelligence_channel_mappings_channel_id_channels_id_fk", + "tableFrom": "intelligence_channel_mappings", + "tableTo": "channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "intelligence_channel_mappings_user_id_channel_id_pk": { + "name": "intelligence_channel_mappings_user_id_channel_id_pk", + "columns": ["user_id", "channel_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.revoked_access": { + "name": "revoked_access", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_providers": { + "name": "sso_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sso_providers_user_id_users_id_fk": { + "name": "sso_providers_user_id_users_id_fk", + "tableFrom": "sso_providers", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sso_providers_provider_id_unique": { + "name": "sso_providers_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_roles": { + "name": "user_roles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_roles_user_id_role_pk": { + "name": "user_roles_user_id_role_pk", + "columns": ["user_id", "role"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.action_policy": { + "name": "action_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deny": { + "name": "deny", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "allow": { + "name": "allow", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_page_frame": { + "name": "computer_page_frame", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "computer_page_frame_captured_idx": { + "name": "computer_page_frame_captured_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "computer_page_frame_computer_id_tool_call_id_pk": { + "name": "computer_page_frame_computer_id_tool_call_id_pk", + "columns": ["computer_id", "tool_call_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.computer_snapshot": { + "name": "computer_snapshot", + "schema": "", + "columns": { + "computer_id": { + "name": "computer_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "elements": { + "name": "elements", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_preferences": { + "name": "agent_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_preferences_user_id_users_id_fk": { + "name": "agent_preferences_user_id_users_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_preferences_agent_id_agents_id_fk": { + "name": "agent_preferences_agent_id_agents_id_fk", + "tableFrom": "agent_preferences", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_preferences_user_id_agent_id_pk": { + "name": "agent_preferences_user_id_agent_id_pk", + "columns": ["user_id", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_profiles": { + "name": "agent_profiles", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_description": { + "name": "role_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_seed": { + "name": "avatar_seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "agent_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "callback_token_hash": { + "name": "callback_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_token_issued_at": { + "name": "callback_token_issued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_profiles_visibility_deleted_idx": { + "name": "agent_profiles_visibility_deleted_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_profiles_agent_id_agents_id_fk": { + "name": "agent_profiles_agent_id_agents_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_profiles_owner_user_id_users_id_fk": { + "name": "agent_profiles_owner_user_id_users_id_fk", + "tableFrom": "agent_profiles", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_exclusions": { + "name": "component_exclusions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "withheld_by": { + "name": "withheld_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_exclusions_component_name_components_name_fk": { + "name": "component_exclusions_component_name_components_name_fk", + "tableFrom": "component_exclusions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "component_exclusions_agent_id_agents_id_fk": { + "name": "component_exclusions_agent_id_agents_id_fk", + "tableFrom": "component_exclusions", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_exclusions_component_name_agent_id_pk": { + "name": "component_exclusions_component_name_agent_id_pk", + "columns": ["component_name", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.component_functions": { + "name": "component_functions", + "schema": "", + "columns": { + "component_name": { + "name": "component_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "component_functions_component_name_components_name_fk": { + "name": "component_functions_component_name_components_name_fk", + "tableFrom": "component_functions", + "tableTo": "components", + "columnsFrom": ["component_name"], + "columnsTo": ["name"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "component_functions_component_name_function_name_pk": { + "name": "component_functions_component_name_function_name_pk", + "columns": ["component_name", "function_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.components": { + "name": "components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provenance": { + "name": "provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'first-party'" + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tools_refreshed_at": { + "name": "tools_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_credential_id_credentials_id_fk": { + "name": "mcp_servers_credential_id_credentials_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_tools": { + "name": "mcp_tools", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_tools_server_id_mcp_servers_id_fk": { + "name": "mcp_tools_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_tools", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_tools_server_id_name_pk": { + "name": "mcp_tools_server_id_name_pk", + "columns": ["server_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_user_credentials": { + "name": "mcp_user_credentials", + "schema": "", + "columns": { + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_user_credentials_user_idx": { + "name": "mcp_user_credentials_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_user_credentials_server_id_mcp_servers_id_fk": { + "name": "mcp_user_credentials_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "mcp_servers", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_user_id_users_id_fk": { + "name": "mcp_user_credentials_user_id_users_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_user_credentials_credential_id_credentials_id_fk": { + "name": "mcp_user_credentials_credential_id_credentials_id_fk", + "tableFrom": "mcp_user_credentials", + "tableTo": "credentials", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "mcp_user_credentials_server_id_user_id_pk": { + "name": "mcp_user_credentials_server_id_user_id_pk", + "columns": ["server_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_grants": { + "name": "plugin_grants", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "plugin_grants_agent_idx": { + "name": "plugin_grants_agent_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "plugin_grants_agent_id_agents_id_fk": { + "name": "plugin_grants_agent_id_agents_id_fk", + "tableFrom": "plugin_grants", + "tableTo": "agents", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_grants_kind_ref_agent_id_pk": { + "name": "plugin_grants_kind_ref_agent_id_pk", + "columns": ["kind", "ref", "agent_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandboxed_components": { + "name": "sandboxed_components", + "schema": "", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "draft_description": { + "name": "draft_description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_html": { + "name": "draft_html", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_css": { + "name": "draft_css", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_js_functions": { + "name": "draft_js_functions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "draft_argument_schema": { + "name": "draft_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "published_description": { + "name": "published_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_html": { + "name": "published_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_css": { + "name": "published_css", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_js_functions": { + "name": "published_js_functions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_argument_schema": { + "name": "published_argument_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sample_arguments": { + "name": "sample_arguments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authored_by": { + "name": "authored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_tools": { + "name": "skill_tools", + "schema": "", + "columns": { + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref": { + "name": "ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by": { + "name": "declared_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_tools_ref_idx": { + "name": "skill_tools_ref_idx", + "columns": [ + { + "expression": "ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_tools_skill_id_skills_id_fk": { + "name": "skill_tools_skill_id_skills_id_fk", + "tableFrom": "skill_tools", + "tableTo": "skills", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "skill_tools_skill_id_ref_pk": { + "name": "skill_tools_skill_id_ref_pk", + "columns": ["skill_id", "ref"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instructions": { + "name": "instructions", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'yours'" + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skills_slug_key": { + "name": "skills_slug_key", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skills_owner_idx": { + "name": "skills_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_owner_user_id_users_id_fk": { + "name": "skills_owner_user_id_users_id_fk", + "tableFrom": "skills", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.work_items": { + "name": "work_items", + "schema": "", + "columns": { + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_at": { + "name": "run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "work_items_claimable_idx": { + "name": "work_items_claimable_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "work_items_kind_key_pk": { + "name": "work_items_kind_key_pk", + "columns": ["kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_type": { + "name": "agent_type", + "schema": "public", + "values": ["built_in", "remote_ag_ui"] + }, + "public.credential_kind": { + "name": "credential_kind", + "schema": "public", + "values": [ + "model", + "connector", + "agent", + "mcp", + "mcp_oauth_client", + "mcp_user_token" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": ["admin", "user"] + }, + "public.agent_visibility": { + "name": "agent_visibility", + "schema": "public", + "values": ["public", "private"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 8b13de0c..b2ebb2ae 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1787688017645, "tag": "0018_page_frames", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1787744867526, + "tag": "0019_channel_read_marker", + "breakpoints": true } ] } diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts index 0a1061d7..0d10a13a 100644 --- a/server/src/channels/routes.ts +++ b/server/src/channels/routes.ts @@ -51,6 +51,8 @@ export type ChannelSummary = AgentChannel & { createdAt: Date; /** Whether the caller pinned this channel. A pin is per-member, so this is the caller's, only. */ pinned: boolean; + /** When the caller last had this channel open, or null for never. The caller's, only. */ + lastReadAt: Date | null; }; /** What a client that ran an agent reports back about the message it just saw. */ @@ -152,6 +154,8 @@ export type ChannelStore = { channelId: string, pinned: boolean, ): Promise; + /** Stamp the caller's own membership as read now. Throws ChannelNotFoundError for a non-member. */ + markRead(actor: AgentActor, channelId: string): Promise; /** * Hide the channel for every member. Soft: the row and the thread survive, every read filters. * Throws ChannelNotFoundError for a non-member and ChannelPackageOwnedError for a channel the @@ -366,6 +370,7 @@ export function createChannelStore( lastMessageAgentId: channels.lastMessageAgentId, createdAt: channels.createdAt, pinnedAt: channelMemberships.pinnedAt, + lastReadAt: channelMemberships.lastReadAt, }) .from(channels) .innerJoin( @@ -423,6 +428,7 @@ export function createChannelStore( lastMessageAgentId: row.lastMessageAgentId, createdAt: row.createdAt, pinned: row.pinnedAt !== null, + lastReadAt: row.lastReadAt, }); } return { channels: [...summaries.values()], nextCursor }; @@ -482,6 +488,39 @@ export function createChannelStore( ); }, + async markRead(actor, channelId) { + const updated = await database + .update(channelMemberships) + .set({ + /* + * The later of this clock and the channel's own last-message stamp. last_message_at is + * written from the reporting browser's clock and is not bounded; a marker stamped + * plainly "now" by a server running behind it would leave the row reading as unseen for + * every member, re-lighting the dot on each refetch until wall clock catches up. + */ + lastReadAt: sql`greatest(now(), coalesce((select ${channels.lastMessageAt} from ${channels} where ${channels.id} = ${channelMemberships.channelId}), now()))`, + }) + .where( + and( + eq(channelMemberships.channelId, channelId), + eq(channelMemberships.userId, actor.id), + // A deleted channel is not there to read. The same guard `setPinned` carries, for the + // same reason: the row is gone from every roster, so nothing about it is markable. + exists( + database + .select({ one: sql`1` }) + .from(channels) + .where( + and(eq(channels.id, channelId), isNull(channels.deletedAt)), + ), + ), + ), + ) + .returning({ channelId: channelMemberships.channelId }); + // Not a member, or no such channel: the same answer either way, matching setPinned. + if (updated.length === 0) throw new ChannelNotFoundError(channelId); + }, + async softDelete(actor, channelId) { await database.transaction( async (transaction) => { @@ -874,6 +913,15 @@ export function createChannelRoutes( } }); + routes.put("/:channelId/read", requireUser, async (context) => { + try { + await store.markRead(context.var.actor, context.req.param("channelId")); + return context.body(null, 204); + } catch (error) { + return mapStoreError(context, error); + } + }); + routes.delete("/:channelId", requireUser, async (context) => { const channelId = context.req.param("channelId"); try { @@ -922,6 +970,8 @@ function channelSummaryDto(channel: ChannelSummary) { lastMessageAgentId: channel.lastMessageAgentId, createdAt: channel.createdAt.toISOString(), pinned: channel.pinned, + // Serialised as ISO-8601 like lastMessageAt, so the browser can compare the two as strings. + lastReadAt: channel.lastReadAt?.toISOString() ?? null, }; } diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts index 617ba97f..f8869b26 100644 --- a/server/src/db/schema/core.ts +++ b/server/src/db/schema/core.ts @@ -308,6 +308,11 @@ export const channelMemberships = pgTable( * one person's marker, and the membership row is already the per-member half of a channel. */ pinnedAt: timestamp("pinned_at", { withTimezone: true }), + /** + * When this member last had the channel open, or null for never. On the membership like the + * pin: reading is one person's act, and the unread marker it feeds is that person's alone. + */ + lastReadAt: timestamp("last_read_at", { withTimezone: true }), createdAt: createdAt(), }, (table) => [primaryKey({ columns: [table.channelId, table.userId] })], diff --git a/server/tests/channel-activity.integration.test.ts b/server/tests/channel-activity.integration.test.ts index 5b0b155c..a592eb88 100644 --- a/server/tests/channel-activity.integration.test.ts +++ b/server/tests/channel-activity.integration.test.ts @@ -228,6 +228,7 @@ describe("channel activity", () => { lastMessageAt: at, createdAt: expect.any(Date), pinned: false, + lastReadAt: null, }, ]); }); diff --git a/server/tests/channel-routes.test.ts b/server/tests/channel-routes.test.ts index a43275b0..7019b1f9 100644 --- a/server/tests/channel-routes.test.ts +++ b/server/tests/channel-routes.test.ts @@ -79,6 +79,9 @@ function fakeStore( async setPinned(receivedActor, id, pinned) { calls.push(["setPinned", receivedActor, id, pinned]); }, + async markRead(receivedActor, id) { + calls.push(["markRead", receivedActor, id]); + }, async softDelete(receivedActor, id) { calls.push(["softDelete", receivedActor, id]); }, @@ -360,6 +363,45 @@ describe("channel routes", () => { expect(store.calls).toEqual([]); }); + test("marks read through the authenticated actor and answers 204", async () => { + const store = fakeStore(); + const response = await appFor(store).request( + "http://openbot.test/channel-1/read", + { method: "PUT" }, + ); + + expect(response.status).toBe(204); + expect(store.calls).toEqual([["markRead", actor, "channel-1"]]); + }); + + test("maps an unknown channel to 404 for marking read", async () => { + const store = fakeStore({ + markRead: async () => { + throw new ChannelNotFoundError("channel-1"); + }, + }); + const response = await appFor(store).request( + "http://openbot.test/channel-1/read", + { method: "PUT" }, + ); + + expect(response.status).toBe(404); + expect(await json(response)).toEqual({ error: "Channel not found." }); + }); + + test("keeps authentication in front of marking read", async () => { + const store = fakeStore(); + const denied: MiddlewareHandler<{ Variables: AppVariables }> = (context) => + Promise.resolve(context.json({ error: "denied" }, 401)); + const response = await appFor(store, denied).request( + "http://openbot.test/channel-1/read", + { method: "PUT" }, + ); + + expect(response.status).toBe(401); + expect(store.calls).toEqual([]); + }); + test("deletes through the authenticated actor and answers 204", async () => { const store = fakeStore(); const response = await appFor(store).request( @@ -1115,6 +1157,127 @@ describe("channel pinning", () => { }); }); +describe("channel read markers", () => { + // Two members of one channel, which is what a per-member marker has to be tested against. + async function sharedChannel() { + const reader = await createPersistentUser(); + const other = await createPersistentUser(); + const agentId = await createPersistentAgent({ + name: "Shared readable agent", + owner: reader, + visibility: "public", + }); + const created = await persistentStore.create(reader, [agentId]); + createdChannelIds.push(created.id); + // The store only creates the creator's membership; give the other user one directly, + // plus the thread mapping the list join requires. + await database.insert(channelMemberships).values({ + channelId: created.id, + userId: other.id, + }); + await database.insert(intelligenceChannelMappings).values({ + userId: other.id, + channelId: created.id, + // thread_id is globally unique; the reader's own mapping row already claimed + // created.threadId, so the other member's row needs one of its own. + threadId: randomUUID(), + }); + return { reader, other, channelId: created.id }; + } + + test("stamps last_read_at on the caller's own membership only", async () => { + const { reader, other, channelId } = await sharedChannel(); + + await persistentStore.markRead(reader, channelId); + + const rows = await database + .select({ + userId: channelMemberships.userId, + lastReadAt: channelMemberships.lastReadAt, + }) + .from(channelMemberships) + .where(eq(channelMemberships.channelId, channelId)); + expect( + rows.find((row) => row.userId === reader.id)?.lastReadAt, + ).not.toBeNull(); + expect(rows.find((row) => row.userId === other.id)?.lastReadAt).toBeNull(); + }); + + test("the list carries the caller's lastReadAt and nobody else's", async () => { + const { reader, other, channelId } = await sharedChannel(); + + await persistentStore.markRead(reader, channelId); + + const forReader = await persistentStore.list(reader); + const forOther = await persistentStore.list(other); + expect( + forReader.channels.find((channel) => channel.id === channelId) + ?.lastReadAt, + ).not.toBeNull(); + expect( + forOther.channels.find((channel) => channel.id === channelId)?.lastReadAt, + ).toBeNull(); + }); + + test("refuses to mark read a channel the caller is not a member of", async () => { + const { channelId } = await sharedChannel(); + const outsider = await createPersistentUser(); + + await expect( + persistentStore.markRead(outsider, channelId), + ).rejects.toBeInstanceOf(ChannelNotFoundError); + }); + + test("stamps a read no earlier than the channel's own last-message clock", async () => { + const { reader, channelId } = await sharedChannel(); + // last_message_at is written from the reporting browser's clock and is not bounded; simulate + // one running ahead of the server so a plain "now" stamp would still read as unseen. + const future = new Date(Date.now() + 60_000); + await database + .update(channels) + .set({ lastMessageAt: future }) + .where(eq(channels.id, channelId)); + + await persistentStore.markRead(reader, channelId); + + const [row] = await database + .select({ lastReadAt: channelMemberships.lastReadAt }) + .from(channelMemberships) + .where( + and( + eq(channelMemberships.channelId, channelId), + eq(channelMemberships.userId, reader.id), + ), + ); + expect(row?.lastReadAt).not.toBeNull(); + expect(row?.lastReadAt?.getTime() ?? 0).toBeGreaterThanOrEqual( + future.getTime(), + ); + }); + + test("refuses to mark a soft-deleted channel read, mirroring setPinned", async () => { + const { reader, channelId } = await sharedChannel(); + + await persistentStore.softDelete(reader, channelId); + + await expect( + persistentStore.markRead(reader, channelId), + ).rejects.toBeInstanceOf(ChannelNotFoundError); + + const [row] = await database + .select({ lastReadAt: channelMemberships.lastReadAt }) + .from(channelMemberships) + .where( + and( + eq(channelMemberships.channelId, channelId), + eq(channelMemberships.userId, reader.id), + ), + ); + // The membership row outlives the channel, but its marker was never stamped. + expect(row?.lastReadAt).toBeNull(); + }); +}); + describe("channel soft delete", () => { test("hides a deleted channel from list and get", async () => { const actor = await createPersistentUser(); From b94138548a3f55869434cf22a49d6b1f0e5fb278 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:13:25 -0700 Subject: [PATCH 04/13] Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c79e8f1..a5550af0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 with: version: v3.19.0 # For the coherence check below, which is a Bun script like everything else here. From a4549bee80a495db8deb73a815ad6f85e5236710 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:43:41 +0530 Subject: [PATCH 05/13] Say what a strict content-security-policy has to allow (#225) --- docs/deployment.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/deployment.md b/docs/deployment.md index 830827d0..14164094 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -135,3 +135,9 @@ which makes them the shortest path from nothing to a running deployment. **The image is 5.3 GB**, most of it the Playwright base, which ships Firefox and WebKit alongside the Chromium we use. Deleting them afterwards does not help, because the bytes still ship in the layer below. Building Chromium-only onto a slim base would cut this substantially and is not done yet. + +**A strict content-security-policy needs a hash or a nonce.** `app/index.html` runs a small inline +script that decides the theme before the first paint. Nothing in this repo sends a CSP header, so it +works as shipped; a deployment that adds one at its proxy has to allow that script explicitly, or +`script-src` blocks it and the page renders with the wrong theme until the app boots. A `'sha256-'` +hash of the script body is the version that survives a rebuild without a per-request nonce. From 951d20f62e2dbc2b097ff32c57da07b85cb18401 Mon Sep 17 00:00:00 2001 From: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:43:44 +0530 Subject: [PATCH 06/13] Point the test at the database the project actually has (#234) --- server/tests/server-side-tools.integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/tests/server-side-tools.integration.test.ts b/server/tests/server-side-tools.integration.test.ts index d0fbffd4..eb5c58bc 100644 --- a/server/tests/server-side-tools.integration.test.ts +++ b/server/tests/server-side-tools.integration.test.ts @@ -31,7 +31,7 @@ import { TEST_POOL } from "./support/database"; const database = createDatabase( process.env.DATABASE_URL ?? - "postgres://openkai:openkai@localhost:5432/openkai", + "postgres://openbot:openbot@localhost:5432/openbot", TEST_POOL, ); From c0638c7ad19b818ca90ed513653b1994ba35a83f Mon Sep 17 00:00:00 2001 From: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:45:19 +0530 Subject: [PATCH 07/13] Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) --- CHANGELOG.md | 19 ++++++++ charts/openbot/templates/networkpolicy.yaml | 50 +++++++++++++++++++-- charts/openbot/values.yaml | 10 +++++ scripts/check-rendered-chart.ts | 28 ++++++++++++ 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bdc7671..98fc7192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,25 @@ the membership row like the pin — so one person reading does not clear anybody The deployment gains one nullable column, via migration `0019`. +### The API can reach Intelligence and sign-in when a NetworkPolicy is on + +`networkPolicy.enabled` wrote a rule for the API server that named DNS, the database and the Bots' +computers, and nothing on 443. On a cluster that enforces policy the server could therefore reach +neither CopilotKit Intelligence, nor an identity provider, nor a Bot: nobody could sign in and no +conversation ran. Two of the five shipped `ci/` targets turn the policy on, and on GKE enforcement is +the default and cannot be switched off. + +Nothing said so. The pod passed every probe and stayed Ready, because `/health` answers from a +literal, so the first evidence was a timeout to a hostname that read as the internet being down. + +The API now reaches HTTP and HTTPS everywhere outside the cluster's private ranges, in every +`computers.mode` rather than only `sandbox`, cut by the same exception list the computers' own policy +uses. It still cannot address another pod, a node, or a cloud metadata endpoint. + +`mode: sandbox` had been working only because a rule meant for the Kubernetes API server carried no +destination and so permitted everything. That rule now covers the API server alone, and +`networkPolicy.kubernetesApiCidr` narrows it to your cluster's service range; left empty it stays as +it was, because a chart cannot know that range. ### A finished turn shows the page it opened, not the one open now diff --git a/charts/openbot/templates/networkpolicy.yaml b/charts/openbot/templates/networkpolicy.yaml index 973a093e..d188a428 100644 --- a/charts/openbot/templates/networkpolicy.yaml +++ b/charts/openbot/templates/networkpolicy.yaml @@ -7,8 +7,16 @@ Off by default, because a NetworkPolicy on a cluster with no CNI that enforces o that silently does nothing, and on a cluster that does enforce one a wrong rule is an outage. A deployment that turns this on is saying it knows which of the two it has. -Egress deliberately allows DNS and the database, and nothing else without being asked: a Bot's -computer reaching the open internet is the computers' own policy, not the API's. +Egress allows DNS, the database, the computers, and HTTP and HTTPS to everywhere that is not the +cluster's own private network. THAT LAST ONE IS NOT A CONCESSION, it is what this pod does: sign-in +goes to an identity provider, every conversation goes to Intelligence, and every run goes to a Bot +at an address somebody registered. All three are hostnames rather than CIDRs, and a NetworkPolicy +cannot match a hostname, so there is no narrower rule to write. Leaving it out did not fence the API +off, it stopped the product working, and only in `computers.mode: sandbox` did a rule meant for the +Kubernetes API server quietly cover for it. + +The private ranges stay cut out by exception, the way the computers' policy does it, so this is +still a pod that cannot address another pod, a node, or a cloud metadata endpoint. */}} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy @@ -62,9 +70,43 @@ spec: - port: 4100 protocol: TCP {{- end }} + {{- /* + Intelligence, the identity provider, and every Bot: all of them, and all outside the cluster. + + Written as an exception list rather than as a destination list because the destinations are + hostnames the deployment configures and a NetworkPolicy matches addresses. The same shape the + computers' policy below already uses, for the same reason. + */}} + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + # The cluster and everything else on the private network, including the database. + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + # Link-local, which is where every cloud keeps the endpoint that hands out credentials. + - 169.254.0.0/16 + ports: + - port: 80 + protocol: TCP + - port: 443 + protocol: TCP {{- if eq .Values.computers.mode "sandbox" }} - {{- /* The API server, which is where a per-Bot computer is asked for. */}} - - ports: + {{- /* + The Kubernetes API server, which is where a per-Bot computer is asked for. + + Its own rule because it sits on the private network the rule above cuts out, so nothing else + here reaches it. Unscoped unless a deployment says otherwise: the API server answers on a + ClusterIP from the service range, and a chart cannot know that range at template time. Name it + in `networkPolicy.kubernetesApiCidr` and this narrows to it. + */}} + - {{- with .Values.networkPolicy.kubernetesApiCidr }} + to: + - ipBlock: + cidr: {{ . }} + {{- end }} + ports: - port: 443 protocol: TCP - port: 6443 diff --git a/charts/openbot/values.yaml b/charts/openbot/values.yaml index da8e7596..dbb42f81 100644 --- a/charts/openbot/values.yaml +++ b/charts/openbot/values.yaml @@ -365,8 +365,18 @@ httpRoute: networkPolicy: enabled: false # Where the API may reach out to. A deployment with a managed database adds its CIDR here. + # + # It already reaches HTTP and HTTPS everywhere outside the cluster's private ranges, because that + # is where Intelligence, sign-in and the Bots are. This is for anything on the private side. extraEgress: [] extraIngress: [] + # `computers.mode: sandbox` only. The service range the Kubernetes API server answers on, so the + # rule that lets the API ask for a Bot's computer can name it instead of being left open. + # + # Empty means unscoped, which is the only thing a chart can do by default: the range is the + # cluster's, not the release's. `kubectl get svc kubernetes -o jsonpath='{.spec.clusterIP}'` shows + # which one yours is on; on EKS it is usually 172.20.0.0/16, on GKE and kubeadm 10.96.0.0/12. + kubernetesApiCidr: "" # Where a Bot's computer may reach beyond the public internet. A deployment whose Bots must reach # an internal site adds it here, one address at a time, rather than reopening the private ranges. computerExtraEgress: [] diff --git a/scripts/check-rendered-chart.ts b/scripts/check-rendered-chart.ts index 12e68145..dc5de112 100644 --- a/scripts/check-rendered-chart.ts +++ b/scripts/check-rendered-chart.ts @@ -127,6 +127,33 @@ for (const [name, keys] of written) { } } +/** + * A policy that fences the API off from the services it cannot work without. + * + * The same question as the Secret one above, asked of the other thing a render can be internally + * wrong about: this chart requires CopilotKit Intelligence and an identity provider, reaches both + * over HTTPS at hostnames, and also writes the rule that says where the API may go. Those two had + * never been compared. The server's egress named DNS, the database and the computers, so on any + * cluster that enforces policy nobody could sign in and no conversation ran — and the pod stayed + * Ready throughout, because `/health` answers from a literal. + * + * Asked of the rendered object rather than the template, because the rule that covered for this was + * conditional on `computers.mode` and only one mode ever had it. + */ +const serverPolicy = documents.find( + (document) => + /^kind:\s*NetworkPolicy\s*$/m.test(document) && + /app\.kubernetes\.io\/component:\s*server/.test(document), +); +if (serverPolicy) { + const egress = serverPolicy.split(/^\s{2}egress:\s*$/m)[1] ?? ""; + if (!/port:\s*443\b/.test(egress)) { + problems.push( + "The server's NetworkPolicy has no egress on 443, so the API cannot reach Intelligence or an identity provider. Nothing would report it: /health answers from a literal and every probe reads it.", + ); + } +} + if (problems.length > 0) { for (const problem of problems) console.error(`::error::${problem}`); process.exit(1); @@ -134,6 +161,7 @@ if (problems.length > 0) { console.log( `${documents.length} objects, ${demands.length} secret keys demanded, and every required one is written.` + + (serverPolicy ? " The server's egress reaches 443." : "") + (skippedOptional > 0 ? ` ${skippedOptional} optional key${skippedOptional === 1 ? " was" : "s were"} not checked.` : ""), From cbab27edce060ddf2b9462c47922f31f7b85bf0c Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:16:25 -0500 Subject: [PATCH 08/13] Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --- CHANGELOG.md | 26 +++++++ server/src/computer/target.ts | 20 ++++- server/src/plugins/catalogue.ts | 98 +++++++++++++++++++++++ server/tests/plugin-catalogue.test.ts | 108 ++++++++++++++++++++++++++ 4 files changed, 248 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fc7192..c16b435c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -243,6 +243,32 @@ this port has to reach it another way**, which is what publishing it on every in This does not reach back in time. A deployment that has been running with the two on one network should assume a Bot could have read or written the database, and look at the trail with that in mind. +### A credential in an MCP server address is refused in the query and the fragment too + +Refusing `https://user:token@vendor.example/mcp` closed the userinfo spelling of a credential in the +address and left the two obvious ones open. `?token=`, `?api_key=` and their neighbours were still +accepted, and the address is stored and named in the trail exactly as given: audit redaction keys on +the field name, `url` is not a sensitive one, so the secret was written to `mcp_servers` and to an +append-only audit row in clear text. That is the same disclosure the userinfo rule exists to prevent, +one character away. + +A parameter whose name reads as a credential is now refused, in the query string and in the fragment, +and the refusal points at the token field without repeating what was typed. The name is read rather +than matched against a list, so `?auth_token=`, `?x-api-key=` and `?X-Amz-Signature=` are refused +alongside `?token=`: a rule that only catches the spellings somebody thought of reads as a guard +while behaving like a gap. The test is on the parameter name rather than on the presence of a query, +because vendors route and version with parameters and a floor that refused every one of them would +be one an operator works around instead of with. `https://mcp.example.com/mcp?workspace=acme&version=2` +is unaffected, and so is an ordinary fragment. A credential written into the *path* is still +accepted: it is indistinguishable from a route, and at least one hosted provider addresses servers +that way. **A deployment where somebody has put a credential in an address should treat it as +disclosed and rotate it**, for the same reason as before: the audit row cannot be deleted. + +`metadata.goog` is refused too. It is Google's own short name for the metadata server, published +beside `metadata.google.internal`, and it carries a dot and none of the suffixes this check lists, so +it read as an ordinary vendor name. The long spelling was only ever refused incidentally, by the +`.internal` rule. Both are now named, so the address this check was written for is refused on purpose +rather than by luck. ### Name the private addresses an agent may live at diff --git a/server/src/computer/target.ts b/server/src/computer/target.ts index a0874141..0304b9a9 100644 --- a/server/src/computer/target.ts +++ b/server/src/computer/target.ts @@ -39,6 +39,20 @@ const NEVER_ALLOWED_HOSTNAMES = new Set([ "100.100.100.200", ]); +/** + * Is this the address of a cloud metadata service? + * + * Exported because the same question is asked outside browsing: an MCP server address an + * administrator types is refused on the same grounds, and the answer has to come from one list. + * Two copies drift, and the copy that misses an alias is the one that lets a credential endpoint + * through. + * + * Canonicalises first, so the trailing-dot and IPv6 spellings are seen through here as well. + */ +export function isNeverAllowedHostname(hostname: string): boolean { + return NEVER_ALLOWED_HOSTNAMES.has(canonicalHostname(hostname.toLowerCase())); +} + /** Hostnames inside the deployment. Reachable only when a deployment opts in. */ const INTERNAL_HOSTNAMES = new Set([ "localhost", @@ -204,9 +218,7 @@ export function checkComputerAddress(raw: string): TargetVerdict { // Canonicalised for the same reason navigation is: the address reaches a fetch either way, so the // spellings that gate has to see through are the spellings this one has to see through. - if ( - NEVER_ALLOWED_HOSTNAMES.has(canonicalHostname(url.hostname.toLowerCase())) - ) { + if (isNeverAllowedHostname(url.hostname)) { return { allowed: false, reason: @@ -244,7 +256,7 @@ export function checkNavigationTarget( const hostname = canonicalHostname(url.hostname.toLowerCase()); // Checked before the opt-in, so no configuration can reach it. - if (NEVER_ALLOWED_HOSTNAMES.has(hostname)) { + if (isNeverAllowedHostname(hostname)) { return { allowed: false, reason: diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index 420148a5..e6ca47b0 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -26,6 +26,9 @@ * These are where this deployment sends a person's authorization code and receives the refresh * token that stands in for their access, so they are a reviewed source contract too. */ +// The one place browsing and this check agree on: the addresses that hold the deployment's own +// cloud credentials. `target.ts` imports nothing itself, so asking it here adds no dependency. +import { isNeverAllowedHostname } from "../computer/target"; // Type-only, so naming the transport here creates no import cycle with the registry that resolves it. import type { TransportKind } from "./transport"; @@ -330,6 +333,61 @@ export function classifyTool( return entry.writeTools.includes(toolName) ? "write" : "read"; } +/** + * Words that make a parameter name a credential, wherever they appear in it. + * + * A containment test rather than a list of exact names, because the exact-name version of this rule + * refused `?token=` and accepted `?auth_token=`, `?api_token=`, `?session_token=` and every other + * spelling one word away. An operator has no way to know which of those the check happens to hold, + * so a rule that only refuses the names somebody thought of reads as a guard while behaving like a + * gap. + * + * Not shared with `sensitiveKeys` in `audit.ts`: that module reaches the database and this function + * deliberately imports nothing that does. The two also want different contents, since audit redacts + * `content`, `prompt` and `result`, which are payload field names and mean nothing here. + */ +const CREDENTIAL_WORDS = [ + "token", + "secret", + "password", + "passwd", + "credential", + "signature", + "bearer", +]; + +/** + * Names that are a credential on their own but are too short to contain safely. + * + * `sig` is the reason this list is separate from the one above: "design" contains it. These are + * compared whole, so an ordinary word carrying the same three letters is left alone. + */ +const CREDENTIAL_NAMES = new Set([ + "auth", + "authorization", + "pass", + "pwd", + "sig", +]); + +/** + * Does this parameter name say it holds a credential? + * + * Names are compared with their separators dropped, so `api_key`, `apiKey` and `x-api-key` are one + * question rather than three. A name ending in "key" is a credential and a name merely containing it + * is not, which is what keeps `keyword` and `monkey` apart; "author" is likewise not "auth". + * + * It over-refuses in one direction on purpose. A parameter this rule misreads costs an operator a + * rename, and one it misses is written to an append-only audit row that cannot be deleted. + */ +function readsAsCredential(name: string): boolean { + const normalized = name.replaceAll(/[^a-zA-Z0-9]/g, "").toLowerCase(); + if (CREDENTIAL_NAMES.has(normalized) || normalized.endsWith("key")) { + return true; + } + return CREDENTIAL_WORDS.some((word) => normalized.includes(word)); +} + /** * Is this a URL an administrator may point the deployment at? * @@ -366,6 +424,32 @@ export function customUrlRefusal(raw: string): string | null { return "Put the credential in the token field rather than in the address."; } + /* + * The query is the other half of the same hole, and the fragment is the half after that. + * + * No host rule below reads either one, and both are stored and audited with the rest of the + * string, so a token written here is as durable and as readable as one written into the userinfo. + * The fragment never reaches the server at all, which is why it is not a request-forgery concern + * and is still a disclosure one: what this rule is about is where the string ends up, not where + * the request goes. + * + * The test is on the parameter name rather than on the presence of a query, because vendors + * legitimately route and version with parameters. A floor that refused every one of them would be + * one an operator works around rather than with, and an ordinary `#section` is left alone for the + * same reason. + */ + const hash = url.hash.replace(/^#/, ""); + const marker = hash.indexOf("?"); + const fragment = + marker === -1 ? [hash] : [hash.slice(0, marker), hash.slice(marker + 1)]; + const named = [ + ...url.searchParams.keys(), + ...fragment.flatMap((part) => [...new URLSearchParams(part).keys()]), + ]; + if (named.some(readsAsCredential)) { + return "Put the credential in the token field rather than in the address."; + } + // A trailing dot is the root-anchored spelling of the same name and resolves to the same place, so // they are stripped here rather than added to each comparison below. Without it "localhost." // misses the equality test, "vault.internal." misses the suffix tests, and "database." picks up @@ -377,6 +461,20 @@ export function customUrlRefusal(raw: string): string | null { if (host.includes(":") || /^[0-9.]+$/.test(host)) { return "Give a hostname rather than an IP address."; } + /* + * The cloud metadata endpoint, by name rather than by luck. + * + * `metadata.goog` is Google's own short alias for it, published beside `metadata.google.internal`, + * and it carries a dot and none of the suffixes below, so it read as an ordinary vendor name. The + * long spelling was refused only incidentally, by the `.internal` test. + * + * Asked of the list browsing already uses rather than a second copy here. That list holds the + * aliases somebody has already had to think about, including the ones Alibaba and ECS answer on, + * and a new alias added there should not have to be remembered here as well. + */ + if (isNeverAllowedHostname(host)) { + return "That address holds this deployment's own cloud credentials."; + } if (host === "localhost" || host.endsWith(".localhost")) { return "That address is local to the deployment."; } diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index e98a6213..b0acfe29 100644 --- a/server/tests/plugin-catalogue.test.ts +++ b/server/tests/plugin-catalogue.test.ts @@ -342,6 +342,114 @@ describe("a URL an administrator typed", () => { expect(refusal).not.toContain("oauth"); }); + test("a credential in the query string is refused", () => { + // The same harm as the userinfo case above, reached through the other part of the URL no host + // rule looks at. addCustomServer writes the string it was given into mcp_servers.url and into + // the configuration.changed audit payload, audit redaction keys on the field name, and "url" is + // not a sensitive name, so a token here sits in an append-only trail in clear text. + expect( + customUrlRefusal("https://mcp.example.com/mcp?token=sk-live-abcdef"), + ).not.toBeNull(); + expect( + customUrlRefusal("https://mcp.example.com/mcp?api_key=SECRET"), + ).not.toBeNull(); + expect( + customUrlRefusal("https://mcp.example.com/mcp?access_token=SECRET"), + ).not.toBeNull(); + expect( + customUrlRefusal("https://mcp.example.com/mcp?client_secret=SECRET"), + ).not.toBeNull(); + }); + + test("the names a credential is actually given are refused too", () => { + // The first version of this rule listed exact names, which is a corner of the class rather than + // the class: every one of these was accepted while `?token=` was refused, and an operator does + // not know which spelling the check happens to hold. The match reads the name for what it says. + for (const name of [ + "auth_token", + "api_token", + "apiToken", + "access_key", + "secret_key", + "private_key", + "session_token", + "x-api-key", + "subscription-key", + "X-Amz-Signature", + "bearer", + "pwd", + ]) { + expect( + customUrlRefusal(`https://mcp.example.com/mcp?${name}=s3cret`), + ).not.toBeNull(); + } + }); + + test("an ordinary query parameter is still accepted", () => { + // The rule reads the parameter name, not the presence of a query, because vendors route and + // version with parameters. Refusing every query string would make this floor an outage rather + // than a guard, and an operator who cannot add a working server will find a way around it. + expect( + customUrlRefusal("https://mcp.example.com/mcp?workspace=acme&version=2"), + ).toBeNull(); + // The near misses, which are what a rule that reads names rather than matching them exactly has + // to get right: "keyword" is not a key and "author" is not auth. + expect( + customUrlRefusal("https://mcp.example.com/mcp?keyword=x&author=jane"), + ).toBeNull(); + }); + + test("refusing a credential in the query does not repeat it", () => { + // Same property as the userinfo refusal: this string is rendered to an administrator and can + // reach a log, so it must not carry the secret it exists to reject. + const refusal = customUrlRefusal( + "https://mcp.example.com/mcp?token=s3cret", + ); + expect(refusal).not.toBeNull(); + expect(refusal).not.toContain("s3cret"); + expect(refusal).not.toContain("mcp.example.com"); + }); + + test("a credential in the fragment is refused too", () => { + // The fragment never leaves the browser, but that is not the harm here. addCustomServer stores + // and audits the whole string, so a secret written after the hash is as durable and as readable + // as one in the query. Refusing one and not the other would leave the same bypass a character + // away. + expect( + customUrlRefusal("https://mcp.example.com/mcp#token=s3cret"), + ).not.toBeNull(); + // The shapes a fragment is actually written in. A hash route or an OAuth-style callback puts a + // path before the question mark, and reading the whole fragment as one query string turns all + // of it into a single name that matches nothing. + expect( + customUrlRefusal("https://mcp.example.com/mcp#/callback?token=s3cret"), + ).not.toBeNull(); + expect( + customUrlRefusal("https://mcp.example.com/mcp#!/x?token=s3cret"), + ).not.toBeNull(); + expect( + customUrlRefusal("https://mcp.example.com/mcp#token%3Ds3cret"), + ).not.toBeNull(); + // An ordinary fragment is not a credential and is left alone. + expect(customUrlRefusal("https://mcp.example.com/mcp#section")).toBeNull(); + }); + + test("the short name for the cloud metadata endpoint is refused", () => { + // metadata.goog is Google's own alias for the metadata server, published beside + // metadata.google.internal and 169.254.169.254. It carries a dot and none of the suffixes + // above, so it read as an ordinary vendor name, while the long spelling was caught only + // incidentally by the .internal test. + expect( + customUrlRefusal("https://metadata.goog/computeMetadata/v1/"), + ).not.toBeNull(); + expect( + customUrlRefusal("https://metadata.goog./computeMetadata/v1/"), + ).not.toBeNull(); + expect( + customUrlRefusal("https://METADATA.GOOG/computeMetadata/v1/"), + ).not.toBeNull(); + }); + test("nonsense is refused rather than thrown", () => { expect(customUrlRefusal("not a url")).toBe("That is not a URL."); }); From 8f68eaa42bfb51f6a22247070bb997d25d988c1e Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:21:27 -0500 Subject: [PATCH 09/13] Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --- CHANGELOG.md | 68 ++++ server/src/plugins/catalogue.ts | 15 + server/src/plugins/routes.ts | 7 +- server/src/plugins/store.ts | 195 +++++++++-- server/tests/plugin-catalogue.test.ts | 42 +++ ...gin-credential-binding.integration.test.ts | 328 ++++++++++++++++++ ...gin-curated-credential.integration.test.ts | 261 ++++++++++++++ .../tests/plugin-routes.integration.test.ts | 268 ++++++++++++++ server/tests/plugin-routes.test.ts | 105 ++++++ server/tests/plugin-store.integration.test.ts | 18 +- 10 files changed, 1279 insertions(+), 28 deletions(-) create mode 100644 server/tests/plugin-credential-binding.integration.test.ts create mode 100644 server/tests/plugin-curated-credential.integration.test.ts create mode 100644 server/tests/plugin-routes.integration.test.ts create mode 100644 server/tests/plugin-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c16b435c..805817b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -192,6 +192,46 @@ read that as a stolen token and revoke the whole connection. Every plugin call t token now locks the credential's vault row for the length of the exchange, so a second replica waits rather than races, and the rotated token is written back in the same transaction that held the lock. Nothing to configure; a connection just stops going stale under concurrent traffic. + +### An MCP token is spent only by its own server, and only at the address it was given + +Pointing a server at a credential is the one place this deployment takes a reference to a stored +secret rather than the secret itself. Everywhere else, the value was typed into the same request that +stores it: a Bot's key is minted from what an administrator pasted and the id it gets is nobody's to +choose. So this is the one field where which secret and which address could be made to disagree, and +the add settles the disagreement by spending the credential: the tool refresh runs before the call +returns and sends what it decrypts to the URL from that same request. + +Two ways they could disagree, and both are now refused. A server could be pointed at any `mcp` +credential in the vault, including one minted for a different vendor, so a token given to one server +was deliverable to another. And re-adding a server with a different URL rewrote the address while +keeping the credential, so the same token could be sent somewhere else entirely with no +cross-server trick at all: the token really did belong to that server, and only the address moved. + +The second is why the first was not enough on its own. A credential now has to belong to the server +it is attached to, and a server that already holds one cannot be re-added at a different address. +Correcting a title or retrying an interrupted add sends the same URL and is unaffected. A server +holding no credential can still be re-addressed, because there is nothing to misdirect. Moving a +server that does hold one means removing it and adding it again with the token the new address is +meant to have, which is the honest description of what has happened anyway. + +This matters more than "an administrator could misconfigure something". A stored credential cannot +be read back by anybody, by design: the credentials screen answers that a credential exists and +never what it is. These two shapes were the way around that, so a deployment where somebody has +used them should treat the credentials involved as disclosed and rotate them. + +A token also stops outliving the server it was minted for. Re-adding a server without naming a +credential used to clear the pointer while leaving the credential live, and removing a server retires +its token by reading it off that pointer, so a cleared one meant the token survived its server and +could be attached to a freshly created one at any address, where there was no longer a stored address +to compare against. Three ordinary acts in a row and the binding above stopped meaning anything. The +pointer now survives a re-add that names none, removal therefore finds and retires it, and a retired +credential is refused rather than quietly attached to fail on its next call. + +Curated servers keep working as they did. Their URL comes from the catalogue rather than the +request, and a per-instance hostname is matched against the vendor's own anchored pattern before +anything is stored, so re-adding one cannot point it at an address of the caller's choosing. + ### Knowledge searches instead of guessing A package can say which of its skills each coworker gets, and the fintech example gives Knowledge the @@ -269,6 +309,34 @@ beside `metadata.google.internal`, and it carries a dot and none of the suffixes it read as an ordinary vendor name. The long spelling was only ever refused incidentally, by the `.internal` rule. Both are now named, so the address this check was written for is refused on purpose rather than by luck. +### A curated MCP server is pointed at its own kind of credential too + +Adding a server by URL was made to check which credential it is being pointed at. Adding one from the +catalogue, the other half of the same screen, took the same field from the same request and stored it +unread, so a credential of any kind could be attached to a curated server and spent by the refresh +that runs before the add returns. + +Worth being plain about the reach, because it is narrower than the path beside it. The column is a +foreign key, so an id naming nothing was already refused by the database, and the one entry in the +catalogue is reached with each person's own Google account, whose OAuth client is registered through +its own call and sent to an address pinned in code. Nothing could be delivered to an address a caller +chose. What was reachable was a credential of the wrong kind being accepted and spent on behalf of +somebody who never agreed to it, and a malformed id arriving as a database error rather than as a +refusal. + +The rule now comes from the entry: a server the deployment holds one token for takes that token, and +a server answered as the person asking takes no credential when it is added, because its client +arrives through the call that mints it. Both add paths ask the same question in the same words, so a +credential that does not exist and one of the wrong kind are still refused identically and the +endpoint cannot be used to ask which ids are real. Adding a curated server the way the admin screen +does is unchanged. + +Adding a curated server that is already there no longer clears the credential it points at. The +column holds the OAuth client that registering one put there, and re-adding the server to change an +instance host said nothing about that client, but cleared it anyway: the credential row was left +behind with nothing pointing at it and nothing to revoke it, and everybody who had connected their +account was told the deployment has no client registered. A re-add that names no credential now +leaves the one that is there alone. ### Name the private addresses an agent may live at diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index e6ca47b0..f4fd2d3e 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -266,6 +266,21 @@ const PATTERNS = new Map( ]), ); +/** + * Which kind of credential this entry's server record may be pointed at, or null when it takes none + * from the caller. + * + * Beside the entry rather than at the call site, because it is a property of the vendor's auth and + * not of the request. `deployment-bearer` is the only kind that means "one token this deployment + * holds for this server", which is what `mcp` names in the vault. A `user-oauth` server is answered + * with the asker's own grant and its OAuth client is registered through its own call, which mints + * the credential itself, so an id offered when the server is added is never the right one whatever + * kind it names. A server needing no credential takes none. + */ +export function serverCredentialKind(entry: CatalogueEntry): "mcp" | null { + return entry.auth.kind === "deployment-bearer" ? "mcp" : null; +} + export function catalogueEntry(key: string): CatalogueEntry | null { return BY_KEY.get(key) ?? null; } diff --git a/server/src/plugins/routes.ts b/server/src/plugins/routes.ts index 0c62c9ee..30d73294 100644 --- a/server/src/plugins/routes.ts +++ b/server/src/plugins/routes.ts @@ -202,7 +202,12 @@ export function createPluginRoutes( }); return context.json({ server }); } catch (error) { - if (error instanceof CatalogueEntryUnknownError) { + // A refused credential is the administrator's mistake to correct, so it comes back as a + // refusal with its reason rather than as a 500 the way an unmapped throw would. + if ( + error instanceof CatalogueEntryUnknownError || + error instanceof CustomServerRefusedError + ) { return context.json({ error: error.message }, 400); } throw error; diff --git a/server/src/plugins/store.ts b/server/src/plugins/store.ts index 61a27974..958c29f9 100644 --- a/server/src/plugins/store.ts +++ b/server/src/plugins/store.ts @@ -32,6 +32,7 @@ import { classifyTool, customUrlRefusal, resolveServerUrl, + serverCredentialKind, } from "./catalogue"; import { McpServerError } from "./mcp"; import { registerDynamicClient } from "./oauth"; @@ -1373,6 +1374,84 @@ export function createPluginStore(options: PluginStoreOptions) { } } + /** + * The credential a server is being pointed at is of the kind that server can spend. + * + * Both add paths dereference the pointer before they return, so this is checked where the pointer + * is accepted rather than where it is used. `mcp` is the only kind that answers "this server's own + * token". A `mcp_user_token` is one person's grant and a `mcp_oauth_client` identifies the + * deployment to a vendor; spending either here uses a credential on behalf of somebody who never + * agreed to it, which is the same objection `POST /api/admin/credentials` already makes when it + * refuses to mint those two by hand. + * + * The shape is checked before the lookup because `credentials.id` is a `uuid` column, so a value + * that is not one makes the query itself fail rather than return no rows, and the caller gets a + * database error where a refusal belongs. + * + * One message for both "wrong kind" and "no such credential", deliberately. A caller who can tell + * those apart can ask this endpoint which credential ids are real. + */ + async function requireCredentialOfKind( + serverTitle: string, + serverId: string, + credentialId: string, + kind: "mcp" | null, + ): Promise { + /* + * A server that takes no credential when it is added is refused here rather than at the caller, + * so that offering an id is one question with one answer wherever it is asked. The wording says + * what is true of both kinds that reach it: a `user-oauth` server's client arrives through the + * call that mints it, and a server needing no credential has nothing to be given. + */ + if (!kind) { + throw new CustomServerRefusedError( + `${serverTitle} takes no credential when it is added.`, + ); + } + + const looksLikeId = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + credentialId, + ); + /* + * Live, as well as the right kind and the right owner. + * + * A revoked credential cannot be decrypted, so attaching one only ever produced a server that + * fails on its next call. Refusing it here says so at the moment somebody can still act on it, + * and it closes the case where a token was retired precisely because it should stop being used. + */ + const [named] = looksLikeId + ? await database + .select({ + kind: credentialRows.kind, + provider: credentialRows.provider, + }) + .from(credentialRows) + .where( + and( + eq(credentialRows.id, credentialId), + isNull(credentialRows.revokedAt), + ), + ) + : []; + + /* + * Whose it is, as well as what it is. + * + * `provider` is the server a token was minted for: `storeMcpToken` sets it to the server id and + * is the only way the plugins screen makes one. Without this, any `mcp` row in the vault could + * be attached to any server, and since the refresh spends it against that server's address, a + * token given to one vendor was deliverable to another. Reading a credential back is otherwise + * impossible by design, so this closes the one field that accepts a reference to a secret rather + * than the secret itself. + */ + if (named?.kind !== kind || named.provider !== serverId) { + throw new CustomServerRefusedError( + "That is not a credential this server can use. Add the server's own token instead.", + ); + } + } + async function requireServer(serverId: string) { const [row] = await database .select() @@ -1411,6 +1490,27 @@ export function createPluginStore(options: PluginStoreOptions) { const resolved = resolveServerUrl(input.key, input.instanceHost); if (!resolved) throw new CatalogueEntryUnknownError(input.key); + /* + * The pointer is checked here for the same reason it is on the path below: the refresh that + * runs before this returns dereferences whatever it names. + * + * What that reaches is narrower on this path, because the URL is the catalogue's rather than + * the caller's, so a credential cannot be delivered to an address somebody chose. That is a + * property of today's catalogue rather than of this function: the one entry it holds is + * `user-oauth`, and the catalogue's own comment invites a fork to re-add the vendors that were + * taken out. The first `deployment-bearer` entry restores the full shape, so the check belongs + * here now rather than in the review that re-adds one. + */ + const credentialId = input.credentialId?.trim() || undefined; + if (credentialId) { + await requireCredentialOfKind( + resolved.entry.title, + resolved.entry.key, + credentialId, + serverCredentialKind(resolved.entry), + ); + } + await database .insert(mcpServers) .values({ @@ -1418,14 +1518,24 @@ export function createPluginStore(options: PluginStoreOptions) { title: resolved.entry.title, vendor: resolved.entry.vendor, url: resolved.url, - credentialId: input.credentialId ?? null, + credentialId: credentialId ?? null, addedBy: input.by, }) .onConflictDoUpdate({ target: mcpServers.id, set: { url: resolved.url, - credentialId: input.credentialId ?? null, + /* + * Left alone when the caller sends none, rather than cleared. + * + * `registerOAuthClient` keeps the client it minted in this column, and adding the server + * again to change an instance host is not a statement about that client. Clearing it + * orphaned the credential row, which nothing then revokes, and told everybody who had + * connected that the deployment has no OAuth client registered. There is no longer a way + * to hand it back through this call either, since a `user-oauth` entry now refuses a + * credential id, so the pointer has to survive here. + */ + ...(credentialId ? { credentialId } : {}), addedBy: input.by, updatedAt: new Date(), }, @@ -1504,30 +1614,51 @@ export function createPluginStore(options: PluginStoreOptions) { * One message for both "wrong kind" and "no such credential", deliberately. A caller who can * tell those apart can ask this endpoint which credential ids are real. */ + /* + * A credential is spent at the address it was given to, or not spent. + * + * Adding a server that is already here rewrites its URL, and the refresh that follows sends + * whatever credential it holds to the new one, in the same call. That is the same disclosure + * as naming another server's token and it needs no trick at all: the token really does belong + * to this server, and only the address moved. A check on whose credential it is cannot see it, + * which is why this rule is here and not folded into that one. + * + * Refused rather than repaired, because the two harmless readings of the request are both + * served by something else. Correcting a title or retrying an interrupted add sends the same + * URL and is unaffected, and genuinely moving a server means the vendor is at a new address, + * where the honest act is to remove it and add it again with the token that address is + * supposed to hold. + * + * Only this path. A curated server's URL comes from the catalogue rather than the request, so + * the most a caller can influence is an instance hostname, and that is matched against the + * vendor's own anchored pattern before anything is stored. Re-adding one cannot point it at an + * address of the caller's choosing, which is the whole of what this refuses. + */ const credentialId = input.credentialId?.trim() || undefined; + const [existing] = await database + .select({ url: mcpServers.url, credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, input.id)); + + if ( + existing && + existing.url !== input.url && + (existing.credentialId || credentialId) + ) { + throw new CustomServerRefusedError( + `${input.id} is already here at a different address and holds a credential. Remove it and add it again, with the token the new address is meant to have.`, + ); + } + if (credentialId) { - /* - * The shape is checked before the lookup because `credentials.id` is a `uuid` column, so a - * value that is not one makes the query itself fail rather than return no rows, and the - * caller gets a database error where a refusal belongs. The same was true of the foreign key - * before this guard existed. - */ - const looksLikeId = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - credentialId, - ); - const [named] = looksLikeId - ? await database - .select({ kind: credentialRows.kind }) - .from(credentialRows) - .where(eq(credentialRows.id, credentialId)) - : []; - - if (named?.kind !== "mcp") { - throw new CustomServerRefusedError( - "That is not a credential this server can use. Add the server's own token instead.", - ); - } + // Always `mcp`: a server added by URL is reached with the one token the deployment holds for + // it, whatever the vendor is, because nothing here knows the vendor. + await requireCredentialOfKind( + input.title, + input.id, + credentialId, + "mcp", + ); } await database @@ -1546,7 +1677,21 @@ export function createPluginStore(options: PluginStoreOptions) { set: { title: input.title, url: input.url, - credentialId: credentialId ?? null, + /* + * Kept when the caller names none, rather than cleared, for a reason beyond tidiness. + * + * Clearing it left the credential live with nothing pointing at it, and `removeServer` + * retires a token by reading it off the row: with the pointer gone it revoked nothing + * and deleted the server, so the token outlived the server it was minted for. It could + * then be attached to a freshly created server at any address, because the rule above + * compares against a row that no longer existed. Three ordinary acts, and the address + * this server was entrusted to stopped meaning anything. + * + * So the pointer survives, `removeServer` finds it, and a removed server's token is + * dead rather than loose. Detaching a token without removing the server is not a thing + * this endpoint does, and nothing asks it to. + */ + ...(credentialId ? { credentialId } : {}), addedBy: input.by, updatedAt: new Date(), }, diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index b0acfe29..9ba4dfd6 100644 --- a/server/tests/plugin-catalogue.test.ts +++ b/server/tests/plugin-catalogue.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test"; import { CATALOGUE, + type CatalogueEntry, catalogueEntry, classifyTool, customUrlRefusal, hostAdmissible, resolveServerUrl, + serverCredentialKind, } from "../src/plugins/catalogue"; /** @@ -454,3 +456,43 @@ describe("a URL an administrator typed", () => { expect(customUrlRefusal("not a url")).toBe("That is not a URL."); }); }); + +describe("which credential a curated server is given", () => { + /** + * A synthetic entry, because the catalogue holds one vendor today and it is `user-oauth`. + * + * The shared-token branch is the one a fork re-enables when it puts a removed vendor back, which + * is the case this rule exists for, so it is exercised here rather than left to be discovered + * then. The other side of the same argument is why the entry is written out in full rather than + * spread from a real one: what is under test is the auth kind deciding the answer. + */ + const sharedToken: CatalogueEntry = { + key: "shared-token-vendor", + title: "Vendor", + vendor: "Vendor", + summary: "A server the deployment holds one token for.", + host: "https://mcp.vendor.example", + path: "/mcp", + auth: { kind: "deployment-bearer" }, + writeTools: [], + docsUrl: "https://vendor.example/docs", + }; + + test("a shared-token server takes the deployment's own token for it", () => { + expect(serverCredentialKind(sharedToken)).toBe("mcp"); + }); + + test("a server reached as the asker takes no credential from the caller", () => { + // Its OAuth client arrives through registerOAuthClient, which mints the credential itself. An id + // offered here is therefore never the right one, whatever kind it names. + const drive = catalogueEntry("google-drive"); + expect(drive?.auth.kind).toBe("user-oauth"); + expect(serverCredentialKind(drive as CatalogueEntry)).toBeNull(); + }); + + test("a server that needs no credential takes none", () => { + expect( + serverCredentialKind({ ...sharedToken, auth: { kind: "none" } }), + ).toBeNull(); + }); +}); diff --git a/server/tests/plugin-credential-binding.integration.test.ts b/server/tests/plugin-credential-binding.integration.test.ts new file mode 100644 index 00000000..1427b6f8 --- /dev/null +++ b/server/tests/plugin-credential-binding.integration.test.ts @@ -0,0 +1,328 @@ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray, like } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { encryptSecret } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { credentials, mcpServers, mcpTools } from "../src/db/schema"; +import { + CustomServerRefusedError, + createPluginStore, +} from "../src/plugins/store"; +import { TEST_POOL } from "./support/database"; + +/** + * Which address a stored credential may be spent against, and whose it has to be. + * + * Pointing a server at a credential is the one place this deployment accepts a *reference* to a + * secret rather than the secret itself. Everywhere else that a stored value is spent, the value was + * typed into the same request that stores it: `storeAgentAuth` mints its own row from the key an + * administrator pasted and hands back an id nobody chose. So this is the field where "which secret" + * and "which address" can be made to disagree, and the add is what settles the disagreement, because + * the refresh runs before it returns and sends what it decrypts to the URL from that same request. + * + * Two rules, and the second is the one that matters. Naming another server's token was accepted, so + * a credential could be spent by a server it was never given to. And re-adding a server with a + * different URL rewrote the address while keeping the credential, so the same token could be sent + * somewhere else entirely without any cross-server trick at all. Closing only the first leaves the + * second, which is why they are one question here rather than two. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const KEY = `${"x".repeat(43)}=`; +const tag = randomUUID().slice(0, 8); +const serverId = `binding-${tag}`; +const otherServerId = `binding-other-${tag}`; +const ownCredentialId = randomUUID(); +const otherCredentialId = randomUUID(); +const OWN_TOKEN = `sk-own-${tag}`; +const OTHER_TOKEN = `sk-other-${tag}`; +const LEGITIMATE_URL = "https://legit.vendor.example/mcp"; +const CHOSEN_URL = "https://collector.attacker.example/mcp"; + +const store = createPluginStore({ + database, + auditStore: createAuditStore(database), + credentials: { + readSecret: async (id: string) => { + const [row] = await database + .select({ + encryptedValue: credentials.encryptedValue, + revokedAt: credentials.revokedAt, + }) + .from(credentials) + .where(eq(credentials.id, id)); + return row ?? null; + }, + create: async () => { + throw new Error("this suite does not write credentials"); + }, + /** + * A real revoke, unlike the other suites here, because the chain below turns on whether removing + * a server actually retires its token. Stubbing this to throw would make the test prove nothing + * about the case it exists for. + */ + revoke: async (id: string) => { + await database + .update(credentials) + .set({ revokedAt: new Date() }) + .where(eq(credentials.id, id)); + }, + } as never, + encryptionKey: KEY, + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), +}); + +/** + * What left the deployment, so a refusal can be shown to have stopped the send rather than reported + * on it afterwards. The vendors here do not exist, so a real request would fail anyway; what this + * captures is whether one was attempted at all, and what it carried. + */ +let sent: { url: string; authorization: string | null }[] = []; +const realFetch = globalThis.fetch; + +beforeAll(async () => { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = + input instanceof Request ? input : new Request(input as string, init); + sent.push({ + url: request.url, + authorization: request.headers.get("authorization"), + }); + return new Response("{}", { status: 500 }); + }) as typeof fetch; + + const encrypted = async (value: string) => encryptSecret(KEY, value); + await database.insert(credentials).values([ + { + id: ownCredentialId, + kind: "mcp", + // How `storeMcpToken` records whose token this is: the server it was minted for. + provider: serverId, + keyId: `mcp-${serverId}`, + encryptedValue: await encrypted(OWN_TOKEN), + metadata: {}, + }, + { + id: otherCredentialId, + kind: "mcp", + provider: otherServerId, + keyId: `mcp-${otherServerId}`, + encryptedValue: await encrypted(OTHER_TOKEN), + metadata: {}, + }, + ]); +}); + +afterEach(() => { + sent = []; +}); + +afterAll(async () => { + globalThis.fetch = realFetch; + await database.delete(mcpTools).where(like(mcpTools.serverId, `binding-%`)); + await database.delete(mcpServers).where(like(mcpServers.id, `binding-%`)); + await database + .delete(credentials) + .where(inArray(credentials.id, [ownCredentialId, otherCredentialId])); +}); + +async function storedUrl(id: string) { + const [row] = await database + .select({ url: mcpServers.url }) + .from(mcpServers) + .where(eq(mcpServers.id, id)); + return row?.url ?? null; +} + +describe("a credential is spent only by the server it belongs to", () => { + test("another server's token is refused", async () => { + await expect( + store.addCustomServer({ + id: serverId, + title: "Collector", + url: CHOSEN_URL, + credentialId: otherCredentialId, + by: "admin@example.com", + }), + ).rejects.toBeInstanceOf(CustomServerRefusedError); + + // The refusal is the whole point only if it happens before the send. + expect(sent).toEqual([]); + expect(await storedUrl(serverId)).toBeNull(); + }); + + test("the server's own token is accepted", async () => { + const added = await store.addCustomServer({ + id: serverId, + title: "Collector", + url: LEGITIMATE_URL, + credentialId: ownCredentialId, + by: "admin@example.com", + }); + + expect(added.id).toBe(serverId); + expect(await storedUrl(serverId)).toBe(LEGITIMATE_URL); + // This is the case the field exists for, so the token does go out, to the address it was given. + expect(sent[0]?.url).toContain("legit.vendor.example"); + expect(sent[0]?.authorization).toContain(OWN_TOKEN); + }); +}); + +describe("a credential is spent only at the address it was given", () => { + test("re-adding the server at a different address is refused", async () => { + // The case a check on whose credential it is cannot see: the token really does belong to this + // server. What changed is where the server points, and the add would spend the credential + // against the new address in the same call. + await expect( + store.addCustomServer({ + id: serverId, + title: "Collector", + url: CHOSEN_URL, + credentialId: ownCredentialId, + by: "admin@example.com", + }), + ).rejects.toBeInstanceOf(CustomServerRefusedError); + + expect(sent).toEqual([]); + expect(await storedUrl(serverId)).toBe(LEGITIMATE_URL); + }); + + test("re-adding it at the address it already has still works", async () => { + // Adding twice is not an attack and must stay ordinary: it is how a title is corrected and how + // an interrupted add is retried. + const added = await store.addCustomServer({ + id: serverId, + title: "Collector, renamed", + url: LEGITIMATE_URL, + credentialId: ownCredentialId, + by: "admin@example.com", + }); + + expect(added.title).toBe("Collector, renamed"); + expect(await storedUrl(serverId)).toBe(LEGITIMATE_URL); + }); + + test("a server holding no credential can still be re-addressed", async () => { + // Nothing to misdirect, so nothing to refuse. The rule is about spending a secret somewhere it + // was not entrusted to, not about URLs being immutable. + const openServerId = `binding-open-${tag}`; + await store.addCustomServer({ + id: openServerId, + title: "Open", + url: LEGITIMATE_URL, + by: "admin@example.com", + }); + + const moved = await store.addCustomServer({ + id: openServerId, + title: "Open", + url: CHOSEN_URL, + by: "admin@example.com", + }); + + expect(moved.id).toBe(openServerId); + expect(await storedUrl(openServerId)).toBe(CHOSEN_URL); + expect(sent.every((call) => call.authorization === null)).toBe(true); + }); +}); + +/** + * The way a token used to outlive the server it belonged to, and become spendable again. + * + * Three ordinary administrative acts in a row, none of them suspicious on its own. This is the shape + * that makes "a credential belongs to its server" and "a server keeps its address" both true and + * still not enough: the address rule only fires when a row is already here, so anything that gets + * the row out of the way while the token stays live reopens the same door. + */ +describe("a token does not outlive the server it was given to", () => { + const holderId = `binding-holder-${tag}`; + const holderCredentialId = randomUUID(); + const HOLDER_TOKEN = `sk-holder-${tag}`; + + beforeAll(async () => { + await database.insert(credentials).values({ + id: holderCredentialId, + kind: "mcp", + provider: holderId, + keyId: `mcp-${holderId}`, + encryptedValue: await encryptSecret(KEY, HOLDER_TOKEN), + metadata: {}, + }); + }); + + afterAll(async () => { + // The server row first: it holds a foreign key onto the credential, so the other order is + // refused by the database rather than by anything this suite is testing. + await database.delete(mcpTools).where(eq(mcpTools.serverId, holderId)); + await database.delete(mcpServers).where(eq(mcpServers.id, holderId)); + await database + .delete(credentials) + .where(eq(credentials.id, holderCredentialId)); + }); + + test("re-adding without a token keeps the one the server already holds", async () => { + // Clearing it was the first link: the row stops naming the credential, so nothing later knows + // the credential belongs to anything, and nothing retires it. + await store.addCustomServer({ + id: holderId, + title: "Holder", + url: LEGITIMATE_URL, + credentialId: holderCredentialId, + by: "admin@example.com", + }); + + await store.addCustomServer({ + id: holderId, + title: "Holder, renamed", + url: LEGITIMATE_URL, + by: "admin@example.com", + }); + + const [row] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, holderId)); + expect(row?.credentialId).toBe(holderCredentialId); + }); + + test("removing the server retires its token", async () => { + await store.removeServer(holderId, "admin@example.com"); + + const [row] = await database + .select({ revokedAt: credentials.revokedAt }) + .from(credentials) + .where(eq(credentials.id, holderCredentialId)); + expect(row?.revokedAt).not.toBeNull(); + }); + + test("a retired token cannot be attached to a server again", async () => { + // The end of the chain. Even with the row gone, so the address rule has nothing to compare + // against, the credential itself is no longer spendable. + sent = []; + + await expect( + store.addCustomServer({ + id: holderId, + title: "Holder", + url: CHOSEN_URL, + credentialId: holderCredentialId, + by: "admin@example.com", + }), + ).rejects.toBeInstanceOf(CustomServerRefusedError); + + expect(sent).toEqual([]); + }); +}); diff --git a/server/tests/plugin-curated-credential.integration.test.ts b/server/tests/plugin-curated-credential.integration.test.ts new file mode 100644 index 00000000..65c9171c --- /dev/null +++ b/server/tests/plugin-curated-credential.integration.test.ts @@ -0,0 +1,261 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { createAuditStore } from "../src/audit"; +import { encryptSecret } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { credentials, mcpServers, mcpTools } from "../src/db/schema"; +import { CATALOGUE, serverCredentialKind } from "../src/plugins/catalogue"; +import { + CustomServerRefusedError, + createPluginStore, +} from "../src/plugins/store"; +import { TEST_POOL } from "./support/database"; + +/** + * Which credential a curated server is allowed to be pointed at. + * + * `addCustomServer` was given this rule and `addServer`, one function above it, was not: it takes the + * same `credentialId` from the same administrator's request and stored it unread. The two paths are + * a pair, and a guard on one of them is a guard on the path somebody happened to look at. + * + * What is reachable today is narrower than the custom case and worth stating rather than dressing + * up. `mcp_servers.credential_id` is a real foreign key, so an id naming nothing is refused by the + * database, and the one entry in the catalogue is `user-oauth`, whose client is registered through + * `registerOAuthClient` and sent to a pinned vendor address. What is left is a credential of the + * wrong kind being accepted and spent, a malformed id arriving as a database error where a refusal + * belongs, and the whole hole reopening the moment a fork re-adds a `deployment-bearer` vendor, + * which the catalogue's own comment invites. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const store = createPluginStore({ + database, + auditStore: createAuditStore(database), + credentials: { + readSecret: async () => null, + create: async () => { + throw new Error("this suite does not write credentials"); + }, + revoke: async () => { + throw new Error("this suite does not revoke credentials"); + }, + }, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), +}); + +/** The catalogue key under test. Real, because which credential it takes is a property of the entry. */ +const serverId = "google-drive"; +const suffix = randomUUID().slice(0, 8); +const deploymentCredentialId = randomUUID(); +const personalCredentialId = randomUUID(); +const oauthClientCredentialId = randomUUID(); + +/** + * Whether this deployment already had the server, and what it pointed at. + * + * The id is a real catalogue key rather than a suite-scoped one, so on a database somebody is using + * it is their configured server. It is removed only when this suite is what created it, and left + * pointing where it pointed before when it is not. + */ +let existing: { credentialId: string | null } | null = null; + +beforeAll(async () => { + const [row] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + existing = row ?? null; + + const encrypted = await encryptSecret(`${"A".repeat(43)}=`, "not-read-here"); + await database.insert(credentials).values([ + { + id: deploymentCredentialId, + kind: "mcp", + provider: serverId, + keyId: `mcp-${serverId}-${suffix}`, + encryptedValue: encrypted, + metadata: {}, + }, + { + id: oauthClientCredentialId, + kind: "mcp_oauth_client", + provider: serverId, + keyId: `oauth-client-${serverId}-${suffix}`, + encryptedValue: encrypted, + metadata: {}, + }, + { + id: personalCredentialId, + kind: "mcp_user_token", + provider: serverId, + // For a user token the key is the person, which is what makes one pickable by name from the + // administrator's own credential list. + keyId: `user_someone_else_${suffix}`, + encryptedValue: encrypted, + metadata: {}, + }, + ]); +}); + +afterAll(async () => { + if (existing) { + await database + .update(mcpServers) + .set({ credentialId: existing.credentialId }) + .where(eq(mcpServers.id, serverId)); + } else { + await database.delete(mcpTools).where(eq(mcpTools.serverId, serverId)); + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); + } + await database + .delete(credentials) + .where( + inArray(credentials.id, [ + deploymentCredentialId, + oauthClientCredentialId, + personalCredentialId, + ]), + ); +}); + +describe("a curated server may only be pointed at its own kind of credential", () => { + test("somebody else's connector token is refused, and nothing is written", async () => { + await expect( + store.addServer({ + key: serverId, + credentialId: personalCredentialId, + by: "admin@example.com", + }), + ).rejects.toBeInstanceOf(CustomServerRefusedError); + + // The refusal has to stop the write, not merely report on it: a row here is a pointer the next + // refresh dereferences. + const rows = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + expect(rows).toHaveLength(existing ? 1 : 0); + }); + + test("a deployment token is refused for a vendor reached as the person asking", async () => { + // The right kind for a shared-token server and the wrong thing entirely for this one. Drive is + // answered with each person's own grant, and the deployment's OAuth client is registered through + // its own call, so there is no credential for this path to be given at all. + await expect( + store.addServer({ + key: serverId, + credentialId: deploymentCredentialId, + by: "admin@example.com", + }), + ).rejects.toBeInstanceOf(CustomServerRefusedError); + }); + + test("a malformed credential id is a refusal rather than a database error", async () => { + // `credentials.id` is a uuid column, so a value that is not one makes the query itself fail and + // the administrator gets a 500 where a refusal belongs. The same was true of the custom path + // before its shape check, and it is the reason that check reads the shape before the lookup. + const refused = store + .addServer({ + key: serverId, + credentialId: "not-a-uuid", + by: "admin@example.com", + }) + .catch((error: Error) => error); + expect(await refused).toBeInstanceOf(CustomServerRefusedError); + }); + + test("adding it again leaves the registered OAuth client where it was", async () => { + /* + * `registerOAuthClient` keeps the client it minted in this column, and adding the server again + * to change an instance host says nothing about that client. Clearing it orphaned a credential + * row that nothing revokes and told everybody who had connected that the deployment has no + * client registered, and there is no way to hand it back through this call now that a + * `user-oauth` entry refuses a credential id. + */ + await store.addServer({ key: serverId, by: "admin@example.com" }); + await database + .update(mcpServers) + .set({ credentialId: oauthClientCredentialId }) + .where(eq(mcpServers.id, serverId)); + + await store.addServer({ key: serverId, by: "admin@example.com" }); + + const [row] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + expect(row?.credentialId).toBe(oauthClientCredentialId); + + // Put it back, so the case below reads the column this suite left rather than this one. + await database + .update(mcpServers) + .set({ credentialId: null }) + .where(eq(mcpServers.id, serverId)); + }); + + test("adding the server without a credential still works", async () => { + // The case that must keep passing, so the refusals above are a rule and not a wall. This is also + // how the admin screen adds this vendor: it sends no credential and registers the OAuth client + // afterwards. + const added = await store.addServer({ + key: serverId, + by: "admin@example.com", + }); + expect(added.id).toBe(serverId); + + const [row] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + expect(row?.credentialId).toBeNull(); + }); +}); + +/** + * Every entry the catalogue actually holds, asked the same question. + * + * The shared-token branch cannot be reached today: the catalogue is frozen in code and its one entry + * is reached as the person asking. Rather than add a seam to this store so a test can invent an + * entry, the check is written over whatever the catalogue contains, so the branch starts being + * exercised the moment somebody re-adds one of the vendors that were taken out. That is the review + * where it matters, and this is the test that will be sitting there when it happens. + */ +describe("every curated entry is asked which credential it takes", () => { + test("the catalogue's own entries decide it, whatever they are", async () => { + expect(CATALOGUE.length).toBeGreaterThan(0); + + for (const entry of CATALOGUE) { + const kind = serverCredentialKind(entry); + + if (kind === null) { + // Takes none from the caller, so any id is refused, including one of the right kind. + await expect( + store.addServer({ + key: entry.key, + credentialId: deploymentCredentialId, + by: "admin@example.com", + }), + ).rejects.toBeInstanceOf(CustomServerRefusedError); + continue; + } + + // A shared-token entry takes the deployment's token for that server and nothing else. The + // fixture credential belongs to a different server, so it is refused on ownership, which is + // the branch a wrong pointer would take. + await expect( + store.addServer({ + key: entry.key, + credentialId: deploymentCredentialId, + by: "admin@example.com", + }), + ).rejects.toBeInstanceOf(CustomServerRefusedError); + } + }); +}); diff --git a/server/tests/plugin-routes.integration.test.ts b/server/tests/plugin-routes.integration.test.ts new file mode 100644 index 00000000..e25aab01 --- /dev/null +++ b/server/tests/plugin-routes.integration.test.ts @@ -0,0 +1,268 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import { eq, inArray } from "drizzle-orm"; +import { createApp } from "../src/app"; +import { createAuditStore } from "../src/audit"; +import { loadConfig } from "../src/config"; +import { encryptSecret } from "../src/credentials"; +import { createDatabase } from "../src/db/client"; +import { credentials, mcpServers, mcpTools } from "../src/db/schema"; +import { createPluginStore } from "../src/plugins/store"; +import { TEST_POOL } from "./support/database"; +import { testEnvironment } from "./support/environment"; + +/** + * The whole path an administrator's request actually takes, with nothing stubbed between the request + * and the row. + * + * The two halves are covered on their own: the store's refusals against a real database, and the + * route's mapping of them against a stubbed store. Both passing does not prove the pair is wired + * together, and the failure that would live in the gap is quiet in exactly the way that matters: a + * refusal that reaches the browser as a 500 reads as a broken deployment rather than a correctable + * mistake, and a refusal that stops short of the write leaves a row pointing at a credential the + * next refresh spends. So this asks the question end to end and then looks in the table. + */ + +const database = createDatabase( + process.env.DATABASE_URL ?? + "postgres://openbot:openbot@localhost:5432/openbot", + TEST_POOL, +); + +const store = createPluginStore({ + database, + auditStore: createAuditStore(database), + credentials: { + // Never read: Drive's tool list is in this deployment's own code, so the add path here reaches + // no vault. Loud rather than absent, so a call that starts reaching one is named. + readSecret: async () => { + throw new Error("this suite does not read credentials"); + }, + create: async () => { + throw new Error("this suite does not write credentials"); + }, + revoke: async () => { + throw new Error("this suite does not revoke credentials"); + }, + }, + encryptionKey: "x".repeat(44), + policy: () => ({ mode: "enforce", deny: [], allow: ["true"] }), +}); + +const ADMIN = { + id: "admin-1", + email: "admin@openbot.test", + name: "An Administrator", + image: null, +}; + +function request( + body: unknown, + role: "admin" | "user" = "admin", + path = "/api/plugins/servers", +) { + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: ADMIN }) }, + } as never, + { rolesForUser: async () => [role] }, + // Positions 4-14 are the other stores; the real one is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + ); + + return app.request(`http://openbot.test${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const serverId = "google-drive"; +const suffix = randomUUID().slice(0, 8); +const personalCredentialId = randomUUID(); +const customServerId = `route-custom-${suffix}`; +const foreignCredentialId = randomUUID(); +const ownCredentialId = randomUUID(); + +/** What this deployment already had, so a database somebody is using is left as it was found. */ +let existing: { credentialId: string | null } | null = null; + +beforeAll(async () => { + const [row] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + existing = row ?? null; + + const encrypted = await encryptSecret(`${"A".repeat(43)}=`, "not-read-here"); + await database.insert(credentials).values([ + { + id: personalCredentialId, + kind: "mcp_user_token", + provider: serverId, + keyId: `user_someone_else_${suffix}`, + encryptedValue: encrypted, + metadata: {}, + }, + { + id: foreignCredentialId, + kind: "mcp", + // Minted for a different server, which is what makes it somebody else's to spend. + provider: `route-elsewhere-${suffix}`, + keyId: `mcp-elsewhere-${suffix}`, + encryptedValue: encrypted, + metadata: {}, + }, + { + id: ownCredentialId, + kind: "mcp", + provider: customServerId, + keyId: `mcp-${customServerId}`, + encryptedValue: encrypted, + metadata: {}, + }, + ]); +}); + +afterAll(async () => { + if (existing) { + await database + .update(mcpServers) + .set({ credentialId: existing.credentialId }) + .where(eq(mcpServers.id, serverId)); + } else { + await database.delete(mcpTools).where(eq(mcpTools.serverId, serverId)); + await database.delete(mcpServers).where(eq(mcpServers.id, serverId)); + } + await database.delete(mcpTools).where(eq(mcpTools.serverId, customServerId)); + await database.delete(mcpServers).where(eq(mcpServers.id, customServerId)); + await database + .delete(credentials) + .where( + inArray(credentials.id, [ + personalCredentialId, + foreignCredentialId, + ownCredentialId, + ]), + ); +}); + +async function serverRow() { + const [row] = await database + .select({ credentialId: mcpServers.credentialId }) + .from(mcpServers) + .where(eq(mcpServers.id, serverId)); + return row ?? null; +} + +describe("adding a curated server over HTTP", () => { + test("a credential of the wrong kind is refused, and nothing is written", async () => { + const before = await serverRow(); + + const response = await request({ + key: serverId, + credentialId: personalCredentialId, + }); + + // Not a 500. An administrator who picked the wrong row is told what to do about it. + expect(response.status).toBe(400); + expect((await response.json()).error).toContain( + "takes no credential when it is added", + ); + + // And the refusal stopped the write rather than reporting on it. + expect(await serverRow()).toEqual(before); + }); + + test("a malformed credential id is refused the same way, not as a database error", async () => { + const response = await request({ key: serverId, credentialId: "nonsense" }); + + expect(response.status).toBe(400); + }); + + test("the add the admin screen makes still works and writes the row", async () => { + const response = await request({ key: serverId }); + + expect(response.status).toBe(200); + expect((await response.json()).server.id).toBe(serverId); + // Whatever the column held before, not null: an add that names no credential leaves a registered + // OAuth client alone, so asserting null here would pass on a fresh database and fail on the one + // deployment shape that behaviour exists for. + expect(await serverRow()).toEqual({ + credentialId: existing?.credentialId ?? null, + }); + }); + + test("somebody who is not an administrator is refused before the store", async () => { + const response = await request({ key: serverId }, "user"); + + expect(response.status).toBe(403); + }); +}); + +/** + * The same two rules, asked over HTTP against the real store. + * + * Both are refusals an administrator has to be able to act on, so what they must never be is a 500: + * "something went wrong" sends somebody to look at the deployment when the answer is to pick a + * different token or remove the server first. + */ +describe("adding a server by URL over HTTP", () => { + const custom = "/api/plugins/servers/custom"; + + test("another server's token is refused rather than spent", async () => { + const response = await request( + { + id: customServerId, + title: "Collector", + url: "https://collector.attacker.example/mcp", + credentialId: foreignCredentialId, + }, + "admin", + custom, + ); + + expect(response.status).toBe(400); + + const rows = await database + .select({ id: mcpServers.id }) + .from(mcpServers) + .where(eq(mcpServers.id, customServerId)); + expect(rows).toHaveLength(0); + }); + + test("re-addressing a server that holds a token is refused", async () => { + const added = await request( + { + id: customServerId, + title: "Collector", + url: "https://legit.vendor.example/mcp", + credentialId: ownCredentialId, + }, + "admin", + custom, + ); + expect(added.status).toBe(200); + + const moved = await request( + { + id: customServerId, + title: "Collector", + url: "https://collector.attacker.example/mcp", + credentialId: ownCredentialId, + }, + "admin", + custom, + ); + expect(moved.status).toBe(400); + + const [row] = await database + .select({ url: mcpServers.url }) + .from(mcpServers) + .where(eq(mcpServers.id, customServerId)); + expect(row?.url).toBe("https://legit.vendor.example/mcp"); + }); +}); diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts new file mode 100644 index 00000000..cace693d --- /dev/null +++ b/server/tests/plugin-routes.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { createApp } from "../src/app"; +import { loadConfig } from "../src/config"; +import { + CatalogueEntryUnknownError, + CustomServerRefusedError, +} from "../src/plugins/store"; +import { testEnvironment } from "./support/environment"; + +/** + * What a refused add looks like to the administrator who made it. + * + * The store's refusals are tested where they are decided. What is worth pinning here is the mapping, + * because an unmapped throw leaves the route on its default path: the refusal becomes a 500, the + * screen says something went wrong, and a correctable mistake reads as a broken deployment. The + * curated route mapped one refusal and not the other, which is exactly the shape that is invisible + * until somebody hits it. + */ + +const ADMIN = { + id: "admin-1", + email: "admin@openbot.test", + name: "An Administrator", + image: null, +}; + +function appWith( + addServer: () => Promise, + role: "admin" | "user" = "admin", +) { + const store = { + addServer, + // Every read the plugins surface makes on its way to the route under test. + listServers: async () => [], + listSkills: async () => [], + listGrants: async () => [], + }; + + const app = createApp( + loadConfig(testEnvironment()), + { + handler: () => new Response(null, { status: 204 }), + api: { getSession: async () => ({ user: ADMIN }) }, + } as never, + { rolesForUser: async () => [role] }, + // Positions 4-14 are the other stores; `store` is 15, pluginStore. + ...(Array.from({ length: 11 }) as never[]), + store as never, + ); + + return (body: unknown) => + app.request("http://openbot.test/api/plugins/servers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("adding a curated server", () => { + test("a refused credential comes back as a refusal with its reason", async () => { + const request = appWith(async () => { + throw new CustomServerRefusedError( + "That is not a credential this server can use. Add the server's own token instead.", + ); + }); + + const response = await request({ + key: "google-drive", + credentialId: "11111111-1111-1111-1111-111111111111", + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: + "That is not a credential this server can use. Add the server's own token instead.", + }); + }); + + test("an unknown catalogue key still comes back the same way", async () => { + const request = appWith(async () => { + throw new CatalogueEntryUnknownError("nope"); + }); + + expect((await request({ key: "nope" })).status).toBe(400); + }); + + test("a failure that is not a refusal is not dressed up as one", async () => { + // The must-not case. Mapping every throw to 400 would tell an administrator to correct their + // input when the database is down, and would hide a real fault behind a message about + // credentials. + const request = appWith(async () => { + throw new Error("the database is unreachable"); + }); + + expect((await request({ key: "google-drive" })).status).toBe(500); + }); + + test("somebody who is not an administrator cannot add one at all", async () => { + const request = appWith(async () => { + throw new Error("the store must not be reached"); + }, "user"); + + expect((await request({ key: "google-drive" })).status).toBe(403); + }); +}); diff --git a/server/tests/plugin-store.integration.test.ts b/server/tests/plugin-store.integration.test.ts index 00fe637b..646c108c 100644 --- a/server/tests/plugin-store.integration.test.ts +++ b/server/tests/plugin-store.integration.test.ts @@ -2279,6 +2279,12 @@ describe("a custom server may only be pointed at its own kind of credential", () const deploymentCredentialId = randomUUID(); const personalCredentialId = randomUUID(); const oauthClientCredentialId = randomUUID(); + /** + * The upsert case gets its own token, because a credential names the server it was minted for and + * that case adds a second server id. Sharing one row across two ids is a shape `storeMcpToken` + * cannot produce: it sets the provider to the server it is minting for, every time. + */ + const upsertCredentialId = randomUUID(); const customServerId = `custom-cred-${suffix}`; const madeServerIds: string[] = []; @@ -2306,6 +2312,14 @@ describe("a custom server may only be pointed at its own kind of credential", () encryptedValue: encrypted, metadata: {}, }, + { + id: upsertCredentialId, + kind: "mcp", + provider: `${customServerId}-upsert`, + keyId: `${customServerId}-upsert`, + encryptedValue: encrypted, + metadata: {}, + }, { id: oauthClientCredentialId, kind: "mcp_oauth_client", @@ -2463,7 +2477,7 @@ describe("a custom server may only be pointed at its own kind of credential", () id, title: "Collector", url: "https://collector.example/mcp", - credentialId: deploymentCredentialId, + credentialId: upsertCredentialId, by: "admin@example.com", }); @@ -2481,7 +2495,7 @@ describe("a custom server may only be pointed at its own kind of credential", () .select({ credentialId: mcpServers.credentialId }) .from(mcpServers) .where(eq(mcpServers.id, id)); - expect(row?.credentialId).toBe(deploymentCredentialId); + expect(row?.credentialId).toBe(upsertCredentialId); }); test("a custom server with no credential at all still works", async () => { From a46b5f91d2462adeaa3fe556ddca64e24bcf9eeb Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:22:16 -0500 Subject: [PATCH 10/13] Carry the per-Bot egress proxy as far as the process that reads it (#250) * Carry the per-Bot egress proxy as far as the process that reads it `EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_` are documented in .env.example and docs/configuration.md, and neither reached any process. docker-compose.yml named no EGRESS variable and had no `env_file`, and Compose hands a container only what those two blocks name. So the shared computer resolved every Bot to null and went out directly, and in the supervisor arrangement the supervisor's own environment held none either, leaving its EGRESS_PROXY passthrough with nothing to forward into the computers it creates. Nothing said so. The operator sets a proxy, the stack starts, the browser leaves by the host, and the Computers screen reports "Leaves directly" because it is reading the same empty environment. For a setting whose stated purpose is to give a security team a per-Bot address for network rules, silently doing nothing is the worst of the available failures. A file rather than more `environment:` entries because `EGRESS_PROXY_` is derived from a Bot's id, so there is no fixed set of names to write out here. A file of its own rather than .env because that one holds the deployment's secrets, and the container driving a browser and running a Bot's shell is deliberately given what it needs and not the rest. It is optional, since going out directly is the ordinary case and must still start, and gitignored, because a proxy URL can carry a password. The all-in-one image was never affected: its s6 service runs under `with-contenv` and inherits the container's environment, which is the mechanism this restores for Compose. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --- .env.example | 9 +++++++-- .gitignore | 2 ++ CHANGELOG.md | 21 +++++++++++++++++++++ docker-compose.yml | 14 ++++++++++++++ docs/configuration.md | 22 ++++++++++++++++++++-- tests/compose.test.ts | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index bfd61b36..e749f1eb 100644 --- a/.env.example +++ b/.env.example @@ -230,8 +230,13 @@ COMPUTER_TOKEN= # # This is attribution, not anonymity, and it is not a boundary by itself: it gives a security team a # per-Bot address for network rules alongside AGENT_COMPUTER_POLICY. -# EGRESS_PROXY_DEFAULT=http://user:password@proxy.internal:8080 -# EGRESS_PROXY_SALES_BOT=http://sales.proxy.internal:8080 +# +# These go in `egress.env` beside this file, NOT here. The names are per-Bot, so Compose cannot +# list them the way it lists every variable below, and it hands a container only what it is told to. +# In `.env` they reach no process and the browser goes out directly with nothing saying so. +# +# EGRESS_PROXY_DEFAULT=http://user:password@proxy.internal:8080 +# EGRESS_PROXY_SALES_BOT=http://sales.proxy.internal:8080 # The managed coworker AG-UI endpoint. Optional: use an HTTP(S) URL, and set MANAGED_AGENT_TOKEN diff --git a/.gitignore b/.gitignore index f1e6ae03..bfc1237c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ docs/plans/ .env .env.* !.env.example +# Per-Bot egress proxies. Carries credentials in the URL, like .env does. +egress.env node_modules/ **/dist/ app/src/lib/generated/application-config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 805817b0..350328b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,6 +231,27 @@ credential is refused rather than quietly attached to fail on its next call. Curated servers keep working as they did. Their URL comes from the catalogue rather than the request, and a per-instance hostname is matched against the vendor's own anchored pattern before anything is stored, so re-adding one cannot point it at an address of the caller's choosing. +### A configured egress proxy reaches the browser that uses it + +`EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_` were documented as the way to give a Bot a stable +outbound address, and Compose passed neither to anything. `docker-compose.yml` named no egress +variable and had no `env_file`, so the shared computer resolved every Bot to no proxy and went out +directly, and under the supervisor the same emptiness meant there was nothing to forward into the +computers it creates. + +The failure was silent, which for a setting whose purpose is to give a security team a per-Bot +address for network rules is the worst of the available failures. The stack started, the browser +left by the host, and the Computers screen reported "Leaves directly" because it was reading the +same empty environment. + +They now live in `egress.env`, which both the computer and the supervisor are given. A file rather +than more `environment:` entries because `EGRESS_PROXY_` is derived from a Bot's id and there +is no fixed set of names to list; a file of its own rather than `.env` because that one holds the +deployment's secrets and the container running a browser and a Bot's shell is deliberately not +given them. It is optional, so a deployment with no proxy is unchanged, and gitignored, because a +proxy URL can carry a password. + +**Move these two out of `.env` and into `egress.env`.** In `.env` they reach no process. ### Knowledge searches instead of guessing diff --git a/docker-compose.yml b/docker-compose.yml index e2e7cfbc..b420e933 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,15 @@ services: # Loopback only. This process drives a browser holding real logins; COMPUTER_TOKEN is the # request control, and loopback keeps the surface off routed networks. - "127.0.0.1:${COMPUTER_PORT:-4100}:4100" + # Per-Bot egress, in a file of its own because the names are not knowable here. + # + # `EGRESS_PROXY_` is derived from the Bot's id, so there is no fixed list to write out the + # way COMPUTER_TOKEN is. Not `.env`: that holds the deployment's secrets, and this container + # drives a browser and runs a Bot's shell, so it is given what it needs and not the rest. + # Optional, because going out directly is the ordinary case and must still start. + env_file: + - path: ./egress.env + required: false environment: # The secret every caller must present. The container refuses to start without it. COMPUTER_TOKEN: ${COMPUTER_TOKEN:-} @@ -143,6 +152,11 @@ services: build: context: . dockerfile: supervisor/Dockerfile + # The same file, because this process does not read these itself: it forwards every EGRESS_PROXY + # key out of its own environment into each computer it creates, so it has to be given them first. + env_file: + - path: ./egress.env + required: false environment: PORT: "4300" # Shared with the API server. The Bot-level verb set is the boundary; this token keeps other diff --git a/docs/configuration.md b/docs/configuration.md index be7ff85b..a8291d3c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -191,8 +191,8 @@ where `` is `google`, `microsoft` or `okta`. - `WORKSPACE_DIR` - `PROFILES_DIR` - `COMPUTER_BOT_ID` -- `EGRESS_PROXY_DEFAULT` -- `EGRESS_PROXY_` +- `EGRESS_PROXY_DEFAULT` (in `egress.env`, see below) +- `EGRESS_PROXY_` (in `egress.env`, see below) - `COMPUTER_SHELL_ENV` A command on the computer inherits PATH, locale and terminal names, and the proxy variables, not @@ -200,6 +200,24 @@ the rest of the process environment. Userinfo is stripped from a proxy URL, so a `HTTP_PROXY` is not in `env`. `COMPUTER_SHELL_ENV` is a comma-separated list of extra names to pass. Naming a secret or a credentialed proxy there is an operator's decision; the default does not. +### Per-Bot egress + +The two egress variables live in `egress.env` at the repository root, not in `.env`. `EGRESS_PROXY_` +is derived from a Bot's id, so there is no fixed set of names for Compose to list the way it lists +every other variable, and Compose passes a container only the names it is given. A file of its own +rather than `.env` because that one holds the deployment's secrets and neither the browser container +nor the supervisor is given those. + +```sh +# egress.env +EGRESS_PROXY_DEFAULT=http://user:password@proxy.internal:8080 +EGRESS_PROXY_SALES_BOT=http://sales.proxy.internal:8080 +``` + +The file is optional and gitignored. Without it every Bot's browser goes out directly, which is the +default. Both the shared computer and the supervisor are given it: the computer resolves its own +proxy from these names, and the supervisor forwards them into each computer it creates. + The supervisor also reads: - `COMPUTER_IMAGE` diff --git a/tests/compose.test.ts b/tests/compose.test.ts index ac18455f..b7261b3e 100644 --- a/tests/compose.test.ts +++ b/tests/compose.test.ts @@ -121,3 +121,35 @@ test("runs migrations after PostgreSQL becomes healthy", () => { expect(compose).toContain("condition: service_healthy"); expect(compose).toContain('"drizzle-kit", "migrate"'); }); + +/** + * Per-Bot egress reaches the processes that read it. + * + * `EGRESS_PROXY_` and `EGRESS_PROXY_DEFAULT` are resolved from `process.env` by the computer + * itself (`agent-computer/src/egress.ts`), and the supervisor forwards every `EGRESS_PROXY` key out + * of its own environment into each computer it creates (`supervisor/src/index.ts`). Compose gives a + * container only what its `environment:` and `env_file:` blocks name, and for a long time neither + * named these, so an operator who configured a proxy per the documentation got a browser that went + * out directly and no error saying so. + * + * A file rather than `environment:` entries because the names are per-Bot and therefore not knowable + * here, and a file of its own rather than `.env` because that one holds the deployment's secrets and + * the browser container is deliberately not given them. + */ +test("carries per-Bot egress into the computer and the supervisor", () => { + const compose = readFileSync( + join(import.meta.dir, "..", "docker-compose.yml"), + "utf8", + ); + + // Both halves: the shared computer reads them itself, and the supervisor passes them on. + const services = compose.split(/^ {2}(?=\S)/m); + for (const name of ["agent-computer:", "supervisor:"]) { + const service = services.find((block) => block.startsWith(name)); + expect(service).toBeDefined(); + expect(service).toContain("egress.env"); + } + + // Optional, because a deployment with no proxy is the ordinary case and must still start. + expect(compose).toContain("required: false"); +}); From 0403be14817fb149ab27d35210e55b2ca32a4800 Mon Sep 17 00:00:00 2001 From: beardthelion <56458543+beardthelion@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:23:00 -0500 Subject: [PATCH 11/13] Refuse the shell and a workspace write while a person holds the wheel (#247) * Refuse the shell and a workspace write while a person holds the wheel `assertBotMayAct` was called in the navigate handler and the four action handlers, and nowhere else. `/exec` and `/files/write` were not covered, so a Bot could keep running commands and rewriting its workspace underneath somebody who had taken the browser at a login wall. The shell arrived after the wheel existed and was never wired to it. That is the property this codebase states outright. control.ts says every acting call from the Bot is refused while a person holds control, and the README says Bot actions are refused rather than queued. Both were false for the most powerful path the product exposes, and the server could not cover for it: control lives in this process, so those two call sites were the whole of the enforcement. The decision moves to `actsOnTheComputer` in authorisation.ts and is asked once by the dispatcher, after the session resolves. A per-handler check is the thing the next endpoint forgets, which is exactly how the shell came to be missing one; a list the dispatcher consults has to be added to instead. It lives beside the other path decision rather than in index.ts because that file imports Playwright at module scope, so a decision left there cannot be tested without Chrome. Reading stays open. `/files/read` and `/files/list` are not acting, and a Bot that has just been stopped still needs to say what it was doing. The two in-handler guards and their now-dead ControlError branches come out with it, so there is one place that answers this and not three. * Say in the changelog that the wheel now stops the shell A deployment behaves differently afterwards: an action that used to run during a takeover is refused, so it belongs here rather than only in the commit. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay * Carry the per-Bot egress proxy as far as the process that reads it (#250) * Carry the per-Bot egress proxy as far as the process that reads it `EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_` are documented in .env.example and docs/configuration.md, and neither reached any process. docker-compose.yml named no EGRESS variable and had no `env_file`, and Compose hands a container only what those two blocks name. So the shared computer resolved every Bot to null and went out directly, and in the supervisor arrangement the supervisor's own environment held none either, leaving its EGRESS_PROXY passthrough with nothing to forward into the computers it creates. Nothing said so. The operator sets a proxy, the stack starts, the browser leaves by the host, and the Computers screen reports "Leaves directly" because it is reading the same empty environment. For a setting whose stated purpose is to give a security team a per-Bot address for network rules, silently doing nothing is the worst of the available failures. A file rather than more `environment:` entries because `EGRESS_PROXY_` is derived from a Bot's id, so there is no fixed set of names to write out here. A file of its own rather than .env because that one holds the deployment's secrets, and the container driving a browser and running a Bot's shell is deliberately given what it needs and not the rest. It is optional, since going out directly is the ordinary case and must still start, and gitignored, because a proxy URL can carry a password. The all-in-one image was never affected: its s6 service runs under `with-contenv` and inherits the container's environment, which is the mechanism this restores for Compose. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --- CHANGELOG.md | 14 ++++++ agent-computer/src/authorisation.ts | 27 +++++++++++ agent-computer/src/index.ts | 37 ++++++++++----- agent-computer/tests/authorisation.test.ts | 54 +++++++++++++++++++++- 4 files changed, 120 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350328b9..9acfaa3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,20 @@ uses. It still cannot address another pod, a node, or a cloud metadata endpoint. destination and so permitted everything. That rule now covers the API server alone, and `networkPolicy.kubernetesApiCidr` narrows it to your cluster's service range; left empty it stays as it was, because a chart cannot know that range. +### Taking the wheel stops the Bot's shell, not just its clicks + +While a person held the wheel the Bot was refused on the page, and not in the shell. `/exec` and a +workspace write went through, so a Bot could keep running commands and rewriting its `/workspace` +underneath somebody who had taken the browser at a login wall. The guard existed and covered +navigation and the four page actions; the shell arrived later and was never wired to it. + +Every acting path now asks the same question in one place, so the property the documentation states +is the property the computer has. Reading is deliberately not acting: `/files/read` and +`/files/list` still answer while a person drives, because a Bot that has just been stopped still has +to be able to say what it was doing. + +Nothing to configure. A Bot that acts during a takeover gets the refusal it already got for a click, +and the trail records the attempt and the failure the same way. ### A finished turn shows the page it opened, not the one open now diff --git a/agent-computer/src/authorisation.ts b/agent-computer/src/authorisation.ts index 667f2d44..cbeabb10 100644 --- a/agent-computer/src/authorisation.ts +++ b/agent-computer/src/authorisation.ts @@ -47,3 +47,30 @@ export function offeredToken(headers: Headers, url: URL): string { export function isOpenPath(pathname: string): boolean { return pathname === "/health"; } + +/** + * Which paths act on the computer, and so are refused while a person holds the wheel. + * + * One list, asked once per request, rather than a check inside each handler. The shell is the reason: + * `/exec` arrived after the wheel existed and was never given the guard the page paths had, so a Bot + * could keep running commands and writing files underneath somebody who had taken the browser at a + * login wall. A per-handler check is exactly the thing the next endpoint forgets, which is how that + * happened; a list the dispatcher consults is one an endpoint has to be added to. + * + * Reading is not acting. `/files/read` and `/files/list` stay open so a Bot that has been stopped can + * still read its own notes and explain what it was doing, which is the answer the person handing the + * wheel back usually wants. + */ +const ACTING_PATHS = new Set([ + "/navigate", + "/click", + "/type", + "/key", + "/scroll", + "/exec", + "/files/write", +]); + +export function actsOnTheComputer(pathname: string): boolean { + return ACTING_PATHS.has(pathname); +} diff --git a/agent-computer/src/index.ts b/agent-computer/src/index.ts index b4762635..5d7177a6 100644 --- a/agent-computer/src/index.ts +++ b/agent-computer/src/index.ts @@ -1,7 +1,12 @@ import { serve } from "bun"; import type { Page } from "playwright"; import { parseAriaSnapshot, type SnapshotElement } from "./aria-snapshot"; -import { isOpenPath, matchesToken, offeredToken } from "./authorisation"; +import { + actsOnTheComputer, + isOpenPath, + matchesToken, + offeredToken, +} from "./authorisation"; import { isPlainBotId } from "./bot-id"; import { type Control, @@ -485,6 +490,26 @@ serve({ } const session = sessionFor(botId); + /* + * The wheel, asked once for everything that acts. + * + * Refused here rather than inside each handler because the handler that forgets is the whole + * defect: the shell shipped without this check and ran commands underneath a person who had taken + * the browser at a login wall. `actsOnTheComputer` is the list, and a new acting endpoint is + * refused by being added to it rather than by remembering to repeat this. + */ + if (actsOnTheComputer(url.pathname)) { + try { + session.control.assertBotMayAct(); + } catch (error) { + // A person holding the wheel is not a failure of the action; the Bot should wait and say so. + if (error instanceof ControlError) { + return json({ error: error.message, humanHasControl: true }, 409); + } + throw error; + } + } + if (url.pathname === "/stream") { /* * The socket carries the Bot in the query because it cannot do it in a header. Every other call here names @@ -696,7 +721,6 @@ serve({ const startedAt = Date.now(); try { - session.control.assertBotMayAct(); const target = await currentPage(botId); await target.goto(body.url, { waitUntil: "domcontentloaded", @@ -715,10 +739,6 @@ serve({ elapsedMs: Date.now() - startedAt, }); } catch (error) { - // A person holding the wheel is not a failed navigation; the Bot should wait. - if (error instanceof ControlError) { - return json({ error: error.message, humanHasControl: true }, 409); - } // The page is the Bot's working surface, so a failed navigation is reported rather than // thrown: the transcript needs to say what happened, and the browser stays usable. return json( @@ -893,7 +913,6 @@ serve({ const startedAt = Date.now(); try { - session.control.assertBotMayAct(); const target = await currentPage(botId); const detail = await performAction( session, @@ -932,10 +951,6 @@ serve({ if (error instanceof StaleSnapshotError) { return json({ error: error.message, stale: true }, 409); } - // 409 as well, and for the same reason: nothing is broken, the caller simply has to wait. - if (error instanceof ControlError) { - return json({ error: error.message, humanHasControl: true }, 409); - } return json({ error: describe(error, "The action failed.") }, 502); } } diff --git a/agent-computer/tests/authorisation.test.ts b/agent-computer/tests/authorisation.test.ts index 9ec6521b..34fcfdc4 100644 --- a/agent-computer/tests/authorisation.test.ts +++ b/agent-computer/tests/authorisation.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { isOpenPath, matchesToken, offeredToken } from "../src/authorisation"; +import { + actsOnTheComputer, + isOpenPath, + matchesToken, + offeredToken, +} from "../src/authorisation"; /** * The check that stands in front of a Bot's browser. @@ -88,3 +93,50 @@ describe("what an unauthenticated caller may reach", () => { } }); }); + +/** + * Which paths the wheel stops. + * + * A person takes the wheel at a login wall precisely because they no longer want the Bot acting, and + * `control.ts` states the property outright: "While a person holds control every acting call from the + * Bot is refused". That was true of the page from the start and untrue of the shell, which arrived + * later (#62) and was never wired to the wheel, so a Bot could run a command and rewrite the + * workspace underneath somebody mid-sign-in. + * + * The list lives here, beside the other path decision, rather than in `index.ts`, for the reason the + * header of this file gives: a decision next to `chromium.launch()` cannot be tested without Chrome. + * + * Reading is not acting. `/files/read` and `/files/list` stay open so a Bot waiting to be handed the + * wheel back can still say what it was doing. + */ +describe("what the wheel stops while a person is driving", () => { + test("every path that acts on the computer, the shell and a workspace write included", () => { + for (const path of [ + "/navigate", + "/click", + "/type", + "/key", + "/scroll", + "/exec", + "/files/write", + ]) { + expect(actsOnTheComputer(path)).toBeTrue(); + } + }); + + test("reading, looking and the handover itself are not acting", () => { + for (const path of [ + "/files/read", + "/files/list", + "/snapshot", + "/screenshot", + "/health", + "/control", + "/control/take", + "/control/release", + "/stream", + ]) { + expect(actsOnTheComputer(path)).toBeFalse(); + } + }); +}); From 615a041237459625e6b23533cc872e5cb775cd3c Mon Sep 17 00:00:00 2001 From: anygivenfriday Date: Wed, 26 Aug 2026 08:23:54 -0700 Subject: [PATCH 12/13] Stop grant queries polling the placeholder Bot (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: stop grant queries polling the placeholder Bot Every surface polls which components its Bot holds, so a revoked grant leaves an open conversation within seconds. On a screen with no conversation, the Bot it polled about was the placeholder id the routing holder falls back to — which no package registers and the server 404s. An admin page left open asked a guaranteed miss every five seconds, forever. Nothing looked broken: an absent grant list and an empty one render identically. The cost is that a request log where the same 404 repeats indefinitely is one where a 404 that matters is invisible. `declaredBotId` names the distinction the holder already had but nothing could ask about: the placeholder exists so a handler always has something to route with, but it is not a Bot. The grant queries now take the declared id and their existing `enabled` guard does the rest — undefined simply does not run. Conversation surfaces declare a real Bot and are unchanged, as are the call-time checks, which never trusted the poll anyway. * Channel pin and soft delete, and a Notion connector over hosted MCP (#242) * Let a member pin a channel and soft-delete it, from a right-click menu * Land the caret in the composer once a coworker is chosen * Calm the screen panel down and make the full-size view a card * Let a Bot's message take the whole transcript column * Put Notion in the catalogue, and let a vendor register its OAuth client dynamically * Rotate refresh tokens in place, serialised per connection, and recover an evicted client * Introduce the deployment to a dynamic vendor on first connect * Show Notion in the plugin screens, without a client form it does not need * Say what the Notion connector is, everywhere the catalogue is described * Grant a batch of tools to Bots from the vendor page * Hold the vault row while a rotating token is spent, so replicas take turns * Refuse to mint a second client inside the re-registration window * Tell every member's roster when a channel is deleted, again * Leave a who-and-when behind a soft delete, again * Carry a pin across one person's own tabs * Say the classification direction right everywhere a person reads it * Refuse to whisper into a deleted channel `get` and `list` filter on `deleted_at`; `recordActivity` and `setPinned` did not. Activity POSTed to a soft-deleted channel returned 204, bumped `last_message`, and announced it to every member, each of whom then refetched a roster for a row it cannot show; a pin on one succeeded the same way. Both now join the channel and require it undeleted, throwing ChannelNotFoundError to match `get`, which also keeps the notify off the refused path since it is written inside the transaction. The roster's second query repeats the same filter. It selects the page and then joins the agents to it in a separate statement on a separate snapshot, so a delete committing between the two would hand back a channel this person can no longer see. * Hold a pinned channel at the top of the roster, not the page The roster ordered by recency alone and the client lifted pinned rows at render, so a pin only reached the top of whatever pages were loaded: a channel somebody pinned and then did not talk to for a month sat on page three and never appeared above anything. The promise is about the roster, so the ordering belongs in the query. The page now orders by the pin first and the cursor carries it as the leading element. Every part of the sort descends — a pin is 1 and no pin is 0 — which keeps the keyset predicate a single row comparison rather than a nest of ORs, and a cursor minted before the pin existed reads as the first page, like any other cursor describing an ordering this query no longer has. `pinnedFirst` stays in the sidebar as the render-level mirror, for the window between refetches: the socket patches a pin onto a loaded row without moving it, and re-sorts a page by recency alone. Its comment now says that is what it is for, rather than claiming to be where the rule lives. * Read a vendor's garbage as a refusal, not a crash * Keep the wheel reachable when the screen has nothing to show Take control and Hand back live in the full-size view, and the only way in was disabled unless there was a picture to open. So a blank browser, a screenshot that had not arrived, or a computer that could not be reached left a person with no way to take the wheel at all - the three states where they most want it. The frame now opens whatever is in it, and with nothing to draw the full-size view reserves the same shape and says the same words the card does, with the wheel underneath them. Somebody already driving keeps the live socket, whatever is on the page: once a person holds the wheel the stream is the truth about it. The Bot ASKING for the wheel comes back to the card as its own amber row with the reason on it, which is what the rework dropped. It is not the persistent footer that was deliberately removed - it is there only while the request is, next to the credential form, which is the other thing a stuck Bot needs. * Answer pin and delete failures where they happened Three things this row did quietly. A refused delete stayed on the mutation, so reopening the confirm showed a stale 409 about an attempt nobody had made yet; the menu resets it on the way in. A failed pin said nothing at all - the menu closed, the pin did not move, and that reads as the app ignoring the click - so the sentence now lands on the row, there being no toast in this app. And a delete of the channel on screen navigated home after the write. The roster invalidates the moment it lands, which unmounts this row and the dialog inside it, so the navigate belonged to a component that was already gone. Leaving first is safe in the other direction: a refusal puts them on the roster with the channel still in it, and says why. * Grant a batch with one refetch and a progress count Two Bots and twelve tools is twenty-four writes, and every one of them went through the grant mutation - which invalidates every plugin query and waits for the refetch. Most of the wait was re-reading a list hidden behind the dialog. The write is now its own function with no refetch attached, and the dialog invalidates once when the loop is done, including after a refusal, because the grants before it landed. The button says which of the N is in flight rather than only "Granting", so a slow batch can be told from a stuck one, and each set of tickboxes is a fieldset named by the heading already above it - "Changes things" is the whole warning on those tools, and a listener would otherwise never hear it. * Sweep the code the screen rework orphaned `hasBrowsed` had no callers left once the screen and the activity log stopped being tabs that had to guess which one to open, and the placeholder artwork went with the blank-browser strip it decorated. The note itself stays: the tool handler is the only place the fact exists, and a screenshot cannot answer it. The composer's autofocus is a mount-time courtesy, claimed once. Keyed off the editor becoming interactive, it re-fired on every disabled or busy transition, so a completed turn yanked the caret back from wherever the person had moved it. A send of their own still returns it - that one they asked for. * Stop pretending a new client can spend an old grant * Let two first connects race to one client * Cap, revoke and say what refresh saw * Seal the consent state, not just sign it * Refuse a consent that outlived the person's access * Run OpenBot on Kubernetes: Bots and all, proven on EKS (#235) * Run OpenBot on Kubernetes: a Helm chart, and what installing it found One chart for EKS, GKE, AKS and somebody's own cluster, with nothing but values between them. No cloud branching in any template: every place the clouds differ is a value whose default is what a plain self-hosted cluster does. Identity is one annotations map, because that is all IRSA, Workload Identity and AKS workload identity are. Secrets are a plain Secret by default and an ExternalSecret against any backend when asked. Two replicas by default, because horizontal is the point and one hides every bug that is not. A bad install is refused at helm install naming the value to change, rather than found in a crash loop. Three things only a real install could find: drizzle-kit cannot migrate in the shipped image. It reads a TypeScript config, which needs the esbuild that bun install --production leaves out, so it printed one line, exited 1 and said nothing. EMBEDDED_POSTGRES=on was starting containers whose database was never migrated. The migrator inside drizzle-orm is a runtime dependency already and keeps the same journal. sessionOf answered from a map in the process that started the computer, which is right until there are two of them. The replica taking a snapshot is usually not the one handling the click, and an unknown session skips the generation check rather than failing it, so the check that stops a ref from a replaced computer resolving against a live one was silently absent on the shape it was written for. It now asks by listing, never by ensuring, so asking cannot start a computer that had stopped. A browser in an API pod cannot be replicated, so the image's computer gets the same switch its database has. * Give Bots computers on Kubernetes, and suspend them when idle The chart had no computer, so no Bot could do anything on a cluster. It has one now, and a Bot has driven a real browser on real EKS with the decision in the audit trail. computers.mode picks the shape. shared runs one browser for every Bot and needs nothing installed. sandbox gives each Bot its own as a Sandbox from kubernetes-sigs/agent-sandbox, which is built for exactly this: an isolated stateful singleton with a stable identity and persistent storage, where suspending is a field that keeps the volumes, so a computer comes back with its logins rather than signed out. What decides a computer is idle is the audit trail, not the browser. Asking the browser wakes it, so every computer anything asked about would come back up and the bill would never fall. The work is claimed and leased out of Postgres with for update skip locked. Three features need that one mechanism, so it is written once with all three in view: the culler here, routines, and a hop from one Bot to another. A CronJob runs the sweep rather than a timer in the API, because a timer fires in every replica and suspending a browser somebody just started using is not something to do five times. Also: a fresh EKS cluster very often has no default StorageClass. eksctl creates gp2, unmarked and on the in-tree provisioner current Kubernetes no longer has, so a volume asking for the default never binds and nothing says why. Found on a real 1.34 cluster and written down where somebody configuring one will read it. * Refuse a sandbox install on a cluster that cannot make one computers.mode: sandbox creates Sandbox objects, which exist only once the agent-sandbox controller is installed. Without it the install succeeds, every pod is healthy, and the deployment looks finished right up until the first Bot asks for a browser and the API server answers 404. That is the worst moment to learn it. The check reads the cluster rather than a value somebody has to remember to set, and the message carries the one command that fixes it. Proven both ways: refused on a cluster with no CRD, installs on the EKS cluster that has one. Also from driving it on real EKS: lost+found was listed as a Bot, because an EBS volume is ext4 and arrives with that directory, which a bind mount never does. The allow-list that stops a hostile id becoming a path answers the other half of the question too. The migration Job named a ServiceAccount that does not exist yet, since a pre-install hook runs before the chart's own resources. It talks to a database and never to the cluster, so it needs no account at all. The API pod gets a cluster token only in sandbox mode, the pods roll when the computer template changes, the Sandbox asks for a Service so it has an address that survives a resume, and the cluster CA is actually used when talking to the API server. * Tell one run of a computer from the next across a suspend A resumed browser counts snapshot generations from one again, so a ref the model still holds from before the suspend matches a row nothing has overwritten, and the boundary decides about an element on a page that no longer exists. The first answer used the node and the pod address. Resuming a real computer on EKS disproved it: a suspended sandbox is very often rescheduled onto the same node and handed the same address back, and both were identical across the cycle, so the check would have said same run for the exact case it exists to catch. The Ready condition's transition time moves whenever a computer starts serving again, needs no permission beyond the sandbox already read, and is precisely the question. Driven on EKS: a ref taken before a suspend is refused after the resume, naming why, and a fresh ref from a new snapshot clicks through. * Let the policy reach the computers, and refuse one that fences off the database The NetworkPolicy allowed DNS and the bundled database. Nothing let the API reach a Bot's computer, which it does for every browser action, and nothing let it reach a managed database, whose address this chart cannot know. On a cluster that enforces policy both are outages that read as something else: the API looks broken rather than fenced. The computers and the API server are allowed now, and turning the policy on with an external database and no rule for it is refused with the shape of the rule to add. None of this showed up by installing it, because EKS runs its CNI with --enable-network-policy=false and the policy is inert there. That is worth knowing on its own, so it is written down: a policy that installs, looks right, and does nothing is worse than one that is off. Also driven on EKS: reset takes the volumes with it and the Bot gets a clean profile afterwards, and the HPA reads real metrics. * Keep the browsing that produced an answer Every turn in which a Bot used a tool vanished from the transcript on reload. The sentence the Bot wrote stayed, the browsing that produced it did not, the inline screen went with it, and the footer said some messages could not be read. The history store writes a tool call as {id, name, args}; AG-UI describes {id, type: function, function: {name, arguments}}. The reader validated against the second and treated the first as damage from an interrupted run. It is not damage, it is how every tool call is stored, so a guard written against one bad turn was deleting all the real ones. Found by driving a real conversation on the EKS deployment rather than by reading: two browsing turns, both counted unreadable, both well formed in the store's own dialect. Both spellings now read as the same thing. A mixed or unrecognised array is still refused rather than half-translated, because a reader that rewrites what it does not recognise is worse than one that refuses it. * Show the page a finished turn opened, not the one open now Reopening a conversation made every past turn fetch the screen as it is now, so an answer about Hacker News from an hour ago sat under a picture of whatever the Bot had open since. The frame was live and the caption was not, and the turn read as though it had browsed somewhere it never went. A turn that has finished is history, and history is not polled. It names the page that turn actually left open, which the tool result already carried. Nothing changes while a turn runs: those frames are its own and freeze where it left them. It names the page rather than showing it, because nothing stored the picture and fetching one now would show a different page. Naming it stays true however many times the Bot has browsed since. Driven on EKS: three turns, three different pages, each holding its own across a reload. * Keep the frame a browsing turn ended on Reopening a conversation made every past turn fetch the screen as it is now, so an answer about one page sat under a picture of whatever the Bot had open since. A browsing turn keeps its last frame in computer_turn_frame, filed under the tool call and written once, because a turn that has happened does not happen differently later. Three things had to be true together and each was wrong on its own first. The frame is read at the moment the turn ends, since a short turn finishes before the tile has polled anything. Restoring a kept frame must not make the turn look live again, which the first version did: it counted a turn as history only while it had no picture, so restoring one restarted the polling that then replaced it. And a turn is over when it has a result rather than when its status says so, because a restored tool call arrives with its result in hand and a status that is briefly something else. Found by watching the network on the deployed cluster rather than by reading: two live screenshot reads before the restore, on every reload. * Keep a turn's frame only when it is a frame of that turn's page The capture ran at the end of a turn and took whatever the screen showed then. That is usually right and sometimes badly wrong: the same computer is driven by other conversations, a resumed one starts blank, and a short turn finishes before the tile has polled anything. So an answer about one page could be filed with a picture of another, which is worse than having no picture at all. A frame is now kept only when its own url is the page the turn opened. Unknown counts as no match, because storing on unknown is how the wrong picture gets kept. Also folds the restore and the capture into one effect asked in order: what is stored first, the live screen only if nothing is. Two effects racing is what made a reopened turn restore the right frame and then overwrite it with a fresh screenshot one render later, which the console showed plainly once I stopped guessing and logged it. * Photograph the page where it is opened, not where it is read back The transcript's inline screen used to capture its own frame after the turn ended, and file it under the tool call. That is a race it cannot win. A reopened turn and one that has just finished look identical from inside the component, the same computer is driven by other conversations in between, and a resumed computer starts blank, so the picture filed was routinely of somewhere the turn never went, or of nothing. The frame is now taken on the server the moment a navigation succeeds, which is the one moment the screen is certainly showing the page that was asked for, and kept per computer and page rather than per tool call. The surface only reads. Failing to take the picture never fails the navigation. * Open the Bot screen on a Bot this deployment has Three things a fork trips over. The Bot screen defaulted to a coworker named risk-analyst, which is a name from one tenant package and a crash on every other. OpenBot exists to be forked, so a Bot id written into a route is a defect on all but the deployment it came from: the screen took the whole page down to an unstyled error boundary. It now opens on whatever Bot this deployment actually has, and answers a mistyped name in a sentence. The audit trail wrote "not in the current snapshot" against every navigation, file read and command. That sentence is about a ref the server could not resolve, and deciding it by elimination put it on actions that never named an element at all, sending a reader looking for a snapshot nobody took. It is keyed on the ref now. The chart had no way to point at anyone's own AG-UI Bot, which is the seam the whole product is about. config.managedAgent.url and secrets.managedAgentToken, refused at install if one is set without the other. * Format the regenerated migration snapshot * Fix what the review found, and make CI able to find it next time Every claim driven against the real thing rather than read, and all but two held. THE QUEUE. Leases were computed on the replica's clock and compared against the database's, which is two clocks pretending to be one: a node ninety seconds behind wrote a sixty-second lease that arrived already expired, and the next replica took the item out from under it. Both ran it. `finish` and `release` matched on the key alone, so a replica whose lease had quietly gone deleted or rescheduled work another was executing. Nothing ever renewed, and the culler took twenty items on one lease. `finish` deleted the row, destroying the idempotence this table's own comment promises: the insert a re-offer was meant to collide with had nothing left to collide with. And a permanently failing item retried until somebody noticed, which on a queue with no dashboard is never. Every moment is named in SQL now, all three lease calls ask the same question, the culler renews between items, a finished row stays until a retention sweep takes it, and an item that runs out of attempts stops with its count and its reason where a person can query them. The probe that reproduced the first two comes back clean. THE CHART could not start a server on any of its four shipped targets: each configured sign-in and none supplied the secret sessions are signed with, and one carried the public example encryption key. Driven on a real cluster, watched to crash-loop, fixed, watched to come up ready. Both states are refused at install now, including the one hiding behind an external secret store, where the value is unreadable but the list of keys is not. THE DATABASE WAS REACHABLE FROM THE BOT'S BROWSER. Compose has kept those apart since the beginning; the chart dropped it, and the bundled database shipped a policy admitting any pod in any namespace on 5432. Proven by opening a socket from the browser pod. Pinned, plus a policy for the computer itself, which had none. That pod also carried a cluster credential it has no use for, which it no longer does: verified on a recreated per-Bot computer. The service account token was read once and held for the life of the process. Projected tokens rotate on a schedule the cluster picks, so sandbox calls work until the first rotation and then all return 401, which reads like the cluster broke. THE TRANSCRIPT still lied in two places. A finished turn holding a stale live frame fell through to "Waiting for the assistant's screen…" and waited there for ever, because the poll that would end the wait stops when a turn settles. And zooming a past turn mounted the live stream and offered Take control, so the one gesture for looking closer at what a turn did replaced it with whatever the Bot has open now. The kept frame exists to stop exactly that. TWO TESTS passed a pool-options object where a connection string belongs and were green for a reason unrelated to what they check, because the test tree is not type-checked. That is its own sweep; the misuse throws now. One of them also deleted every real queued suspension in the database. NOTHING HAS EVER RENDERED THIS CHART, which is how four broken targets shipped and stayed shipped. CI lints, renders and checks five targets now, including the per-Bot mode nothing rendered before, and a script that asks whether every secret key a container demands is one the chart writes. * Give the chart job the runtime its check needs * Address the second review: the frame goes back on turn identity, and the fixes stop breaking things Most of round two is consequences of round one, which is the honest summary. THE QUEUE WEDGED ITS OWN KEYS. An item at the attempt cap is not finished, so `claim` skipped it, `purge` did not match it, and `offer` cannot replace a row that is still there. The culler keys on the Bot id: five failed suspends and that Bot never scaled to zero again, silently and for good. Both kinds of done are reaped now, on the same window, which is also how long it waits before anything tries again. Giving up is logged rather than simply ceasing. PINNING THE DATABASE POLICY BROKE THE THINGS THAT USE IT. Only the API server carried the client label; the migration Job and the culler both open the database and neither did, so any cluster that actually enforces would have failed the install. My own probe could not have caught it, because that cluster ships enforcement switched off. THE FRAME GOES BACK ON THE TURN. Keying it on the page was a mistake with a plausible reason: two visits to one address collided, and letting the newer win made a past turn's picture change under the person reading it, which is the mutability this whole change exists to remove. It was chosen because the navigate handler seemed not to know its tool call. It does, on `context.toolCall.id`, which I assumed rather than checked. The row is written once and never updated. That leaves the race the old client-side guard used to cover: the screenshot is a second round trip, and with one computer shared by every Bot another Bot's navigation lands in the gap. The guard is back, on the side that now does the capturing. The capture also refuses to resume a suspended computer, so a convenience picture cannot undo a cull or hold a navigation open for a pod schedule. A TURN IS OVER WHETHER OR NOT IT GOT ANYWHERE. Settling on "do I have a page" left refused, failed and stopped navigations polling the live screen for ever under a finished answer, which are the turns where what is on screen has least to do with what is being read. And the control pill was the one affordance the merge did not teach: take the wheel mid-navigation, the turn settles, and a frozen picture from an hour ago asserted "You have control" with no way to hand it back. RESET NOW MEANS RESET. "Every login the Bot had is gone" was said while screenshots of the signed-in pages stayed in the database, readable from the transcript by anyone who could reach that Bot. The frames go with the profile, and a reaper takes the rest on a retention window, because a page is a row and nothing ever took anything out of that table. A REJECTED PROMISE WAS REMEMBERED FOR EVER. One unreadable token file at the wrong moment and every computer request for the pod's life failed with the same stale error, with no probe failing. And the chart's own gates were softer than they looked. `helm lint` reports a template `fail` as an INFO line and exits 0 even under `--strict`, which I drove rather than assumed, so it can never gate a refusal. Rendering can, and now does: CI asserts three refusals actually fire. The render check can no longer pass by matching nothing. `better-auth-secret` is optional only when it truly is, which also makes the existing-Secret path visible to that check. The policies render in a CI target for the first time. The subchart, its image and the lock are all pinned, and the lock is committed rather than ignored beside the tarballs it exists to pin. * Assert the example-key refusal only where it is armed * Arm the Bot-endpoint refusal under an external secret store too * Close the round-one gates: one dialect, one predicate, one shipped rule Four things that were reported as still open, and all four were. THE BOT SIDE HAD THE SAME DIALECT BUG AS THE SURFACE. A call read back from the thread store arrives as `{id, name, args}`, so `call.function` is empty: agent-bot defaulted every restored call to a tool named `tool` with no arguments, which is a call the model cannot recognise as the one it made, so it makes it again. That is the repetition the default was written to prevent, caused by the default. The LangGraph twin did not degrade at all, it dereferenced straight through and threw. Both read either spelling now, and the fallback is the last resort it was meant to be. THE SURFACE STILL PASSED ARGUMENTS THROUGH UNTOUCHED. AG-UI types them as a string and the store is under no such obligation, so a tool called with structured input produced a call that looked translated and failed validation anyway. Strings are passed through exactly, down to their whitespace, because a fragment of a stream that was never valid JSON is what the model actually said. AND IT DROPPED A TURN THAT CALLED A TOOL AND SAID NOTHING. The schema makes an assistant's content optional and does not allow null, so the two mean the same thing and only one parsed: the same loss as the dialect bug, by a different route. A person's turn is not the same case, and #207's decision to refuse and count that one stands. The tests that pinned multimodal content, null content and ordering are back, and the shapes were driven against the reader rather than assumed: the one I was most confident about, that a list of parts is refused, turned out to be wrong. THE PROFILE TEST PROVED ITS OWN COPY. It reimplemented the filter it was checking, so deleting the real one left the suite green and the fleet page listing `lost+found` as a Bot again. The rule is its own module now, imported by both, and removing the filter fails the test. * Run the reaper that was written, and keep frames through a rollout Two things asked for before merge, both mine, and the second was worse than reported. THE REAPER HAD NO CALLER. `computer_page_frame` had a purge, an index to serve it and a test proving it works, and nothing ever invoked it: written on every navigation, taken out by a profile wipe and by nothing else. The culler calls it, because that is already the sweep that runs on a schedule with a claim under it and a second timer would be a second thing to get wrong. Kept a month, which is long after anybody reads a conversation back. Deleting a Bot still leaves them, and that is left alone deliberately: a delete is soft and touches no computer state at all today, not the profile, not the browser, not the snapshots. Clearing only the screenshots would be the one half-measure that reads as though the rest had been handled. A SCREENSHOT THAT DOES NOT SAY WHAT IT IS OF IS THE ORDINARY CASE ON AN OLD COMPUTER. That field arrived after the first computers shipped. Refusing on a missing url therefore did not fail safe, it failed silently and completely: a fleet part-way through a rollout kept no frames at all and said nothing about why. The question is now asked where it means something. With a computer each there is nobody to race with and the picture can only be this turn's. On one shared browser another Bot's navigation lands in exactly that gap, so an unlabelled frame is still refused, and the rollout order that matters is written down where somebody upgrading will read it. And every refusal says so now. Two of the three returned quietly, under a docstring promising the opposite, which is how a deployment ends up keeping no frames with nothing in its logs to explain it. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay * Carry the per-Bot egress proxy as far as the process that reads it (#250) * Carry the per-Bot egress proxy as far as the process that reads it `EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_` are documented in .env.example and docs/configuration.md, and neither reached any process. docker-compose.yml named no EGRESS variable and had no `env_file`, and Compose hands a container only what those two blocks name. So the shared computer resolved every Bot to null and went out directly, and in the supervisor arrangement the supervisor's own environment held none either, leaving its EGRESS_PROXY passthrough with nothing to forward into the computers it creates. Nothing said so. The operator sets a proxy, the stack starts, the browser leaves by the host, and the Computers screen reports "Leaves directly" because it is reading the same empty environment. For a setting whose stated purpose is to give a security team a per-Bot address for network rules, silently doing nothing is the worst of the available failures. A file rather than more `environment:` entries because `EGRESS_PROXY_` is derived from a Bot's id, so there is no fixed set of names to write out here. A file of its own rather than .env because that one holds the deployment's secrets, and the container driving a browser and running a Bot's shell is deliberately given what it needs and not the rest. It is optional, since going out directly is the ordinary case and must still start, and gitignored, because a proxy URL can carry a password. The all-in-one image was never affected: its s6 service runs under `with-contenv` and inherits the container's environment, which is the mechanism this restores for Compose. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay * Refuse the shell and a workspace write while a person holds the wheel (#247) * Refuse the shell and a workspace write while a person holds the wheel `assertBotMayAct` was called in the navigate handler and the four action handlers, and nowhere else. `/exec` and `/files/write` were not covered, so a Bot could keep running commands and rewriting its workspace underneath somebody who had taken the browser at a login wall. The shell arrived after the wheel existed and was never wired to it. That is the property this codebase states outright. control.ts says every acting call from the Bot is refused while a person holds control, and the README says Bot actions are refused rather than queued. Both were false for the most powerful path the product exposes, and the server could not cover for it: control lives in this process, so those two call sites were the whole of the enforcement. The decision moves to `actsOnTheComputer` in authorisation.ts and is asked once by the dispatcher, after the session resolves. A per-handler check is the thing the next endpoint forgets, which is exactly how the shell came to be missing one; a list the dispatcher consults has to be added to instead. It lives beside the other path decision rather than in index.ts because that file imports Playwright at module scope, so a decision left there cannot be tested without Chrome. Reading stays open. `/files/read` and `/files/list` are not acting, and a Bot that has just been stopped still needs to say what it was doing. The two in-handler guards and their now-dead ControlError branches come out with it, so there is one place that answers this and not three. * Say in the changelog that the wheel now stops the shell A deployment behaves differently afterwards: an action that used to run during a takeover is refused, so it belongs here rather than only in the commit. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay * Carry the per-Bot egress proxy as far as the process that reads it (#250) * Carry the per-Bot egress proxy as far as the process that reads it `EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_` are documented in .env.example and docs/configuration.md, and neither reached any process. docker-compose.yml named no EGRESS variable and had no `env_file`, and Compose hands a container only what those two blocks name. So the shared computer resolved every Bot to null and went out directly, and in the supervisor arrangement the supervisor's own environment held none either, leaving its EGRESS_PROXY passthrough with nothing to forward into the computers it creates. Nothing said so. The operator sets a proxy, the stack starts, the browser leaves by the host, and the Computers screen reports "Leaves directly" because it is reading the same empty environment. For a setting whose stated purpose is to give a security team a per-Bot address for network rules, silently doing nothing is the worst of the available failures. A file rather than more `environment:` entries because `EGRESS_PROXY_` is derived from a Bot's id, so there is no fixed set of names to write out here. A file of its own rather than .env because that one holds the deployment's secrets, and the container driving a browser and running a Bot's shell is deliberately given what it needs and not the rest. It is optional, since going out directly is the ordinary case and must still start, and gitignored, because a proxy URL can carry a password. The all-in-one image was never affected: its s6 service runs under `with-contenv` and inherits the container's environment, which is the mechanism this restores for Compose. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --------- Co-authored-by: Guido Vizoso Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay --------- Co-authored-by: Guido Vizoso Co-authored-by: David McKay Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: beardthelion <56458543+beardthelion@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++++++++++ app/src/lib/copilot/active-bot.tsx | 21 +++++++++++++++++++++ app/src/lib/copilot/gallery-tools.tsx | 7 +++++-- app/src/lib/copilot/sandboxed-tools.tsx | 7 +++++-- app/tests/declared-bot.test.ts | 21 +++++++++++++++++++++ 5 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 app/tests/declared-bot.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9acfaa3f..bf9f33e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -266,6 +266,20 @@ given them. It is optional, so a deployment with no proxy is unchanged, and giti proxy URL can carry a password. **Move these two out of `.env` and into `egress.env`.** In `.env` they reach no process. +### Screens without a conversation stop polling for a Bot that does not exist + +Every surface asks which components its Bot holds, and asks again every few seconds so a revoked +grant leaves an open conversation quickly. The Bot it asked about was whichever one the surface +declared — and on a screen with no conversation at all, that was the placeholder id the routing +holder falls back to, which no package registers and the server answers 404 for. An admin page left +open polled a guaranteed miss every five seconds, indefinitely. + +Nothing looked wrong. The screen rendered, because an absent grant list and an empty one draw the +same. The cost was the noise: a request log where the same 404 repeats forever is one where the 404 +that matters is invisible. + +The grant queries now wait for a surface to declare a real Bot, and simply do not run while the +placeholder holds. Conversation surfaces — the Bot page, channels — declare one and are unchanged. ### Knowledge searches instead of guessing diff --git a/app/src/lib/copilot/active-bot.tsx b/app/src/lib/copilot/active-bot.tsx index b66da3aa..dfb8dda2 100644 --- a/app/src/lib/copilot/active-bot.tsx +++ b/app/src/lib/copilot/active-bot.tsx @@ -73,3 +73,24 @@ export function useActiveBotHolder(): BotHolder { export function useActiveBotId(): string { return useContext(ActiveBotValueContext)?.botId ?? DEFAULT_BOT_ID; } + +/** + * The active Bot when a surface has declared one, and undefined while the placeholder holds. + * + * The placeholder exists so a handler always has something to route with, but it is not a Bot: no + * package registers an agent by that name, and the server answers 404 for it. A grant query handed + * the placeholder therefore polls a guaranteed miss on its own interval — every few seconds, on + * every screen without a conversation — and the constant failing request is noise that would bury a + * real 404 the day one matters. A query given undefined instead simply does not run. + * + * The name is already reserved in practice: an agents.yaml entry called "default" would be + * indistinguishable from the placeholder in every handler that reads the holder. + */ +export function declaredBotId(botId: string): string | undefined { + return botId === DEFAULT_BOT_ID ? undefined : botId; +} + +/** As useActiveBotId, for callers that should do nothing while the placeholder holds. */ +export function useDeclaredBotId(): string | undefined { + return declaredBotId(useActiveBotId()); +} diff --git a/app/src/lib/copilot/gallery-tools.tsx b/app/src/lib/copilot/gallery-tools.tsx index 26b39a2a..b421df73 100644 --- a/app/src/lib/copilot/gallery-tools.tsx +++ b/app/src/lib/copilot/gallery-tools.tsx @@ -9,7 +9,7 @@ import { decideComponent, type GrantedComponent, } from "@/lib/components/queries"; -import { useActiveBotId } from "@/lib/copilot/active-bot"; +import { useActiveBotId, useDeclaredBotId } from "@/lib/copilot/active-bot"; import { GALLERY_COMPONENTS, type GalleryComponent, @@ -32,7 +32,10 @@ export function GalleryTools() { // Active Bot comes from the route/channel surface currently driving the provider. const grantsFor = useActiveBotId(); - const { data: granted } = useQuery(agentComponentsQueryOptions(grantsFor)); + // See sandboxed-tools: the placeholder Bot is not fetchable, so the grant query waits for a + // surface to declare a real one. + const declared = useDeclaredBotId(); + const { data: granted } = useQuery(agentComponentsQueryOptions(declared)); const held = useMemo( () => new Map( diff --git a/app/src/lib/copilot/sandboxed-tools.tsx b/app/src/lib/copilot/sandboxed-tools.tsx index 5b570c01..f69aa43f 100644 --- a/app/src/lib/copilot/sandboxed-tools.tsx +++ b/app/src/lib/copilot/sandboxed-tools.tsx @@ -12,7 +12,7 @@ import { decideComponent, type GrantedComponent, } from "@/lib/components/queries"; -import { useActiveBotId } from "@/lib/copilot/active-bot"; +import { useActiveBotId, useDeclaredBotId } from "@/lib/copilot/active-bot"; import { type PublishedSandboxed, publishedSandboxedQueryOptions, @@ -24,8 +24,11 @@ import { */ export function SandboxedTools() { const botId = useActiveBotId(); + // Grants are fetched only once a surface has declared its Bot; the placeholder is not one the + // server knows, and asking would 404 on every poll. + const declared = useDeclaredBotId(); const { data: published } = useQuery(publishedSandboxedQueryOptions()); - const { data: granted } = useQuery(agentComponentsQueryOptions(botId)); + const { data: granted } = useQuery(agentComponentsQueryOptions(declared)); const held = new Map( (granted ?? []).map((component: GrantedComponent) => [ diff --git a/app/tests/declared-bot.test.ts b/app/tests/declared-bot.test.ts new file mode 100644 index 00000000..5b7b2244 --- /dev/null +++ b/app/tests/declared-bot.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { declaredBotId } from "../src/lib/copilot/active-bot"; + +/** + * The placeholder Bot id is a routing convenience, not a Bot. Anything that would ask the server + * about it must be told there is nothing to ask about. + */ + +describe("declaredBotId", () => { + test("returns undefined for the placeholder", () => { + expect(declaredBotId("default")).toBeUndefined(); + }); + + test("passes a declared Bot through", () => { + expect(declaredBotId("general-assistant")).toBe("general-assistant"); + }); + + test("passes a Bot through even when the placeholder is its prefix", () => { + expect(declaredBotId("default-2")).toBe("default-2"); + }); +}); From 43ea5c11210c485551c25b41a4270c56a58591f1 Mon Sep 17 00:00:00 2001 From: anygivenfriday Date: Wed, 26 Aug 2026 08:24:39 -0700 Subject: [PATCH 13/13] Refuse a port that answers but is not OpenBot, and stop compose blanking the tokens (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: refuse a port that answers but is not OpenBot `curl -f` proves something is listening and returned 2xx. The checks here treated that as proof the port belonged to this stack, and the two are not the same claim: any single-page app serves its index.html for every path it does not recognise, so an unrelated dashboard on a default port answers 200 to `/api/capabilities` exactly as readily as this server does. The gap did not surface as a wrong answer. It surfaced as a wrong answer three stages later. `require_free_or_ours` reported "already up", so the server was never started; `wait_for` then printed a green "server ready"; and the run died at stage 3 inside `json.loads`, on a mouthful of that stranger's HTML. A JSON parse error standing in for "port 3001 belongs to something else" -- and the message says `char 0`, which reads like empty input rather than a `<`. So `identifies_as_openbot` asks each surface for something only it can produce: a `licenseStatus` field for the server, its own `` for the app, `/health` for the compose services, which already sit on dedicated loopback ports. `wait_for_openbot` loops on that rather than on any 200, and says which of the two failures happened when it gives up. The root cause is in .env.example, and is fixed there too: the server reads PORT while this script reads SERVER_PORT, docs/configuration.md documents SERVER_PORT as the setting, and only PORT shipped. Moving the server by editing that one line left the script still pointed at 3001. `wait_for` is unchanged and still used for the three agent containers. * fix: default the token variables to what start.sh already uses `${SUPERVISOR_TOKEN:-}` and `${COMPUTER_TOKEN:-}` default to empty, so the stack you get depends on how you brought it up. scripts/start.sh resolves both to `openbot-dev-*` defaults and exports them before calling compose, so the script's stack is authenticated. A plain `docker compose up -d` -- which this project's own shutdown notes tell you to use -- passes the empty string instead. agent-computer refuses to start without one, so that half fails loudly. The supervisor half is the quiet one: the server keeps the token it was started with while the supervisor holds an empty string, and every call between them is refused at the door. Compose already defaults COMPUTER_IMAGE this way two lines down. These now match the values start.sh applies, so both routes configure the same stack. Both reach services whose exposure is unchanged by this, and a deployment sets real values in .env, which still wins. * docs: record both startup fixes in the changelog * Channel pin and soft delete, and a Notion connector over hosted MCP (#242) * Let a member pin a channel and soft-delete it, from a right-click menu * Land the caret in the composer once a coworker is chosen * Calm the screen panel down and make the full-size view a card * Let a Bot's message take the whole transcript column * Put Notion in the catalogue, and let a vendor register its OAuth client dynamically * Rotate refresh tokens in place, serialised per connection, and recover an evicted client * Introduce the deployment to a dynamic vendor on first connect * Show Notion in the plugin screens, without a client form it does not need * Say what the Notion connector is, everywhere the catalogue is described * Grant a batch of tools to Bots from the vendor page * Hold the vault row while a rotating token is spent, so replicas take turns * Refuse to mint a second client inside the re-registration window * Tell every member's roster when a channel is deleted, again * Leave a who-and-when behind a soft delete, again * Carry a pin across one person's own tabs * Say the classification direction right everywhere a person reads it * Refuse to whisper into a deleted channel `get` and `list` filter on `deleted_at`; `recordActivity` and `setPinned` did not. Activity POSTed to a soft-deleted channel returned 204, bumped `last_message`, and announced it to every member, each of whom then refetched a roster for a row it cannot show; a pin on one succeeded the same way. Both now join the channel and require it undeleted, throwing ChannelNotFoundError to match `get`, which also keeps the notify off the refused path since it is written inside the transaction. The roster's second query repeats the same filter. It selects the page and then joins the agents to it in a separate statement on a separate snapshot, so a delete committing between the two would hand back a channel this person can no longer see. * Hold a pinned channel at the top of the roster, not the page The roster ordered by recency alone and the client lifted pinned rows at render, so a pin only reached the top of whatever pages were loaded: a channel somebody pinned and then did not talk to for a month sat on page three and never appeared above anything. The promise is about the roster, so the ordering belongs in the query. The page now orders by the pin first and the cursor carries it as the leading element. Every part of the sort descends — a pin is 1 and no pin is 0 — which keeps the keyset predicate a single row comparison rather than a nest of ORs, and a cursor minted before the pin existed reads as the first page, like any other cursor describing an ordering this query no longer has. `pinnedFirst` stays in the sidebar as the render-level mirror, for the window between refetches: the socket patches a pin onto a loaded row without moving it, and re-sorts a page by recency alone. Its comment now says that is what it is for, rather than claiming to be where the rule lives. * Read a vendor's garbage as a refusal, not a crash * Keep the wheel reachable when the screen has nothing to show Take control and Hand back live in the full-size view, and the only way in was disabled unless there was a picture to open. So a blank browser, a screenshot that had not arrived, or a computer that could not be reached left a person with no way to take the wheel at all - the three states where they most want it. The frame now opens whatever is in it, and with nothing to draw the full-size view reserves the same shape and says the same words the card does, with the wheel underneath them. Somebody already driving keeps the live socket, whatever is on the page: once a person holds the wheel the stream is the truth about it. The Bot ASKING for the wheel comes back to the card as its own amber row with the reason on it, which is what the rework dropped. It is not the persistent footer that was deliberately removed - it is there only while the request is, next to the credential form, which is the other thing a stuck Bot needs. * Answer pin and delete failures where they happened Three things this row did quietly. A refused delete stayed on the mutation, so reopening the confirm showed a stale 409 about an attempt nobody had made yet; the menu resets it on the way in. A failed pin said nothing at all - the menu closed, the pin did not move, and that reads as the app ignoring the click - so the sentence now lands on the row, there being no toast in this app. And a delete of the channel on screen navigated home after the write. The roster invalidates the moment it lands, which unmounts this row and the dialog inside it, so the navigate belonged to a component that was already gone. Leaving first is safe in the other direction: a refusal puts them on the roster with the channel still in it, and says why. * Grant a batch with one refetch and a progress count Two Bots and twelve tools is twenty-four writes, and every one of them went through the grant mutation - which invalidates every plugin query and waits for the refetch. Most of the wait was re-reading a list hidden behind the dialog. The write is now its own function with no refetch attached, and the dialog invalidates once when the loop is done, including after a refusal, because the grants before it landed. The button says which of the N is in flight rather than only "Granting", so a slow batch can be told from a stuck one, and each set of tickboxes is a fieldset named by the heading already above it - "Changes things" is the whole warning on those tools, and a listener would otherwise never hear it. * Sweep the code the screen rework orphaned `hasBrowsed` had no callers left once the screen and the activity log stopped being tabs that had to guess which one to open, and the placeholder artwork went with the blank-browser strip it decorated. The note itself stays: the tool handler is the only place the fact exists, and a screenshot cannot answer it. The composer's autofocus is a mount-time courtesy, claimed once. Keyed off the editor becoming interactive, it re-fired on every disabled or busy transition, so a completed turn yanked the caret back from wherever the person had moved it. A send of their own still returns it - that one they asked for. * Stop pretending a new client can spend an old grant * Let two first connects race to one client * Cap, revoke and say what refresh saw * Seal the consent state, not just sign it * Refuse a consent that outlived the person's access * Run OpenBot on Kubernetes: Bots and all, proven on EKS (#235) * Run OpenBot on Kubernetes: a Helm chart, and what installing it found One chart for EKS, GKE, AKS and somebody's own cluster, with nothing but values between them. No cloud branching in any template: every place the clouds differ is a value whose default is what a plain self-hosted cluster does. Identity is one annotations map, because that is all IRSA, Workload Identity and AKS workload identity are. Secrets are a plain Secret by default and an ExternalSecret against any backend when asked. Two replicas by default, because horizontal is the point and one hides every bug that is not. A bad install is refused at helm install naming the value to change, rather than found in a crash loop. Three things only a real install could find: drizzle-kit cannot migrate in the shipped image. It reads a TypeScript config, which needs the esbuild that bun install --production leaves out, so it printed one line, exited 1 and said nothing. EMBEDDED_POSTGRES=on was starting containers whose database was never migrated. The migrator inside drizzle-orm is a runtime dependency already and keeps the same journal. sessionOf answered from a map in the process that started the computer, which is right until there are two of them. The replica taking a snapshot is usually not the one handling the click, and an unknown session skips the generation check rather than failing it, so the check that stops a ref from a replaced computer resolving against a live one was silently absent on the shape it was written for. It now asks by listing, never by ensuring, so asking cannot start a computer that had stopped. A browser in an API pod cannot be replicated, so the image's computer gets the same switch its database has. * Give Bots computers on Kubernetes, and suspend them when idle The chart had no computer, so no Bot could do anything on a cluster. It has one now, and a Bot has driven a real browser on real EKS with the decision in the audit trail. computers.mode picks the shape. shared runs one browser for every Bot and needs nothing installed. sandbox gives each Bot its own as a Sandbox from kubernetes-sigs/agent-sandbox, which is built for exactly this: an isolated stateful singleton with a stable identity and persistent storage, where suspending is a field that keeps the volumes, so a computer comes back with its logins rather than signed out. What decides a computer is idle is the audit trail, not the browser. Asking the browser wakes it, so every computer anything asked about would come back up and the bill would never fall. The work is claimed and leased out of Postgres with for update skip locked. Three features need that one mechanism, so it is written once with all three in view: the culler here, routines, and a hop from one Bot to another. A CronJob runs the sweep rather than a timer in the API, because a timer fires in every replica and suspending a browser somebody just started using is not something to do five times. Also: a fresh EKS cluster very often has no default StorageClass. eksctl creates gp2, unmarked and on the in-tree provisioner current Kubernetes no longer has, so a volume asking for the default never binds and nothing says why. Found on a real 1.34 cluster and written down where somebody configuring one will read it. * Refuse a sandbox install on a cluster that cannot make one computers.mode: sandbox creates Sandbox objects, which exist only once the agent-sandbox controller is installed. Without it the install succeeds, every pod is healthy, and the deployment looks finished right up until the first Bot asks for a browser and the API server answers 404. That is the worst moment to learn it. The check reads the cluster rather than a value somebody has to remember to set, and the message carries the one command that fixes it. Proven both ways: refused on a cluster with no CRD, installs on the EKS cluster that has one. Also from driving it on real EKS: lost+found was listed as a Bot, because an EBS volume is ext4 and arrives with that directory, which a bind mount never does. The allow-list that stops a hostile id becoming a path answers the other half of the question too. The migration Job named a ServiceAccount that does not exist yet, since a pre-install hook runs before the chart's own resources. It talks to a database and never to the cluster, so it needs no account at all. The API pod gets a cluster token only in sandbox mode, the pods roll when the computer template changes, the Sandbox asks for a Service so it has an address that survives a resume, and the cluster CA is actually used when talking to the API server. * Tell one run of a computer from the next across a suspend A resumed browser counts snapshot generations from one again, so a ref the model still holds from before the suspend matches a row nothing has overwritten, and the boundary decides about an element on a page that no longer exists. The first answer used the node and the pod address. Resuming a real computer on EKS disproved it: a suspended sandbox is very often rescheduled onto the same node and handed the same address back, and both were identical across the cycle, so the check would have said same run for the exact case it exists to catch. The Ready condition's transition time moves whenever a computer starts serving again, needs no permission beyond the sandbox already read, and is precisely the question. Driven on EKS: a ref taken before a suspend is refused after the resume, naming why, and a fresh ref from a new snapshot clicks through. * Let the policy reach the computers, and refuse one that fences off the database The NetworkPolicy allowed DNS and the bundled database. Nothing let the API reach a Bot's computer, which it does for every browser action, and nothing let it reach a managed database, whose address this chart cannot know. On a cluster that enforces policy both are outages that read as something else: the API looks broken rather than fenced. The computers and the API server are allowed now, and turning the policy on with an external database and no rule for it is refused with the shape of the rule to add. None of this showed up by installing it, because EKS runs its CNI with --enable-network-policy=false and the policy is inert there. That is worth knowing on its own, so it is written down: a policy that installs, looks right, and does nothing is worse than one that is off. Also driven on EKS: reset takes the volumes with it and the Bot gets a clean profile afterwards, and the HPA reads real metrics. * Keep the browsing that produced an answer Every turn in which a Bot used a tool vanished from the transcript on reload. The sentence the Bot wrote stayed, the browsing that produced it did not, the inline screen went with it, and the footer said some messages could not be read. The history store writes a tool call as {id, name, args}; AG-UI describes {id, type: function, function: {name, arguments}}. The reader validated against the second and treated the first as damage from an interrupted run. It is not damage, it is how every tool call is stored, so a guard written against one bad turn was deleting all the real ones. Found by driving a real conversation on the EKS deployment rather than by reading: two browsing turns, both counted unreadable, both well formed in the store's own dialect. Both spellings now read as the same thing. A mixed or unrecognised array is still refused rather than half-translated, because a reader that rewrites what it does not recognise is worse than one that refuses it. * Show the page a finished turn opened, not the one open now Reopening a conversation made every past turn fetch the screen as it is now, so an answer about Hacker News from an hour ago sat under a picture of whatever the Bot had open since. The frame was live and the caption was not, and the turn read as though it had browsed somewhere it never went. A turn that has finished is history, and history is not polled. It names the page that turn actually left open, which the tool result already carried. Nothing changes while a turn runs: those frames are its own and freeze where it left them. It names the page rather than showing it, because nothing stored the picture and fetching one now would show a different page. Naming it stays true however many times the Bot has browsed since. Driven on EKS: three turns, three different pages, each holding its own across a reload. * Keep the frame a browsing turn ended on Reopening a conversation made every past turn fetch the screen as it is now, so an answer about one page sat under a picture of whatever the Bot had open since. A browsing turn keeps its last frame in computer_turn_frame, filed under the tool call and written once, because a turn that has happened does not happen differently later. Three things had to be true together and each was wrong on its own first. The frame is read at the moment the turn ends, since a short turn finishes before the tile has polled anything. Restoring a kept frame must not make the turn look live again, which the first version did: it counted a turn as history only while it had no picture, so restoring one restarted the polling that then replaced it. And a turn is over when it has a result rather than when its status says so, because a restored tool call arrives with its result in hand and a status that is briefly something else. Found by watching the network on the deployed cluster rather than by reading: two live screenshot reads before the restore, on every reload. * Keep a turn's frame only when it is a frame of that turn's page The capture ran at the end of a turn and took whatever the screen showed then. That is usually right and sometimes badly wrong: the same computer is driven by other conversations, a resumed one starts blank, and a short turn finishes before the tile has polled anything. So an answer about one page could be filed with a picture of another, which is worse than having no picture at all. A frame is now kept only when its own url is the page the turn opened. Unknown counts as no match, because storing on unknown is how the wrong picture gets kept. Also folds the restore and the capture into one effect asked in order: what is stored first, the live screen only if nothing is. Two effects racing is what made a reopened turn restore the right frame and then overwrite it with a fresh screenshot one render later, which the console showed plainly once I stopped guessing and logged it. * Photograph the page where it is opened, not where it is read back The transcript's inline screen used to capture its own frame after the turn ended, and file it under the tool call. That is a race it cannot win. A reopened turn and one that has just finished look identical from inside the component, the same computer is driven by other conversations in between, and a resumed computer starts blank, so the picture filed was routinely of somewhere the turn never went, or of nothing. The frame is now taken on the server the moment a navigation succeeds, which is the one moment the screen is certainly showing the page that was asked for, and kept per computer and page rather than per tool call. The surface only reads. Failing to take the picture never fails the navigation. * Open the Bot screen on a Bot this deployment has Three things a fork trips over. The Bot screen defaulted to a coworker named risk-analyst, which is a name from one tenant package and a crash on every other. OpenBot exists to be forked, so a Bot id written into a route is a defect on all but the deployment it came from: the screen took the whole page down to an unstyled error boundary. It now opens on whatever Bot this deployment actually has, and answers a mistyped name in a sentence. The audit trail wrote "not in the current snapshot" against every navigation, file read and command. That sentence is about a ref the server could not resolve, and deciding it by elimination put it on actions that never named an element at all, sending a reader looking for a snapshot nobody took. It is keyed on the ref now. The chart had no way to point at anyone's own AG-UI Bot, which is the seam the whole product is about. config.managedAgent.url and secrets.managedAgentToken, refused at install if one is set without the other. * Format the regenerated migration snapshot * Fix what the review found, and make CI able to find it next time Every claim driven against the real thing rather than read, and all but two held. THE QUEUE. Leases were computed on the replica's clock and compared against the database's, which is two clocks pretending to be one: a node ninety seconds behind wrote a sixty-second lease that arrived already expired, and the next replica took the item out from under it. Both ran it. `finish` and `release` matched on the key alone, so a replica whose lease had quietly gone deleted or rescheduled work another was executing. Nothing ever renewed, and the culler took twenty items on one lease. `finish` deleted the row, destroying the idempotence this table's own comment promises: the insert a re-offer was meant to collide with had nothing left to collide with. And a permanently failing item retried until somebody noticed, which on a queue with no dashboard is never. Every moment is named in SQL now, all three lease calls ask the same question, the culler renews between items, a finished row stays until a retention sweep takes it, and an item that runs out of attempts stops with its count and its reason where a person can query them. The probe that reproduced the first two comes back clean. THE CHART could not start a server on any of its four shipped targets: each configured sign-in and none supplied the secret sessions are signed with, and one carried the public example encryption key. Driven on a real cluster, watched to crash-loop, fixed, watched to come up ready. Both states are refused at install now, including the one hiding behind an external secret store, where the value is unreadable but the list of keys is not. THE DATABASE WAS REACHABLE FROM THE BOT'S BROWSER. Compose has kept those apart since the beginning; the chart dropped it, and the bundled database shipped a policy admitting any pod in any namespace on 5432. Proven by opening a socket from the browser pod. Pinned, plus a policy for the computer itself, which had none. That pod also carried a cluster credential it has no use for, which it no longer does: verified on a recreated per-Bot computer. The service account token was read once and held for the life of the process. Projected tokens rotate on a schedule the cluster picks, so sandbox calls work until the first rotation and then all return 401, which reads like the cluster broke. THE TRANSCRIPT still lied in two places. A finished turn holding a stale live frame fell through to "Waiting for the assistant's screen…" and waited there for ever, because the poll that would end the wait stops when a turn settles. And zooming a past turn mounted the live stream and offered Take control, so the one gesture for looking closer at what a turn did replaced it with whatever the Bot has open now. The kept frame exists to stop exactly that. TWO TESTS passed a pool-options object where a connection string belongs and were green for a reason unrelated to what they check, because the test tree is not type-checked. That is its own sweep; the misuse throws now. One of them also deleted every real queued suspension in the database. NOTHING HAS EVER RENDERED THIS CHART, which is how four broken targets shipped and stayed shipped. CI lints, renders and checks five targets now, including the per-Bot mode nothing rendered before, and a script that asks whether every secret key a container demands is one the chart writes. * Give the chart job the runtime its check needs * Address the second review: the frame goes back on turn identity, and the fixes stop breaking things Most of round two is consequences of round one, which is the honest summary. THE QUEUE WEDGED ITS OWN KEYS. An item at the attempt cap is not finished, so `claim` skipped it, `purge` did not match it, and `offer` cannot replace a row that is still there. The culler keys on the Bot id: five failed suspends and that Bot never scaled to zero again, silently and for good. Both kinds of done are reaped now, on the same window, which is also how long it waits before anything tries again. Giving up is logged rather than simply ceasing. PINNING THE DATABASE POLICY BROKE THE THINGS THAT USE IT. Only the API server carried the client label; the migration Job and the culler both open the database and neither did, so any cluster that actually enforces would have failed the install. My own probe could not have caught it, because that cluster ships enforcement switched off. THE FRAME GOES BACK ON THE TURN. Keying it on the page was a mistake with a plausible reason: two visits to one address collided, and letting the newer win made a past turn's picture change under the person reading it, which is the mutability this whole change exists to remove. It was chosen because the navigate handler seemed not to know its tool call. It does, on `context.toolCall.id`, which I assumed rather than checked. The row is written once and never updated. That leaves the race the old client-side guard used to cover: the screenshot is a second round trip, and with one computer shared by every Bot another Bot's navigation lands in the gap. The guard is back, on the side that now does the capturing. The capture also refuses to resume a suspended computer, so a convenience picture cannot undo a cull or hold a navigation open for a pod schedule. A TURN IS OVER WHETHER OR NOT IT GOT ANYWHERE. Settling on "do I have a page" left refused, failed and stopped navigations polling the live screen for ever under a finished answer, which are the turns where what is on screen has least to do with what is being read. And the control pill was the one affordance the merge did not teach: take the wheel mid-navigation, the turn settles, and a frozen picture from an hour ago asserted "You have control" with no way to hand it back. RESET NOW MEANS RESET. "Every login the Bot had is gone" was said while screenshots of the signed-in pages stayed in the database, readable from the transcript by anyone who could reach that Bot. The frames go with the profile, and a reaper takes the rest on a retention window, because a page is a row and nothing ever took anything out of that table. A REJECTED PROMISE WAS REMEMBERED FOR EVER. One unreadable token file at the wrong moment and every computer request for the pod's life failed with the same stale error, with no probe failing. And the chart's own gates were softer than they looked. `helm lint` reports a template `fail` as an INFO line and exits 0 even under `--strict`, which I drove rather than assumed, so it can never gate a refusal. Rendering can, and now does: CI asserts three refusals actually fire. The render check can no longer pass by matching nothing. `better-auth-secret` is optional only when it truly is, which also makes the existing-Secret path visible to that check. The policies render in a CI target for the first time. The subchart, its image and the lock are all pinned, and the lock is committed rather than ignored beside the tarballs it exists to pin. * Assert the example-key refusal only where it is armed * Arm the Bot-endpoint refusal under an external secret store too * Close the round-one gates: one dialect, one predicate, one shipped rule Four things that were reported as still open, and all four were. THE BOT SIDE HAD THE SAME DIALECT BUG AS THE SURFACE. A call read back from the thread store arrives as `{id, name, args}`, so `call.function` is empty: agent-bot defaulted every restored call to a tool named `tool` with no arguments, which is a call the model cannot recognise as the one it made, so it makes it again. That is the repetition the default was written to prevent, caused by the default. The LangGraph twin did not degrade at all, it dereferenced straight through and threw. Both read either spelling now, and the fallback is the last resort it was meant to be. THE SURFACE STILL PASSED ARGUMENTS THROUGH UNTOUCHED. AG-UI types them as a string and the store is under no such obligation, so a tool called with structured input produced a call that looked translated and failed validation anyway. Strings are passed through exactly, down to their whitespace, because a fragment of a stream that was never valid JSON is what the model actually said. AND IT DROPPED A TURN THAT CALLED A TOOL AND SAID NOTHING. The schema makes an assistant's content optional and does not allow null, so the two mean the same thing and only one parsed: the same loss as the dialect bug, by a different route. A person's turn is not the same case, and #207's decision to refuse and count that one stands. The tests that pinned multimodal content, null content and ordering are back, and the shapes were driven against the reader rather than assumed: the one I was most confident about, that a list of parts is refused, turned out to be wrong. THE PROFILE TEST PROVED ITS OWN COPY. It reimplemented the filter it was checking, so deleting the real one left the suite green and the fleet page listing `lost+found` as a Bot again. The rule is its own module now, imported by both, and removing the filter fails the test. * Run the reaper that was written, and keep frames through a rollout Two things asked for before merge, both mine, and the second was worse than reported. THE REAPER HAD NO CALLER. `computer_page_frame` had a purge, an index to serve it and a test proving it works, and nothing ever invoked it: written on every navigation, taken out by a profile wipe and by nothing else. The culler calls it, because that is already the sweep that runs on a schedule with a claim under it and a second timer would be a second thing to get wrong. Kept a month, which is long after anybody reads a conversation back. Deleting a Bot still leaves them, and that is left alone deliberately: a delete is soft and touches no computer state at all today, not the profile, not the browser, not the snapshots. Clearing only the screenshots would be the one half-measure that reads as though the rest had been handled. A SCREENSHOT THAT DOES NOT SAY WHAT IT IS OF IS THE ORDINARY CASE ON AN OLD COMPUTER. That field arrived after the first computers shipped. Refusing on a missing url therefore did not fail safe, it failed silently and completely: a fleet part-way through a rollout kept no frames at all and said nothing about why. The question is now asked where it means something. With a computer each there is nobody to race with and the picture can only be this turn's. On one shared browser another Bot's navigation lands in exactly that gap, so an unlabelled frame is still refused, and the rollout order that matters is written down where somebody upgrading will read it. And every refusal says so now. Two of the three returned quietly, under a docstring promising the opposite, which is how a deployment ends up keeping no frames with nothing in its logs to explain it. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> * Carry the per-Bot egress proxy as far as the process that reads it (#250) * Carry the per-Bot egress proxy as far as the process that reads it `EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_<BOT>` are documented in .env.example and docs/configuration.md, and neither reached any process. docker-compose.yml named no EGRESS variable and had no `env_file`, and Compose hands a container only what those two blocks name. So the shared computer resolved every Bot to null and went out directly, and in the supervisor arrangement the supervisor's own environment held none either, leaving its EGRESS_PROXY passthrough with nothing to forward into the computers it creates. Nothing said so. The operator sets a proxy, the stack starts, the browser leaves by the host, and the Computers screen reports "Leaves directly" because it is reading the same empty environment. For a setting whose stated purpose is to give a security team a per-Bot address for network rules, silently doing nothing is the worst of the available failures. A file rather than more `environment:` entries because `EGRESS_PROXY_<BOT>` is derived from a Bot's id, so there is no fixed set of names to write out here. A file of its own rather than .env because that one holds the deployment's secrets, and the container driving a browser and running a Bot's shell is deliberately given what it needs and not the rest. It is optional, since going out directly is the ordinary case and must still start, and gitignored, because a proxy URL can carry a password. The all-in-one image was never affected: its s6 service runs under `with-contenv` and inherits the container's environment, which is the mechanism this restores for Compose. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> * Refuse the shell and a workspace write while a person holds the wheel (#247) * Refuse the shell and a workspace write while a person holds the wheel `assertBotMayAct` was called in the navigate handler and the four action handlers, and nowhere else. `/exec` and `/files/write` were not covered, so a Bot could keep running commands and rewriting its workspace underneath somebody who had taken the browser at a login wall. The shell arrived after the wheel existed and was never wired to it. That is the property this codebase states outright. control.ts says every acting call from the Bot is refused while a person holds control, and the README says Bot actions are refused rather than queued. Both were false for the most powerful path the product exposes, and the server could not cover for it: control lives in this process, so those two call sites were the whole of the enforcement. The decision moves to `actsOnTheComputer` in authorisation.ts and is asked once by the dispatcher, after the session resolves. A per-handler check is the thing the next endpoint forgets, which is exactly how the shell came to be missing one; a list the dispatcher consults has to be added to instead. It lives beside the other path decision rather than in index.ts because that file imports Playwright at module scope, so a decision left there cannot be tested without Chrome. Reading stays open. `/files/read` and `/files/list` are not acting, and a Bot that has just been stopped still needs to say what it was doing. The two in-handler guards and their now-dead ControlError branches come out with it, so there is one place that answers this and not three. * Say in the changelog that the wheel now stops the shell A deployment behaves differently afterwards: an action that used to run during a takeover is refused, so it belongs here rather than only in the commit. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> * Carry the per-Bot egress proxy as far as the process that reads it (#250) * Carry the per-Bot egress proxy as far as the process that reads it `EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_<BOT>` are documented in .env.example and docs/configuration.md, and neither reached any process. docker-compose.yml named no EGRESS variable and had no `env_file`, and Compose hands a container only what those two blocks name. So the shared computer resolved every Bot to null and went out directly, and in the supervisor arrangement the supervisor's own environment held none either, leaving its EGRESS_PROXY passthrough with nothing to forward into the computers it creates. Nothing said so. The operator sets a proxy, the stack starts, the browser leaves by the host, and the Computers screen reports "Leaves directly" because it is reading the same empty environment. For a setting whose stated purpose is to give a security team a per-Bot address for network rules, silently doing nothing is the worst of the available failures. A file rather than more `environment:` entries because `EGRESS_PROXY_<BOT>` is derived from a Bot's id, so there is no fixed set of names to write out here. A file of its own rather than .env because that one holds the deployment's secrets, and the container driving a browser and running a Bot's shell is deliberately given what it needs and not the rest. It is optional, since going out directly is the ordinary case and must still start, and gitignored, because a proxy URL can carry a password. The all-in-one image was never affected: its s6 service runs under `with-contenv` and inherits the container's environment, which is the mechanism this restores for Compose. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> * Stop grant queries polling the placeholder Bot (#240) * fix: stop grant queries polling the placeholder Bot Every surface polls which components its Bot holds, so a revoked grant leaves an open conversation within seconds. On a screen with no conversation, the Bot it polled about was the placeholder id the routing holder falls back to — which no package registers and the server 404s. An admin page left open asked a guaranteed miss every five seconds, forever. Nothing looked broken: an absent grant list and an empty one render identically. The cost is that a request log where the same 404 repeats indefinitely is one where a 404 that matters is invisible. `declaredBotId` names the distinction the holder already had but nothing could ask about: the placeholder exists so a handler always has something to route with, but it is not a Bot. The grant queries now take the declared id and their existing `enabled` guard does the rest — undefined simply does not run. Conversation surfaces declare a real Bot and are unchanged, as are the call-time checks, which never trusted the poll anyway. * Channel pin and soft delete, and a Notion connector over hosted MCP (#242) * Let a member pin a channel and soft-delete it, from a right-click menu * Land the caret in the composer once a coworker is chosen * Calm the screen panel down and make the full-size view a card * Let a Bot's message take the whole transcript column * Put Notion in the catalogue, and let a vendor register its OAuth client dynamically * Rotate refresh tokens in place, serialised per connection, and recover an evicted client * Introduce the deployment to a dynamic vendor on first connect * Show Notion in the plugin screens, without a client form it does not need * Say what the Notion connector is, everywhere the catalogue is described * Grant a batch of tools to Bots from the vendor page * Hold the vault row while a rotating token is spent, so replicas take turns * Refuse to mint a second client inside the re-registration window * Tell every member's roster when a channel is deleted, again * Leave a who-and-when behind a soft delete, again * Carry a pin across one person's own tabs * Say the classification direction right everywhere a person reads it * Refuse to whisper into a deleted channel `get` and `list` filter on `deleted_at`; `recordActivity` and `setPinned` did not. Activity POSTed to a soft-deleted channel returned 204, bumped `last_message`, and announced it to every member, each of whom then refetched a roster for a row it cannot show; a pin on one succeeded the same way. Both now join the channel and require it undeleted, throwing ChannelNotFoundError to match `get`, which also keeps the notify off the refused path since it is written inside the transaction. The roster's second query repeats the same filter. It selects the page and then joins the agents to it in a separate statement on a separate snapshot, so a delete committing between the two would hand back a channel this person can no longer see. * Hold a pinned channel at the top of the roster, not the page The roster ordered by recency alone and the client lifted pinned rows at render, so a pin only reached the top of whatever pages were loaded: a channel somebody pinned and then did not talk to for a month sat on page three and never appeared above anything. The promise is about the roster, so the ordering belongs in the query. The page now orders by the pin first and the cursor carries it as the leading element. Every part of the sort descends — a pin is 1 and no pin is 0 — which keeps the keyset predicate a single row comparison rather than a nest of ORs, and a cursor minted before the pin existed reads as the first page, like any other cursor describing an ordering this query no longer has. `pinnedFirst` stays in the sidebar as the render-level mirror, for the window between refetches: the socket patches a pin onto a loaded row without moving it, and re-sorts a page by recency alone. Its comment now says that is what it is for, rather than claiming to be where the rule lives. * Read a vendor's garbage as a refusal, not a crash * Keep the wheel reachable when the screen has nothing to show Take control and Hand back live in the full-size view, and the only way in was disabled unless there was a picture to open. So a blank browser, a screenshot that had not arrived, or a computer that could not be reached left a person with no way to take the wheel at all - the three states where they most want it. The frame now opens whatever is in it, and with nothing to draw the full-size view reserves the same shape and says the same words the card does, with the wheel underneath them. Somebody already driving keeps the live socket, whatever is on the page: once a person holds the wheel the stream is the truth about it. The Bot ASKING for the wheel comes back to the card as its own amber row with the reason on it, which is what the rework dropped. It is not the persistent footer that was deliberately removed - it is there only while the request is, next to the credential form, which is the other thing a stuck Bot needs. * Answer pin and delete failures where they happened Three things this row did quietly. A refused delete stayed on the mutation, so reopening the confirm showed a stale 409 about an attempt nobody had made yet; the menu resets it on the way in. A failed pin said nothing at all - the menu closed, the pin did not move, and that reads as the app ignoring the click - so the sentence now lands on the row, there being no toast in this app. And a delete of the channel on screen navigated home after the write. The roster invalidates the moment it lands, which unmounts this row and the dialog inside it, so the navigate belonged to a component that was already gone. Leaving first is safe in the other direction: a refusal puts them on the roster with the channel still in it, and says why. * Grant a batch with one refetch and a progress count Two Bots and twelve tools is twenty-four writes, and every one of them went through the grant mutation - which invalidates every plugin query and waits for the refetch. Most of the wait was re-reading a list hidden behind the dialog. The write is now its own function with no refetch attached, and the dialog invalidates once when the loop is done, including after a refusal, because the grants before it landed. The button says which of the N is in flight rather than only "Granting", so a slow batch can be told from a stuck one, and each set of tickboxes is a fieldset named by the heading already above it - "Changes things" is the whole warning on those tools, and a listener would otherwise never hear it. * Sweep the code the screen rework orphaned `hasBrowsed` had no callers left once the screen and the activity log stopped being tabs that had to guess which one to open, and the placeholder artwork went with the blank-browser strip it decorated. The note itself stays: the tool handler is the only place the fact exists, and a screenshot cannot answer it. The composer's autofocus is a mount-time courtesy, claimed once. Keyed off the editor becoming interactive, it re-fired on every disabled or busy transition, so a completed turn yanked the caret back from wherever the person had moved it. A send of their own still returns it - that one they asked for. * Stop pretending a new client can spend an old grant * Let two first connects race to one client * Cap, revoke and say what refresh saw * Seal the consent state, not just sign it * Refuse a consent that outlived the person's access * Run OpenBot on Kubernetes: Bots and all, proven on EKS (#235) * Run OpenBot on Kubernetes: a Helm chart, and what installing it found One chart for EKS, GKE, AKS and somebody's own cluster, with nothing but values between them. No cloud branching in any template: every place the clouds differ is a value whose default is what a plain self-hosted cluster does. Identity is one annotations map, because that is all IRSA, Workload Identity and AKS workload identity are. Secrets are a plain Secret by default and an ExternalSecret against any backend when asked. Two replicas by default, because horizontal is the point and one hides every bug that is not. A bad install is refused at helm install naming the value to change, rather than found in a crash loop. Three things only a real install could find: drizzle-kit cannot migrate in the shipped image. It reads a TypeScript config, which needs the esbuild that bun install --production leaves out, so it printed one line, exited 1 and said nothing. EMBEDDED_POSTGRES=on was starting containers whose database was never migrated. The migrator inside drizzle-orm is a runtime dependency already and keeps the same journal. sessionOf answered from a map in the process that started the computer, which is right until there are two of them. The replica taking a snapshot is usually not the one handling the click, and an unknown session skips the generation check rather than failing it, so the check that stops a ref from a replaced computer resolving against a live one was silently absent on the shape it was written for. It now asks by listing, never by ensuring, so asking cannot start a computer that had stopped. A browser in an API pod cannot be replicated, so the image's computer gets the same switch its database has. * Give Bots computers on Kubernetes, and suspend them when idle The chart had no computer, so no Bot could do anything on a cluster. It has one now, and a Bot has driven a real browser on real EKS with the decision in the audit trail. computers.mode picks the shape. shared runs one browser for every Bot and needs nothing installed. sandbox gives each Bot its own as a Sandbox from kubernetes-sigs/agent-sandbox, which is built for exactly this: an isolated stateful singleton with a stable identity and persistent storage, where suspending is a field that keeps the volumes, so a computer comes back with its logins rather than signed out. What decides a computer is idle is the audit trail, not the browser. Asking the browser wakes it, so every computer anything asked about would come back up and the bill would never fall. The work is claimed and leased out of Postgres with for update skip locked. Three features need that one mechanism, so it is written once with all three in view: the culler here, routines, and a hop from one Bot to another. A CronJob runs the sweep rather than a timer in the API, because a timer fires in every replica and suspending a browser somebody just started using is not something to do five times. Also: a fresh EKS cluster very often has no default StorageClass. eksctl creates gp2, unmarked and on the in-tree provisioner current Kubernetes no longer has, so a volume asking for the default never binds and nothing says why. Found on a real 1.34 cluster and written down where somebody configuring one will read it. * Refuse a sandbox install on a cluster that cannot make one computers.mode: sandbox creates Sandbox objects, which exist only once the agent-sandbox controller is installed. Without it the install succeeds, every pod is healthy, and the deployment looks finished right up until the first Bot asks for a browser and the API server answers 404. That is the worst moment to learn it. The check reads the cluster rather than a value somebody has to remember to set, and the message carries the one command that fixes it. Proven both ways: refused on a cluster with no CRD, installs on the EKS cluster that has one. Also from driving it on real EKS: lost+found was listed as a Bot, because an EBS volume is ext4 and arrives with that directory, which a bind mount never does. The allow-list that stops a hostile id becoming a path answers the other half of the question too. The migration Job named a ServiceAccount that does not exist yet, since a pre-install hook runs before the chart's own resources. It talks to a database and never to the cluster, so it needs no account at all. The API pod gets a cluster token only in sandbox mode, the pods roll when the computer template changes, the Sandbox asks for a Service so it has an address that survives a resume, and the cluster CA is actually used when talking to the API server. * Tell one run of a computer from the next across a suspend A resumed browser counts snapshot generations from one again, so a ref the model still holds from before the suspend matches a row nothing has overwritten, and the boundary decides about an element on a page that no longer exists. The first answer used the node and the pod address. Resuming a real computer on EKS disproved it: a suspended sandbox is very often rescheduled onto the same node and handed the same address back, and both were identical across the cycle, so the check would have said same run for the exact case it exists to catch. The Ready condition's transition time moves whenever a computer starts serving again, needs no permission beyond the sandbox already read, and is precisely the question. Driven on EKS: a ref taken before a suspend is refused after the resume, naming why, and a fresh ref from a new snapshot clicks through. * Let the policy reach the computers, and refuse one that fences off the database The NetworkPolicy allowed DNS and the bundled database. Nothing let the API reach a Bot's computer, which it does for every browser action, and nothing let it reach a managed database, whose address this chart cannot know. On a cluster that enforces policy both are outages that read as something else: the API looks broken rather than fenced. The computers and the API server are allowed now, and turning the policy on with an external database and no rule for it is refused with the shape of the rule to add. None of this showed up by installing it, because EKS runs its CNI with --enable-network-policy=false and the policy is inert there. That is worth knowing on its own, so it is written down: a policy that installs, looks right, and does nothing is worse than one that is off. Also driven on EKS: reset takes the volumes with it and the Bot gets a clean profile afterwards, and the HPA reads real metrics. * Keep the browsing that produced an answer Every turn in which a Bot used a tool vanished from the transcript on reload. The sentence the Bot wrote stayed, the browsing that produced it did not, the inline screen went with it, and the footer said some messages could not be read. The history store writes a tool call as {id, name, args}; AG-UI describes {id, type: function, function: {name, arguments}}. The reader validated against the second and treated the first as damage from an interrupted run. It is not damage, it is how every tool call is stored, so a guard written against one bad turn was deleting all the real ones. Found by driving a real conversation on the EKS deployment rather than by reading: two browsing turns, both counted unreadable, both well formed in the store's own dialect. Both spellings now read as the same thing. A mixed or unrecognised array is still refused rather than half-translated, because a reader that rewrites what it does not recognise is worse than one that refuses it. * Show the page a finished turn opened, not the one open now Reopening a conversation made every past turn fetch the screen as it is now, so an answer about Hacker News from an hour ago sat under a picture of whatever the Bot had open since. The frame was live and the caption was not, and the turn read as though it had browsed somewhere it never went. A turn that has finished is history, and history is not polled. It names the page that turn actually left open, which the tool result already carried. Nothing changes while a turn runs: those frames are its own and freeze where it left them. It names the page rather than showing it, because nothing stored the picture and fetching one now would show a different page. Naming it stays true however many times the Bot has browsed since. Driven on EKS: three turns, three different pages, each holding its own across a reload. * Keep the frame a browsing turn ended on Reopening a conversation made every past turn fetch the screen as it is now, so an answer about one page sat under a picture of whatever the Bot had open since. A browsing turn keeps its last frame in computer_turn_frame, filed under the tool call and written once, because a turn that has happened does not happen differently later. Three things had to be true together and each was wrong on its own first. The frame is read at the moment the turn ends, since a short turn finishes before the tile has polled anything. Restoring a kept frame must not make the turn look live again, which the first version did: it counted a turn as history only while it had no picture, so restoring one restarted the polling that then replaced it. And a turn is over when it has a result rather than when its status says so, because a restored tool call arrives with its result in hand and a status that is briefly something else. Found by watching the network on the deployed cluster rather than by reading: two live screenshot reads before the restore, on every reload. * Keep a turn's frame only when it is a frame of that turn's page The capture ran at the end of a turn and took whatever the screen showed then. That is usually right and sometimes badly wrong: the same computer is driven by other conversations, a resumed one starts blank, and a short turn finishes before the tile has polled anything. So an answer about one page could be filed with a picture of another, which is worse than having no picture at all. A frame is now kept only when its own url is the page the turn opened. Unknown counts as no match, because storing on unknown is how the wrong picture gets kept. Also folds the restore and the capture into one effect asked in order: what is stored first, the live screen only if nothing is. Two effects racing is what made a reopened turn restore the right frame and then overwrite it with a fresh screenshot one render later, which the console showed plainly once I stopped guessing and logged it. * Photograph the page where it is opened, not where it is read back The transcript's inline screen used to capture its own frame after the turn ended, and file it under the tool call. That is a race it cannot win. A reopened turn and one that has just finished look identical from inside the component, the same computer is driven by other conversations in between, and a resumed computer starts blank, so the picture filed was routinely of somewhere the turn never went, or of nothing. The frame is now taken on the server the moment a navigation succeeds, which is the one moment the screen is certainly showing the page that was asked for, and kept per computer and page rather than per tool call. The surface only reads. Failing to take the picture never fails the navigation. * Open the Bot screen on a Bot this deployment has Three things a fork trips over. The Bot screen defaulted to a coworker named risk-analyst, which is a name from one tenant package and a crash on every other. OpenBot exists to be forked, so a Bot id written into a route is a defect on all but the deployment it came from: the screen took the whole page down to an unstyled error boundary. It now opens on whatever Bot this deployment actually has, and answers a mistyped name in a sentence. The audit trail wrote "not in the current snapshot" against every navigation, file read and command. That sentence is about a ref the server could not resolve, and deciding it by elimination put it on actions that never named an element at all, sending a reader looking for a snapshot nobody took. It is keyed on the ref now. The chart had no way to point at anyone's own AG-UI Bot, which is the seam the whole product is about. config.managedAgent.url and secrets.managedAgentToken, refused at install if one is set without the other. * Format the regenerated migration snapshot * Fix what the review found, and make CI able to find it next time Every claim driven against the real thing rather than read, and all but two held. THE QUEUE. Leases were computed on the replica's clock and compared against the database's, which is two clocks pretending to be one: a node ninety seconds behind wrote a sixty-second lease that arrived already expired, and the next replica took the item out from under it. Both ran it. `finish` and `release` matched on the key alone, so a replica whose lease had quietly gone deleted or rescheduled work another was executing. Nothing ever renewed, and the culler took twenty items on one lease. `finish` deleted the row, destroying the idempotence this table's own comment promises: the insert a re-offer was meant to collide with had nothing left to collide with. And a permanently failing item retried until somebody noticed, which on a queue with no dashboard is never. Every moment is named in SQL now, all three lease calls ask the same question, the culler renews between items, a finished row stays until a retention sweep takes it, and an item that runs out of attempts stops with its count and its reason where a person can query them. The probe that reproduced the first two comes back clean. THE CHART could not start a server on any of its four shipped targets: each configured sign-in and none supplied the secret sessions are signed with, and one carried the public example encryption key. Driven on a real cluster, watched to crash-loop, fixed, watched to come up ready. Both states are refused at install now, including the one hiding behind an external secret store, where the value is unreadable but the list of keys is not. THE DATABASE WAS REACHABLE FROM THE BOT'S BROWSER. Compose has kept those apart since the beginning; the chart dropped it, and the bundled database shipped a policy admitting any pod in any namespace on 5432. Proven by opening a socket from the browser pod. Pinned, plus a policy for the computer itself, which had none. That pod also carried a cluster credential it has no use for, which it no longer does: verified on a recreated per-Bot computer. The service account token was read once and held for the life of the process. Projected tokens rotate on a schedule the cluster picks, so sandbox calls work until the first rotation and then all return 401, which reads like the cluster broke. THE TRANSCRIPT still lied in two places. A finished turn holding a stale live frame fell through to "Waiting for the assistant's screen…" and waited there for ever, because the poll that would end the wait stops when a turn settles. And zooming a past turn mounted the live stream and offered Take control, so the one gesture for looking closer at what a turn did replaced it with whatever the Bot has open now. The kept frame exists to stop exactly that. TWO TESTS passed a pool-options object where a connection string belongs and were green for a reason unrelated to what they check, because the test tree is not type-checked. That is its own sweep; the misuse throws now. One of them also deleted every real queued suspension in the database. NOTHING HAS EVER RENDERED THIS CHART, which is how four broken targets shipped and stayed shipped. CI lints, renders and checks five targets now, including the per-Bot mode nothing rendered before, and a script that asks whether every secret key a container demands is one the chart writes. * Give the chart job the runtime its check needs * Address the second review: the frame goes back on turn identity, and the fixes stop breaking things Most of round two is consequences of round one, which is the honest summary. THE QUEUE WEDGED ITS OWN KEYS. An item at the attempt cap is not finished, so `claim` skipped it, `purge` did not match it, and `offer` cannot replace a row that is still there. The culler keys on the Bot id: five failed suspends and that Bot never scaled to zero again, silently and for good. Both kinds of done are reaped now, on the same window, which is also how long it waits before anything tries again. Giving up is logged rather than simply ceasing. PINNING THE DATABASE POLICY BROKE THE THINGS THAT USE IT. Only the API server carried the client label; the migration Job and the culler both open the database and neither did, so any cluster that actually enforces would have failed the install. My own probe could not have caught it, because that cluster ships enforcement switched off. THE FRAME GOES BACK ON THE TURN. Keying it on the page was a mistake with a plausible reason: two visits to one address collided, and letting the newer win made a past turn's picture change under the person reading it, which is the mutability this whole change exists to remove. It was chosen because the navigate handler seemed not to know its tool call. It does, on `context.toolCall.id`, which I assumed rather than checked. The row is written once and never updated. That leaves the race the old client-side guard used to cover: the screenshot is a second round trip, and with one computer shared by every Bot another Bot's navigation lands in the gap. The guard is back, on the side that now does the capturing. The capture also refuses to resume a suspended computer, so a convenience picture cannot undo a cull or hold a navigation open for a pod schedule. A TURN IS OVER WHETHER OR NOT IT GOT ANYWHERE. Settling on "do I have a page" left refused, failed and stopped navigations polling the live screen for ever under a finished answer, which are the turns where what is on screen has least to do with what is being read. And the control pill was the one affordance the merge did not teach: take the wheel mid-navigation, the turn settles, and a frozen picture from an hour ago asserted "You have control" with no way to hand it back. RESET NOW MEANS RESET. "Every login the Bot had is gone" was said while screenshots of the signed-in pages stayed in the database, readable from the transcript by anyone who could reach that Bot. The frames go with the profile, and a reaper takes the rest on a retention window, because a page is a row and nothing ever took anything out of that table. A REJECTED PROMISE WAS REMEMBERED FOR EVER. One unreadable token file at the wrong moment and every computer request for the pod's life failed with the same stale error, with no probe failing. And the chart's own gates were softer than they looked. `helm lint` reports a template `fail` as an INFO line and exits 0 even under `--strict`, which I drove rather than assumed, so it can never gate a refusal. Rendering can, and now does: CI asserts three refusals actually fire. The render check can no longer pass by matching nothing. `better-auth-secret` is optional only when it truly is, which also makes the existing-Secret path visible to that check. The policies render in a CI target for the first time. The subchart, its image and the lock are all pinned, and the lock is committed rather than ignored beside the tarballs it exists to pin. * Assert the example-key refusal only where it is armed * Arm the Bot-endpoint refusal under an external secret store too * Close the round-one gates: one dialect, one predicate, one shipped rule Four things that were reported as still open, and all four were. THE BOT SIDE HAD THE SAME DIALECT BUG AS THE SURFACE. A call read back from the thread store arrives as `{id, name, args}`, so `call.function` is empty: agent-bot defaulted every restored call to a tool named `tool` with no arguments, which is a call the model cannot recognise as the one it made, so it makes it again. That is the repetition the default was written to prevent, caused by the default. The LangGraph twin did not degrade at all, it dereferenced straight through and threw. Both read either spelling now, and the fallback is the last resort it was meant to be. THE SURFACE STILL PASSED ARGUMENTS THROUGH UNTOUCHED. AG-UI types them as a string and the store is under no such obligation, so a tool called with structured input produced a call that looked translated and failed validation anyway. Strings are passed through exactly, down to their whitespace, because a fragment of a stream that was never valid JSON is what the model actually said. AND IT DROPPED A TURN THAT CALLED A TOOL AND SAID NOTHING. The schema makes an assistant's content optional and does not allow null, so the two mean the same thing and only one parsed: the same loss as the dialect bug, by a different route. A person's turn is not the same case, and #207's decision to refuse and count that one stands. The tests that pinned multimodal content, null content and ordering are back, and the shapes were driven against the reader rather than assumed: the one I was most confident about, that a list of parts is refused, turned out to be wrong. THE PROFILE TEST PROVED ITS OWN COPY. It reimplemented the filter it was checking, so deleting the real one left the suite green and the fleet page listing `lost+found` as a Bot again. The rule is its own module now, imported by both, and removing the filter fails the test. * Run the reaper that was written, and keep frames through a rollout Two things asked for before merge, both mine, and the second was worse than reported. THE REAPER HAD NO CALLER. `computer_page_frame` had a purge, an index to serve it and a test proving it works, and nothing ever invoked it: written on every navigation, taken out by a profile wipe and by nothing else. The culler calls it, because that is already the sweep that runs on a schedule with a claim under it and a second timer would be a second thing to get wrong. Kept a month, which is long after anybody reads a conversation back. Deleting a Bot still leaves them, and that is left alone deliberately: a delete is soft and touches no computer state at all today, not the profile, not the browser, not the snapshots. Clearing only the screenshots would be the one half-measure that reads as though the rest had been handled. A SCREENSHOT THAT DOES NOT SAY WHAT IT IS OF IS THE ORDINARY CASE ON AN OLD COMPUTER. That field arrived after the first computers shipped. Refusing on a missing url therefore did not fail safe, it failed silently and completely: a fleet part-way through a rollout kept no frames at all and said nothing about why. The question is now asked where it means something. With a computer each there is nobody to race with and the picture can only be this turn's. On one shared browser another Bot's navigation lands in exactly that gap, so an unlabelled frame is still refused, and the rollout order that matters is written down where somebody upgrading will read it. And every refusal says so now. Two of the three returned quietly, under a docstring promising the opposite, which is how a deployment ends up keeping no frames with nothing in its logs to explain it. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> * Carry the per-Bot egress proxy as far as the process that reads it (#250) * Carry the per-Bot egress proxy as far as the process that reads it `EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_<BOT>` are documented in .env.example and docs/configuration.md, and neither reached any process. docker-compose.yml named no EGRESS variable and had no `env_file`, and Compose hands a container only what those two blocks name. So the shared computer resolved every Bot to null and went out directly, and in the supervisor arrangement the supervisor's own environment held none either, leaving its EGRESS_PROXY passthrough with nothing to forward into the computers it creates. Nothing said so. The operator sets a proxy, the stack starts, the browser leaves by the host, and the Computers screen reports "Leaves directly" because it is reading the same empty environment. For a setting whose stated purpose is to give a security team a per-Bot address for network rules, silently doing nothing is the worst of the available failures. A file rather than more `environment:` entries because `EGRESS_PROXY_<BOT>` is derived from a Bot's id, so there is no fixed set of names to write out here. A file of its own rather than .env because that one holds the deployment's secrets, and the container driving a browser and running a Bot's shell is deliberately given what it needs and not the rest. It is optional, since going out directly is the ordinary case and must still start, and gitignored, because a proxy URL can carry a password. The all-in-one image was never affected: its s6 service runs under `with-contenv` and inherits the container's environment, which is the mechanism this restores for Compose. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> * Refuse the shell and a workspace write while a person holds the wheel (#247) * Refuse the shell and a workspace write while a person holds the wheel `assertBotMayAct` was called in the navigate handler and the four action handlers, and nowhere else. `/exec` and `/files/write` were not covered, so a Bot could keep running commands and rewriting its workspace underneath somebody who had taken the browser at a login wall. The shell arrived after the wheel existed and was never wired to it. That is the property this codebase states outright. control.ts says every acting call from the Bot is refused while a person holds control, and the README says Bot actions are refused rather than queued. Both were false for the most powerful path the product exposes, and the server could not cover for it: control lives in this process, so those two call sites were the whole of the enforcement. The decision moves to `actsOnTheComputer` in authorisation.ts and is asked once by the dispatcher, after the session resolves. A per-handler check is the thing the next endpoint forgets, which is exactly how the shell came to be missing one; a list the dispatcher consults has to be added to instead. It lives beside the other path decision rather than in index.ts because that file imports Playwright at module scope, so a decision left there cannot be tested without Chrome. Reading stays open. `/files/read` and `/files/list` are not acting, and a Bot that has just been stopped still needs to say what it was doing. The two in-handler guards and their now-dead ControlError branches come out with it, so there is one place that answers this and not three. * Say in the changelog that the wheel now stops the shell A deployment behaves differently afterwards: an action that used to run during a takeover is refused, so it belongs here rather than only in the commit. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. * Spend an MCP token only for its own server, and only at its own address (#238) * Point a curated MCP server only at a credential of its own kind Adding a server by URL checks which credential it is being pointed at. Adding one from the catalogue took the same field from the same request and stored it unread, so a credential of any kind could be attached to a curated server and spent by the refresh that runs before the add returns. The reach is narrower than the path beside it and worth saying so. The column is a foreign key, so an id naming nothing was already refused by the database, and the one entry in the catalogue is reached with each person's own account, whose OAuth client is registered through its own call and sent to a pinned address. What was reachable is a credential of the wrong kind being accepted and spent on behalf of somebody who never agreed to it, a malformed id arriving as a database error where a refusal belongs, and the whole shape returning with the first deployment-bearer entry a fork re-adds, which the catalogue invites. Which kind an entry takes is decided beside the entry, because it is a property of the vendor's auth rather than of the request. Both add paths then ask one function the same question, so a credential that does not exist and one of the wrong kind are still refused in the same words and the endpoint cannot be asked which ids are real. The curated route maps that refusal to a 400 rather than letting it surface as a 500. Re-adding a curated server no longer clears the credential it points at. That column holds the OAuth client registering one put there, and a re-add to change an instance host said nothing about it while clearing it anyway, leaving the row orphaned and everybody who had connected told there is no client registered. * Spend an MCP token only for its own server, and only at its own address Attaching a credential to a server is the one place this deployment accepts a reference to a stored secret rather than the secret itself. Everywhere else the value arrives in the request that stores it, and the id it gets is nobody's to choose: storeAgentAuth mints its own row from the key an administrator typed. So this is the field where which secret and which address can be made to disagree, and the add is what settles it, because refreshTools runs before the call returns and sends what it decrypts to the URL from the same request. Both ways they could disagree are now refused. A credential has to belong to the server it is attached to, which the vault already records: storeMcpToken sets the provider to the server it mints for and is the only way the plugins screen makes one, so nothing a deployment can reach through the UI is refused by this. And a server that already holds a credential cannot be re-added at a different address, which is the case a check on ownership cannot see: the token does belong to that server, and only the address moved. The second is why the first is not enough alone. Both delivered a stored token to a host the caller named, before any Bot, grant or policy check existed, and a stored credential is otherwise unreadable by design. Refused rather than repaired, because both harmless readings are served by something else. Correcting a title or retrying an interrupted add sends the same URL and is untouched, a server holding no credential can still be re-addressed, and moving one that does means removing it and adding it again with the token the new address is meant to have. Curated servers are unaffected: their URL comes from the catalogue rather than the request, and an instance hostname is matched against the vendor's anchored pattern before anything is stored. The upsert test from #214 now mints its own token. It had reused one credential across two server ids, which is a shape storeMcpToken cannot produce. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on a channel a Bot has spoken in unseen * Mark a channel read the moment it is the one on screen * Narrow the admin and settings rails to the width their labels earn * Keep a fast clock elsewhere from turning mark-read into a storm * Say in the changelog what the unread dot is and is not * Stamp a read against the message clock, not just this one * Assert the clamped stamp without reaching through an optional chain * Regenerate the read-marker migration behind the queue and the frames * Refuse to mark a deleted channel read, matching the pin * Update azure/setup-helm action to v5 (#260) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Say what a strict content-security-policy has to allow (#225) * Point the test at the database the project actually has (#234) * Let the API reach Intelligence and sign-in when a NetworkPolicy is on (#257) * Refuse a credential written into the rest of the address (#230) Refusing a credential in the userinfo closed one spelling and left the two beside it open. A token in the query string or the fragment was accepted, and addCustomServer writes the address it was given into mcp_servers.url and into the configuration.changed audit payload verbatim. Redaction keys on the field name and url is not a sensitive one, so the secret landed in an append-only row in clear text, which is the disclosure the userinfo rule exists to prevent. The name is read rather than matched against a list. An exact-name version of this rule refused token and accepted auth_token, api_token, x-api-key and X-Amz-Signature, and an operator has no way to know which spellings the check happens to hold. Reading the name over-refuses in one direction on purpose: a misread parameter costs a rename, a missed one cannot be deleted afterwards. The fragment is split at the first question mark first, because a hash route or an OAuth-style callback puts a path in front of the parameters and reading the whole fragment as one query string turns all of it into a single name that matches nothing. metadata.goog is refused too, by asking the list browsing already uses rather than keeping a second copy here. It is Google's own short alias for the metadata server and it carries a dot and none of the suffixes this check lists, so it read as an ordinary vendor name, while the long spelling was refused only incidentally by the .internal test. --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> * Carry the per-Bot egress proxy as far as the process that reads it (#250) * Carry the per-Bot egress proxy as far as the process that reads it `EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_<BOT>` are documented in .env.example and docs/configuration.md, and neither reached any process. docker-compose.yml named no EGRESS variable and had no `env_file`, and Compose hands a container only what those two blocks name. So the shared computer resolved every Bot to null and went out directly, and in the supervisor arrangement the supervisor's own environment held none either, leaving its EGRESS_PROXY passthrough with nothing to forward into the computers it creates. Nothing said so. The operator sets a proxy, the stack starts, the browser leaves by the host, and the Computers screen reports "Leaves directly" because it is reading the same empty environment. For a setting whose stated purpose is to give a security team a per-Bot address for network rules, silently doing nothing is the worst of the available failures. A file rather than more `environment:` entries because `EGRESS_PROXY_<BOT>` is derived from a Bot's id, so there is no fixed set of names to write out here. A file of its own rather than .env because that one holds the deployment's secrets, and the container driving a browser and running a Bot's shell is deliberately given what it needs and not the rest. It is optional, since going out directly is the ordinary case and must still start, and gitignored, because a proxy URL can carry a password. The all-in-one image was never affected: its s6 service runs under `with-contenv` and inherits the container's environment, which is the mechanism this restores for Compose. * Show a dot on a channel a Bot has spoken in unseen (#259) * Give a membership a memory of when its channel was last read * Stamp the caller's membership read, and say so in the roster * Let a member say they have read a channel * Carry the read marker to the app and let it be stamped * Draw a dot on … --------- Co-authored-by: Guido Vizoso <guido.vizoso9@gmail.com> Co-authored-by: David McKay <davidmckayv@users.noreply.github.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Vaibhav Zope <121456155+zopeVaibhav@users.noreply.github.com> Co-authored-by: beardthelion <56458543+beardthelion@users.noreply.github.com> --- .env.example | 10 ++++++++ CHANGELOG.md | 38 +++++++++++++++++++++++++++++ docker-compose.yml | 14 ++++++++--- scripts/start.sh | 60 +++++++++++++++++++++++++++++++++++++++------- 4 files changed, 111 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index e749f1eb..706b402a 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,17 @@ KEY_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # one to leave alone until somebody has decided otherwise: the trail is append-only and nothing else # can remove a row, so this is the only way it ever shrinks. # AUDIT_RETENTION_DAYS=365 +# Two names for one number, and they have to agree. +# +# The server reads PORT (server/src/index.ts). scripts/start.sh reads SERVER_PORT, because it also +# has to know where the app should proxy and which port to report free -- and docs/configuration.md +# documents SERVER_PORT as the setting. Only PORT shipped here, so moving the server by editing this +# line left the script still looking at 3001: it found whatever else was there, accepted the first +# 200 as proof, and failed several stages later parsing that stranger's HTML as JSON. +# +# Change both, or neither. PORT=3001 +SERVER_PORT=3001 TENANT_PACKAGE_DIR=../examples/fintech # What this deployment calls itself, when more than one shares an Intelligence project. A copy of a # deployment made for development uses the same project key, and threads are listed per Bot with diff --git a/CHANGELOG.md b/CHANGELOG.md index bf9f33e3..25d106f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -631,6 +631,44 @@ one they are, so those match too. No configuration changes and nothing is stored differently; a deployment that was already on the light theme sees no difference at all. +### `start.sh` refuses a port that answers but is not OpenBot + +The startup checks asked whether a port answered, and treated that as proof the port belonged to this +stack. Those are not the same claim. Any single-page app serves its index.html for every path it does +not recognise, so an unrelated dashboard on a default port answers `200` to `/api/capabilities` as +readily as this server does. + +The cost was not a wrong answer, it was a wrong answer three stages later. `require_free_or_ours` +reported "already up", so the server was never started; `wait_for` then printed a green +"server ready"; and the run failed at stage 3 inside `json.loads`, parsing that stranger's HTML. The +error names `char 0`, which reads like an empty response rather than a `<`, so the visible symptom +pointed nowhere near the port. + +Each surface is now asked for something only it can produce: a `licenseStatus` field for the server, +its own `<title>` for the app, `/health` for the compose services. When a check gives up it says +whether the process failed to start or the port belongs to something else. + +The root cause was in `.env.example`, and is fixed there too. The server reads `PORT`, this script +reads `SERVER_PORT`, `docs/configuration.md` documents `SERVER_PORT` as the setting, and only `PORT` +shipped. Moving the server by editing that one line left the script still looking at 3001. Both names +are now present, next to each other, saying they have to agree. + +**A run may now stop where it used to continue.** That is the point: it stops at the port that is +wrong, naming it, rather than several steps later on a parse error. + +### `docker compose up -d` configures the same stack `scripts/start.sh` does + +`SUPERVISOR_TOKEN` and `COMPUTER_TOKEN` defaulted to the empty string in `docker-compose.yml`, so +which stack you got depended on how you brought it up. `scripts/start.sh` resolves both to their +`openbot-dev-*` defaults and exports them before calling compose. A plain `docker compose up -d` — +which this project's own shutdown notes tell you to use — passed an empty string instead. + +`agent-computer` refuses to start without one, so that half failed loudly. The supervisor half was +the quiet one: the server kept the token it started with while the supervisor held an empty string, +and every call between them was refused at the door. + +Both now carry the same defaults `start.sh` applies, as `COMPUTER_IMAGE` already did two lines down. +A value set in `.env` still wins, and a deployment should set one. ## 0.0.4 diff --git a/docker-compose.yml b/docker-compose.yml index b420e933..e89f4f93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,7 +72,13 @@ services: required: false environment: # The secret every caller must present. The container refuses to start without it. - COMPUTER_TOKEN: ${COMPUTER_TOKEN:-} + # + # The default matches the one scripts/start.sh applies when .env leaves this blank, which + # .env.example does. Without it the two paths disagree: the script exports a value and brings + # the stack up working, while a plain `docker compose up -d` -- which this project's own + # shutdown notes tell you to use -- hands the container an empty string instead. Reaches only + # a loopback-bound port; a deployment sets a real value in .env. + COMPUTER_TOKEN: ${COMPUTER_TOKEN:-openbot-dev-computer-token} # How many Bots may hold a running browser at once, and how long an untouched one is kept. # A few hundred MB each, so on a deployment with many Bots these are the difference between # a container that holds steady and one that is killed for memory. Defaults are 8 and 30 @@ -161,9 +167,11 @@ services: PORT: "4300" # Shared with the API server. The Bot-level verb set is the boundary; this token keeps other # services on the network from calling those verbs. - SUPERVISOR_TOKEN: ${SUPERVISOR_TOKEN:-} + # Defaulted for the same reason as agent-computer's above: so `docker compose up -d` by hand + # configures the same stack scripts/start.sh does, rather than a silently unauthenticated one. + SUPERVISOR_TOKEN: ${SUPERVISOR_TOKEN:-openbot-dev-supervisor-token} # Handed to every computer this creates, so the server and the computers share one secret. - COMPUTER_TOKEN: ${COMPUTER_TOKEN:-} + COMPUTER_TOKEN: ${COMPUTER_TOKEN:-openbot-dev-computer-token} COMPUTER_IMAGE: ${COMPUTER_IMAGE:-openbot-agent-computer:latest} # Which deployment the computers it creates belong to, so two stacks on one Docker host never # derive the same container and volume names for the same Bot. diff --git a/scripts/start.sh b/scripts/start.sh index 20536095..54831826 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -111,21 +111,65 @@ holder() { lsof -nP -iTCP:"$1" -sTCP:LISTEN -Fcn 2>/dev/null | awk '/^c/{c=substr($0,2)} /^n/{print c" ("substr($0,2)")"; exit}' || true } +# Does whatever holds this port answer as OpenBot, rather than merely answer? +# +# `curl -f` proves something is listening and returned 2xx. That is not the same claim, and the gap +# between them is not academic: any single-page app serves its own index.html for every path it does +# not recognise, so an unrelated dashboard sitting on a default port answers 200 to +# `/api/capabilities` as readily as this server does. +# +# When that happened here the cost was not a wrong answer, it was a wrong answer three stages later. +# `require_free_or_ours` reported "already up", the server was therefore never started, `wait_for` +# printed a green "server ready", and the run died at stage 3 in `json.loads` on a mouthful of HTML — +# a JSON parse error standing in for "that port belongs to something else". +# +# So each surface is asked for something only it can produce. +identifies_as_openbot() { + local port="$1" name="$2" + case "$name" in + # A field of this server's own payload. A stray 200 does not carry it. + server) + curl -fsS --max-time 3 "http://localhost:$port/api/copilotkit/info" 2>/dev/null \ + | grep -q '"licenseStatus"' + ;; + # The app is static HTML with nothing to interrogate, so its title is the identity available. + app) + curl -fsS --max-time 3 "http://localhost:$port/" 2>/dev/null \ + | grep -qi '<title>[^<]*OpenBot' + ;; + # Compose services on dedicated loopback ports, answering a route named for this stack. + *) + curl -fsS --max-time 3 "http://localhost:$port/health" >/dev/null 2>&1 + ;; + esac +} + require_free_or_ours() { local port="$1" name="$2" who who="$(holder "$port")" [ -z "$who" ] && return 0 - if curl -fsS --max-time 3 "http://localhost:$port/health" >/dev/null 2>&1 \ - || curl -fsS --max-time 3 "http://localhost:$port/api/capabilities" >/dev/null 2>&1 \ - || curl -fsS --max-time 3 "http://localhost:$port/" >/dev/null 2>&1; then + if identifies_as_openbot "$port" "$name"; then info " $name: already up on $port ($who)" return 0 fi - red " $name: port $port is held by something else: $who" + red " $name: port $port is held by something that is not OpenBot: $who" red " Re-run with ${name^^}_PORT=<free port>, or stop that process yourself." exit 1 } +# As wait_for, but satisfied only by OpenBot answering, not by anything answering. +wait_for_openbot() { + local port="$1" name="$2" tries="${3:-40}" + for _ in $(seq 1 "$tries"); do + identifies_as_openbot "$port" "$name" && { green " $name ready"; return 0; } + sleep 1 + done + red " $name never answered as OpenBot on port $port" + red " Either it failed to start, or that port belongs to another process." + red " Log: $LOGS/${name}.log" + exit 1 +} + wait_for() { local url="$1" name="$2" tries="${3:-40}" for _ in $(seq 1 "$tries"); do @@ -214,7 +258,7 @@ if [ "$SECRETS_ROTATED" = "true" ]; then pkill -f "bun --env-file=../.env src/index.ts" >/dev/null 2>&1 || true sleep 1 fi -if ! curl -fsS --max-time 3 "http://localhost:$SERVER_PORT/api/capabilities" >/dev/null 2>&1; then +if ! identifies_as_openbot "$SERVER_PORT" server; then if [ "$ONE_COMPUTER_EACH" = "true" ]; then (cd server && PORT="$SERVER_PORT" \ COMPUTER_SUPERVISOR_URL="http://localhost:$SUPERVISOR_PORT" \ @@ -225,7 +269,7 @@ if ! curl -fsS --max-time 3 "http://localhost:$SERVER_PORT/api/capabilities" >/d (cd server && PORT="$SERVER_PORT" bun --env-file=../.env src/index.ts >"$LOGS/server.log" 2>&1 &) fi fi -wait_for "http://localhost:$SERVER_PORT/api/capabilities" "server" +wait_for_openbot "$SERVER_PORT" server info "3/4 Runtime health" INFO="$(curl -fsS --max-time 8 "http://localhost:$SERVER_PORT/api/copilotkit/info")" @@ -246,10 +290,10 @@ PY info "4/4 App" require_free_or_ours "$APP_PORT" app -if ! curl -fsS --max-time 3 "http://localhost:$APP_PORT/" >/dev/null 2>&1; then +if ! identifies_as_openbot "$APP_PORT" app; then (cd app && bun run dev --port "$APP_PORT" --strictPort >"$LOGS/app.log" 2>&1 &) fi -wait_for "http://localhost:$APP_PORT/" "app" +wait_for_openbot "$APP_PORT" app cat <<EOF