Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ 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 Bot in the box keeps its instructions

A coworker that runs on this deployment's own Bot has no endpoint — it has a prompt, which is the
Expand Down
22 changes: 20 additions & 2 deletions server/src/agents/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand All @@ -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 } : {}),
});
Expand Down
42 changes: 42 additions & 0 deletions server/tests/bot-lifecycle-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down