From f37bb4ef36e7195ad0f9644bec2c815395fd29f2 Mon Sep 17 00:00:00 2001 From: kevin9327 Date: Fri, 4 Sep 2026 07:33:09 +0900 Subject: [PATCH] Record who a coworker was opened to, not only where it was pointed `visibility` is not a display preference. `accessFilter` admits a public coworker to every signed-in person, and `canRunAgent` is `canAccessAgent`, so public hands everybody in the deployment the right to act as that Bot and spend the connector grants, the tools and the browser it holds. Since the coworker dialogs landed it is one click, written on pick. bot.created and bot.updated recorded the name, the endpoint and whether a key was set, and said nothing about this. So an edit that opened a coworker to the whole deployment was byte-identical on the audit page to one that corrected its title, and "who made this Bot available to everybody, and when" had the same answer the endpoint question used to have: nothing. audit.ts already gives the reason for the endpoint - "the first question asked in an incident" - and it applies here word for word. Carried on every row rather than only the row that moved it, the way `name` already is. The route has no before to compare against, and recording the value on each row is what lets somebody read the trail forward and say what was reachable at any point, which is the question an incident actually asks. Nothing else changes: the payload is still what changed rather than the new values for the endpoint and the key, and the key is still never recorded. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++++++ server/src/agents/routes.ts | 22 +++++++++++-- server/tests/bot-lifecycle-audit.test.ts | 42 ++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11fb9c67f..7b09d0476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### The trail says who a coworker was opened to + +Making a coworker public admits every signed-in person to it, and being admitted to a coworker is +being allowed to act as it — with the connectors, the tools and the browser it was granted. It is one +click in the coworker dialog. The `bot.created` and `bot.updated` rows recorded the name, the +endpoint and whether a key was set, and said nothing about this, so an edit that opened a coworker to +the whole deployment was byte-identical on the audit page to one that corrected its title. Both rows +now carry the visibility, on every edit rather than only the edit that moved it, so reading the trail +forward says who could reach each coworker at any point. + ### Duplicating a coworker keeps the endpoint it was copied from Duplicate used to point every copy at this deployment's own managed Bot, whatever the coworker being diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index 7a2302284..4eb9dbc5e 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -358,9 +358,15 @@ export function createAgentRoutes( /* * The endpoint, because that is where conversation content will be sent, and whether a key was * attached, because "this Bot authenticates" is a fact and the key itself never is. + * + * And who may reach it. `visibility` is not a display preference: `accessFilter` admits a + * `public` coworker to every signed-in person, and `canRunAgent` is `canAccessAgent`, so public + * means everybody in the deployment may act as this Bot and spend the grants it holds. A row + * that cannot say which it was cannot reconstruct who could use this coworker at the time. */ await record(context, "bot.created", agent.id, { name: parsed.value.name, + visibility: parsed.value.visibility, ...(parsed.value.endpoint ? { endpoint: parsed.value.endpoint } : {}), hasKey: Boolean(parsed.value.auth), }); @@ -385,10 +391,22 @@ export function createAgentRoutes( context.req.param("agentId"), parsed.value, ); - // What changed, not the new values. Repointing the endpoint is the dangerous edit and is worth - // naming; a replaced key is worth knowing about and is never worth recording. + /* + * What changed, not the new values. Repointing the endpoint is the dangerous edit and is worth + * naming; a replaced key is worth knowing about and is never worth recording. + * + * `visibility` is carried the way `name` is — on every row, whether or not this edit moved it — + * because it is the second dangerous edit and the route has no before to compare against. + * Public admits every signed-in person to this coworker, and `canRunAgent` is `canAccessAgent`, + * so it hands them the right to act as it and spend what it was granted. Without the value on + * each row, an edit that opened a coworker to the whole deployment is byte-identical to one + * that corrected its title, and the trail cannot say when it was opened or by whom. Recorded on + * every row rather than only on the row that changed it, so reading the trail forward tells you + * what was reachable at any point, which is what an incident asks. + */ await record(context, "bot.updated", agent.id, { name: parsed.value.name, + visibility: parsed.value.visibility, ...(parsed.value.endpoint ? { endpoint: parsed.value.endpoint } : {}), ...(parsed.value.auth ? { keyReplaced: true } : {}), }); diff --git a/server/tests/bot-lifecycle-audit.test.ts b/server/tests/bot-lifecycle-audit.test.ts index 934c08f26..00c18128c 100644 --- a/server/tests/bot-lifecycle-audit.test.ts +++ b/server/tests/bot-lifecycle-audit.test.ts @@ -89,6 +89,48 @@ describe("what a Bot is, on the trail", () => { expect(rows[0]?.payload.endpoint).toBe("https://elsewhere.example/ag-ui"); }); + /* + * The other dangerous edit, and the one the trail was silent about. + * + * `visibility` is not a display preference. `accessFilter` admits a public coworker to every + * signed-in person, and `canRunAgent` is `canAccessAgent`, so public hands everybody in the + * deployment the right to act as this Bot and spend the connector grants it holds. It is one click + * in the coworker dialog. + */ + test("creating one records who may reach it", async () => { + const { rows, hono } = app(); + + await hono.request("http://t/api/agents", json({ visibility: "private" })); + + expect(rows[0]?.eventType).toBe("bot.created"); + expect(rows[0]?.payload.visibility).toBe("private"); + }); + + test("opening one to the whole deployment says so", async () => { + const { rows, hono } = app(); + + await hono.request("http://t/api/agents/bot-1", { + ...json({ visibility: "public" }), + method: "PATCH", + }); + + expect(rows[0]?.eventType).toBe("bot.updated"); + expect(rows[0]?.payload.visibility).toBe("public"); + }); + + test("an edit that leaves it private says that too", async () => { + // Carried on every row rather than only on the row that moved it: reading the trail forward has + // to tell you what was reachable at each point, and the route has no before to compare against. + const { rows, hono } = app(); + + await hono.request("http://t/api/agents/bot-1", { + ...json({ visibility: "private", title: "Sales assistant, revised" }), + method: "PATCH", + }); + + expect(rows[0]?.payload.visibility).toBe("private"); + }); + test("a replaced key is noted and never recorded", async () => { const { rows, hono } = app();