Public: anyone can read this; the crew can change it.

An action taken through the forge's agent is on the trail as the person, by way of the agent #7675

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7648-an-action-taken-through-the-forge-s-agen → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7648-an-action-taken-through-the-forge-s-agen was merged into main as 111d548.

Review

Sign in to review this change.
14 files changed +410 −8
README.md +15
@@ -235,6 +235,21 @@ admin adds `"adminConfirm": true` to mean a token that acts as admin. `ro` and
235235 can make a token with admin scope; ro and rw are open to you."), and the
236236 Settings form offers `admin` only to an admin.
237237
238 **The forge's agent acts as you, and the trail says so** (#7648): `POST
239 /api/v1/conversation-tokens` with `{"words": "<what you said>"}` mints the
240 token the forge's agent acts with in a fresh conversation; add
241 `"conversation": "<id>"` to go on with one of your own (any other is 422
242 `invalid_conversation`). Only your own browser session on the same origin
243 mints it (a bearer token or a password is 401, a cross-origin post 403). It
244 answers 201 once with `{id, token, expiresAt, via}`: the token is `rw`, holds
245 exactly what you hold, reaches the forge only through `/mcp`, fenced
246 (docs/features/conversation-fence.md), and expires within the hour. Every
247 event written while it is the caller has you as its actor and `via:
248 "conversation:<id>"`; what the forge writes on the way (a run, a check) is
249 not marked. The trail reads "alice via the forge's agent": MCP `events` and
250 `GET …/events` add `by` with those words, and an issue's or pull's timeline
251 says "alice via the forge's agent closed this".
252
238253 The same token spawns a child forge (`dryRun` is true unless `false`, so
239254 without it you get the plan and nothing is made). The child's admin comes
240255 from the operator's `--first-admin --child`, or from the `claimUrl` the spawn
src/actions/users.ts +38
@@ -22,6 +22,8 @@ import {
2222 deleteToken as revokeToken,
2323 type TokenScope,
2424 } from "../core/tokens.ts";
25 import { mintViaToken, VIA_TOKEN_MAX_MS } from "../core/via_token.ts";
26 import { id } from "../kernel/id.ts";
2527 import { inviteSpentBy } from "../core/invites.ts";
2628 import {
2729 changePassword as rehash,
@@ -256,6 +258,42 @@ export function createToken(
256258 return ok(minted);
257259 }
258260
261 /**
262 * Mint the token the forge's agent acts with in one conversation: the
263 * person's own, rw, expiring within the turn (`core/via_token.ts`), gated to
264 * `/mcp` and fenced there. Without `conversation` a fresh one starts; with
265 * it, only one this person's earlier token named, so nobody resets the
266 * fence's marks of a conversation that is not theirs. `words` are what the
267 * person said to start the turn. The caller owes the door: the person's own
268 * session, same-origin, never a token.
269 */
270 export function mintConversationToken(
271 app: App,
272 actor: User | undefined,
273 input: { conversation?: string; words: string },
274 ): Result<{ id: string; token: string; expiresAt: string; via: string }, "unauthorized" | "invalid_via"> {
275 const user = requireUser(actor);
276 if (!user.ok) return user;
277 const via = `conversation:${input.conversation ?? id()}`;
278 if (input.conversation !== undefined) {
279 const theirs = app.db
280 .query<{ n: number }, [string, string]>(
281 "SELECT COUNT(*) AS n FROM via_token v JOIN token t ON t.id = v.token_id WHERE v.via = ?1 AND t.user_id = ?2",
282 )
283 .get(via, user.value.id);
284 if (!theirs || theirs.n === 0) return err("invalid_via");
285 }
286 const expiresAt = new Date(Date.now() + VIA_TOKEN_MAX_MS).toISOString();
287 const minted = mintViaToken(app.db, user.value.id, via, input.words, expiresAt);
288 if (!minted.ok) return err("invalid_via");
289 app.bus.append({
290 kind: "token.created",
291 actorId: user.value.id,
292 payload: { tokenId: minted.value.id, name: `via:${via}`, scope: "rw", expiresAt, via },
293 });
294 return ok({ ...minted.value, expiresAt, via });
295 }
296
259297 export function deleteToken(
260298 app: App,
261299 actor: User | undefined,
src/contract/openapi/routes/index.ts +1
@@ -184,6 +184,7 @@ export { route as postApiV1AsksGiveBack } from "./postApiV1AsksGiveBack.ts";
184184 export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdVerb.ts";
185185 export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts";
186186 export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts";
187 export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts";
187188 export { route as postApiV1Drills } from "./postApiV1Drills.ts";
188189 export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts";
189190 export { route as postApiV1FeedInteractions } from "./postApiV1FeedInteractions.ts";
src/contract/openapi/routes/postApiV1ConversationTokens.ts added +15
@@ -0,0 +1,15 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "post",
5 path: "/api/v1/conversation-tokens",
6 summary: "Mint the token the forge's agent acts with in one conversation, as the signed-in person: rw, reaching the forge only through /mcp, fenced, expiring within the hour; `words` are what the person said to start the turn; without `conversation` a fresh one starts, with it only one of the person's own goes on; only the person's own browser session on the same origin mints it (a bearer token or password is 401); every event written with it names the person via the forge's agent; answers 201 with the token once",
7 auth: false,
8 status: 201,
9 body: { conversation: "string", words: "string" },
10 refusals: {
11 invalid_conversation: "conversation is not 1 to 40 lowercase letters or digits, or not one of the person's own",
12 invalid_words: "words is not a string of at most 20000 characters",
13 forbidden: "the request is cross-origin",
14 },
15 };
src/core/acting_via.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * How the caller of this request acts: as itself, or as a person by way of
3 * the forge's agent (`conversation:<id>`). The listener opens the scope once
4 * auth is resolved; `EventBus.append` reads it, so every event a request
5 * writes carries it without any append site passing it along (#7648).
6 */
7
8 import { AsyncLocalStorage } from "node:async_hooks";
9
10 /** Who acts through the agent, and in which conversation. */
11 interface Acting {
12 readonly userId: string;
13 readonly via: string;
14 }
15
16 const store = new AsyncLocalStorage<Acting | undefined>();
17
18 /** Run `fn` with `acting` as how its writes are made; undefined is "as itself". */
19 export const actingVia = <T>(acting: Acting | undefined, fn: () => T): T =>
20 store.run(acting, fn);
21
22 /**
23 * The `via` an event by `actorId` carries now: only the person the agent
24 * acts for is marked, so what the forge itself writes on the way (a run, a
25 * check) is never said to be theirs.
26 */
27 export const currentVia = (actorId: string | undefined): string | undefined => {
28 const acting = store.getStore();
29 return acting !== undefined && acting.userId === actorId ? acting.via : undefined;
30 };
31
32 /** The words that follow the person's name on the trail. */
33 export const VIA_WORDS = "via the forge's agent";
src/core/events.ts +9 −1
@@ -8,6 +8,7 @@
88 import type { Database } from "bun:sqlite";
99 import { id } from "../kernel/id.ts";
1010 import { anyHeld, createLog, redactValues } from "../kernel/log.ts";
11 import { currentVia } from "./acting_via.ts";
1112 import { repoVisible } from "./visibility.ts";
1213
1314 const log = createLog("events");
@@ -19,6 +20,8 @@ export interface ForgeEvent {
1920 readonly actorId: string | null;
2021 readonly payload: Record<string, unknown>;
2122 readonly createdAt: string;
23 /** Set when the actor acted through the forge's agent: `conversation:<id>`. */
24 readonly via?: string;
2225 }
2326
2427 export type EventListener = (event: ForgeEvent) => void;
@@ -51,6 +54,7 @@ export class EventBus {
5154 actorId?: string | undefined;
5255 payload?: Record<string, unknown> | undefined;
5356 }): ForgeEvent {
57 const via = currentVia(input.actorId);
5458 const event: ForgeEvent = {
5559 id: id(),
5660 kind: input.kind,
@@ -58,10 +62,11 @@ export class EventBus {
5862 actorId: input.actorId ?? null,
5963 payload: scrubPayload(input.payload ?? {}),
6064 createdAt: new Date().toISOString(),
65 ...(via === undefined ? {} : { via }),
6166 };
6267 this.db
6368 .query(
64 "INSERT INTO event (id, kind, repo_id, actor_id, payload, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
69 "INSERT INTO event (id, kind, repo_id, actor_id, payload, created_at, via) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
6570 )
6671 .run(
6772 event.id,
@@ -70,6 +75,7 @@ export class EventBus {
7075 event.actorId,
7176 JSON.stringify(event.payload),
7277 event.createdAt,
78 via ?? null,
7379 );
7480
7581 for (const listener of this.listeners) {
@@ -176,6 +182,7 @@ interface EventRow {
176182 actor_id: string | null;
177183 payload: string;
178184 created_at: string;
185 via?: string | null;
179186 }
180187
181188 const toEvent = (r: EventRow): ForgeEvent => ({
@@ -185,6 +192,7 @@ const toEvent = (r: EventRow): ForgeEvent => ({
185192 actorId: r.actor_id,
186193 payload: JSON.parse(r.payload) as Record<string, unknown>,
187194 createdAt: r.created_at,
195 ...(r.via ? { via: r.via } : {}),
188196 });
189197
190198 /** One event by id, or undefined. No access check: the caller owes one. */
src/core/events_page.ts +13 −2
@@ -1,6 +1,8 @@
11 import type { Database } from "bun:sqlite";
22 import { audienceFilter, eventForReader } from "./event_audience.ts";
3 import { listEvents } from "./events.ts";
3 import { VIA_WORDS } from "./acting_via.ts";
4 import { listEvents, type ForgeEvent } from "./events.ts";
5 import { getUsersByIds } from "./users.ts";
46
57 export const EVENTS_LIMIT_DEFAULT = 50;
68 export const EVENTS_LIMIT_MAX = 200;
@@ -54,5 +56,14 @@ export function eventsPage(
5456 const page = found.slice(0, limit);
5557 const next =
5658 found.length > limit ? page[page.length - 1]!.id : more && scanned !== undefined ? scanned : null;
57 return { items: page.map(toReader), next_cursor: next };
59 // An event made through the agent names who and how: "trevato via the forge's agent".
60 const people = getUsersByIds(
61 db,
62 page.filter((e) => e.via !== undefined && e.actorId !== null).map((e) => e.actorId!),
63 );
64 const said = (event: ForgeEvent): ForgeEvent & { by?: string } => {
65 const person = event.via === undefined || event.actorId === null ? undefined : people.get(event.actorId);
66 return person === undefined ? event : { ...event, by: `${person.username} ${VIA_WORDS}` };
67 };
68 return { items: page.map(toReader).map(said), next_cursor: next };
5869 }
src/core/migration-pins/20261101120000-via-token.pin added +1
@@ -0,0 +1,1 @@
1 4dec77910a590703a4996af96ef93cfd5fb0623377f858610f5fa98f9e18edaf
src/core/migrations/20261101120000-via-token.ts added +5
@@ -0,0 +1,5 @@
1 // Every event written while a conversation's token (`core/via_token.ts`) is
2 // the caller carries its conversation, so the record says who and how (#7648).
3 export default `
4 ALTER TABLE event ADD COLUMN via TEXT;
5 `;
src/design/activity.ts +3 −1
@@ -9,6 +9,7 @@
99 * when the row has no actor: a deploy is the forge's own doing, and says so.
1010 */
1111
12 import { VIA_WORDS } from "../core/acting_via.ts";
1213 import type { ForgeEvent } from "../core/events.ts";
1314
1415 /** The pull number an environment name carries, for `pull-12`. */
@@ -86,7 +87,8 @@ export function eventActor(
8687 event: ForgeEvent,
8788 actorName: string | undefined,
8889 ): string {
89 if (actorName !== undefined) return actorName;
90 if (actorName !== undefined)
91 return event.via === undefined ? actorName : `${actorName} ${VIA_WORDS}`;
9092 return event.kind.startsWith("run.") ||
9193 event.kind.startsWith("app.") ||
9294 event.kind.startsWith("prompt.") ||
src/http/api.ts +27
@@ -38,6 +38,7 @@ import {
3838 signOutHere,
3939 } from "../actions/signins.ts";
4040 import { STRANGER_MAX } from "./limits.ts";
41 import { VIA_FORM, WORDS_MAX } from "../core/fence.ts";
4142 import {
4243 askedIn,
4344 askedJson,
@@ -110,6 +111,7 @@ import {
110111 createToken,
111112 deleteToken,
112113 HUMAN_CHECK_MESSAGE,
114 mintConversationToken,
113115 PASSKEY_REQUIRED_MESSAGE,
114116 signUp,
115117 SIGNUP_CLOSED_MESSAGE,
@@ -1276,6 +1278,31 @@ export function apiRoutes(router: Router<Handler>): void {
12761278 return json(minted.value, 201);
12771279 });
12781280
1281 // The token the forge's agent acts with in one conversation (#7648): only
1282 // the person's own browser session mints it, same-origin, for themself.
1283 // Room for `words` at their cap even when every character is escaped.
1284 const CONVERSATION_BODY_MAX = 128_000;
1285 router.add("POST", "/api/v1/conversation-tokens", async (ctx) => {
1286 if (ctx.auth.via !== "session" || !ctx.auth.user) return unauthorized();
1287 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden();
1288 const input = await boundedBody(ctx.request, CONVERSATION_BODY_MAX);
1289 if (input === "too_large")
1290 return apiError("too_large", 413, `at most ${CONVERSATION_BODY_MAX} bytes`);
1291 const { conversation, words = "" } = input;
1292 if (conversation !== undefined && !(typeof conversation === "string" && VIA_FORM.test(`conversation:${conversation}`)))
1293 return apiError("invalid_conversation", 422, "conversation is 1 to 40 lowercase letters or digits");
1294 if (typeof words !== "string" || words.length > WORDS_MAX)
1295 return apiError("invalid_words", 422, `words is a string of at most ${WORDS_MAX} characters`);
1296 const minted = mintConversationToken(ctx.app, ctx.auth.user, {
1297 ...(conversation === undefined ? {} : { conversation }),
1298 words,
1299 });
1300 if (!minted.ok && minted.error === "invalid_via")
1301 return apiError("invalid_conversation", 422, "not a conversation of yours");
1302 if (!minted.ok) return unauthorized();
1303 return json(minted.value, 201);
1304 });
1305
12791306 // The first admin, headless (`core/first_admin.ts`): the operator's
12801307 // one-time code is the credential, so no session or password is asked.
12811308 // It makes the admin and answers once with a token of the asked scope.
src/http/server.ts +11 −3
@@ -31,6 +31,7 @@ import { boardsPage } from "../web/pages/boards.ts";
3131 import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts";
3232 import { boardsHoldPost } from "../web/pages/boards-holds.ts";
3333 import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts";
34 import { actingVia } from "../core/acting_via.ts";
3435 import {
3536 closeStreamsOf,
3637 closeStreamsOfSession,
@@ -2668,10 +2669,17 @@ async function answerGet(
26682669 const replayAs =
26692670 credential ??
26702671 (auth.via === "session" && auth.user ? `user:${auth.user.id}` : undefined);
2671 const response =
2672 // A conversation's token writes as its person, by way of the agent:
2673 // every event the handler appends carries it, at no append site's cost.
2674 const acting =
2675 auth.through !== undefined && auth.user !== undefined
2676 ? { userId: auth.user.id, via: auth.through }
2677 : undefined;
2678 const response = await actingVia(acting, () =>
26722679 replayAs === undefined
2673 ? await invoke(request)
2674 : await withIdempotency(app, request, url, replayAs, invoke);
2680 ? invoke(request)
2681 : withIdempotency(app, request, url, replayAs, invoke),
2682 );
26752683 // A page route's HTML 404 (`/:owner` claims `/nope`) is a refusal an
26762684 // agent cannot read; one that asked for JSON gets it as JSON.
26772685 if (
src/web/pages/thread.ts +4 −1
@@ -27,6 +27,7 @@ import {
2727 setIssueLabelsThrough,
2828 setIssueStateThrough,
2929 } from "../../actions/issues.ts";
30 import { VIA_WORDS } from "../../core/acting_via.ts";
3031 import { listEvents } from "../../core/events.ts";
3132 import { markOf, marksByName, type Mark } from "../../core/marks.ts";
3233 import {
@@ -222,7 +223,9 @@ function stateEvents(ctx: Ctx, repo: Repo, issue: Issue): Entry[] {
222223 )}`
223224 : html`${actor
224225 ? userLink(actor.username, { mark: markOf(actor) })
225 : html`<span>someone</span>`} <span>${look.verb}</span> ${time(
226 : html`<span>someone</span>`}${event.via !== undefined && actor
227 ? html` <span>${VIA_WORDS}</span>`
228 : null} <span>${look.verb}</span> ${time(
226229 event.createdAt,
227230 )}`,
228231 }),
test/via-token.test.ts added +235
@@ -0,0 +1,235 @@
1 /**
2 * The forge's agent acts as the person it talks to, and the trail says so:
3 * a conversation's token (`core/via_token.ts`) is rw, the person's own and
4 * short-lived, and every event it writes has the person as actor and `via`
5 * set (#7648). It reaches the forge only through `/mcp` (`http/via_gate.ts`).
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { eventActor } from "../src/design/activity.ts";
15 import { actingVia } from "../src/core/acting_via.ts";
16 import { listEvents } from "../src/core/events.ts";
17 import { eventsPage } from "../src/core/events_page.ts";
18 import { getRepo } from "../src/core/repos.ts";
19 import { createToken, listTokens, verifyToken } from "../src/core/tokens.ts";
20 import { mintViaToken, VIA_TOKEN_MAX_MS } from "../src/core/via_token.ts";
21 import { createIssue } from "../src/core/issues.ts";
22 import { createSession } from "../src/core/sessions.ts";
23 import { createUser, type User } from "../src/core/users.ts";
24 import { serve, type Servers } from "../src/http/server.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26
27 quietLogs();
28
29 describe("a conversation's token", () => {
30 let dataDir: string;
31 let app: App;
32 let servers: Servers;
33 let alice: User;
34 let bob: User;
35 let aliceVia: string;
36 let bobVia: string;
37 let alicePlain: string;
38
39 const call = (token: string, path: string, init: RequestInit = {}) =>
40 fetch(`${servers.url}${path}`, {
41 ...init,
42 headers: { "content-type": "application/json", authorization: `Bearer ${token}`, ...init.headers },
43 });
44
45 const mcp = async (token: string, name: string, args: Record<string, unknown>) => {
46 const res = await call(token, "/mcp", {
47 method: "POST",
48 headers: { accept: "application/json, text/event-stream" },
49 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
50 });
51 const body = (await res.json()) as { result: { isError?: boolean; content: { text: string }[] } };
52 return { error: body.result.isError === true, text: body.result.content[0]!.text };
53 };
54
55 const person = async (username: string, first = false) => {
56 const made = await createUser(app.db, {
57 origin: "seed",
58 trusted: true,
59 username,
60 email: `${username}@forge.local`,
61 password: "a-good-passphrase",
62 adminIfFirst: first,
63 });
64 if (!made.ok) throw new Error(made.error);
65 return made.value;
66 };
67
68 beforeAll(async () => {
69 dataDir = await mkdtemp(join(tmpdir(), "forge-via-token-"));
70 app = createApp({ dataDir, port: 0, host: "forge.test" });
71 servers = await serve(app);
72 alice = await person("alice", true);
73 bob = await person("bob");
74 for (const [name, visibility] of [["notes", "public"], ["vault", "private"]] as const) {
75 const made = await createRepo(app, alice, { name, visibility });
76 if (!made.ok) throw new Error(String(made.error));
77 }
78 const soon = new Date(Date.now() + 10 * 60_000).toISOString();
79 const a = mintViaToken(app.db, alice.id, "conversation:c1", "close issue 1 in notes", soon);
80 const b = mintViaToken(app.db, bob.id, "conversation:c2", "", soon);
81 if (!a.ok || !b.ok) throw new Error("mint failed");
82 aliceVia = a.value.token;
83 bobVia = b.value.token;
84 alicePlain = createToken(app.db, alice.id, "personal", "rw").token;
85 });
86
87 afterAll(async () => {
88 await servers.stop();
89 await rm(dataDir, { recursive: true, force: true });
90 });
91
92 const notesId = () => getRepo(app.db, "alice", "notes")!.id;
93 const open = (authorId: string, title: string) => {
94 const made = createIssue(app.db, { repoId: notesId(), authorId, title, body: "" });
95 if (!made.ok) throw new Error(made.error);
96 return made.value.number;
97 };
98
99 const mintThroughSession = (headers: Record<string, string>, body: unknown = { words: "say hello on issue 2 in notes" }) =>
100 fetch(`${servers.url}/api/v1/conversation-tokens`, {
101 method: "POST",
102 headers: { "content-type": "application/json", ...headers },
103 body: JSON.stringify(body),
104 });
105
106 test("is minted by the person's own session, same-origin, and acts as them via the agent", async () => {
107 expect(open(alice.id, "First")).toBe(1);
108 expect(open(alice.id, "Second")).toBe(2);
109 const cookie = `forge_session=${createSession(app.db, bob.id).token}`;
110 const res = await mintThroughSession({ cookie, origin: servers.url });
111 expect(res.status).toBe(201);
112 const made = (await res.json()) as { token: string; via: string; expiresAt: string };
113 expect(made.via).toMatch(/^conversation:[0-9a-z]{26}$/);
114 expect(Date.parse(made.expiresAt) - Date.now()).toBeLessThanOrEqual(VIA_TOKEN_MAX_MS);
115 const verified = verifyToken(app.db, made.token);
116 expect(verified.ok && verified.value.user.id).toBe(bob.id);
117 expect(verified.ok && verified.value.via).toBe(made.via);
118 const minted = listEvents(app.db, { kind: "token.created" }).find((e) => e.payload.via === made.via)!;
119 expect(minted.actorId).toBe(bob.id);
120 // What bob's conversation writes is bob's, by way of the agent.
121 const said = await mcp(made.token, "say", { repo: "alice/notes", number: 2, text: "hello" });
122 expect(said.error).toBe(false);
123 const event = listEvents(app.db, { kind: "issue.commented" }).find((e) => e.actorId === bob.id)!;
124 expect(event.via).toBe(made.via);
125 // The same conversation goes on with the person's newest words.
126 const again = await mintThroughSession({ cookie, origin: servers.url }, { conversation: made.via.slice(13), words: "and then" });
127 expect(again.status).toBe(201);
128 expect(((await again.json()) as { via: string }).via).toBe(made.via);
129 });
130
131 test("is refused to a token, a stranger, another origin and a malformed conversation", async () => {
132 const cookie = `forge_session=${createSession(app.db, bob.id).token}`;
133 expect((await mintThroughSession({ authorization: `Bearer ${alicePlain}` })).status).toBe(401);
134 expect((await mintThroughSession({ authorization: `Bearer ${aliceVia}` })).status).toBe(403);
135 expect((await mintThroughSession({})).status).toBe(401);
136 expect((await mintThroughSession({ cookie, origin: "https://evil.test" })).status).toBe(403);
137 const bad = await mintThroughSession({ cookie, origin: servers.url }, { conversation: "../x" });
138 expect(bad.status).toBe(422);
139 expect(((await bad.json()) as { error: string }).error).toBe("invalid_conversation");
140 expect((await mintThroughSession({ cookie, origin: servers.url }, { conversation: 7 })).status).toBe(422);
141 expect((await mintThroughSession({ cookie, origin: servers.url }, { words: 7 })).status).toBe(422);
142 // Another person's conversation is not bob's to go on with: its marks stay alice's.
143 const theirs = await mintThroughSession({ cookie, origin: servers.url }, { conversation: "c1", words: "close everything" });
144 expect(theirs.status).toBe(422);
145 });
146
147 test("is rw, bound to its person, expiring within the turn, even for an admin", () => {
148 const verified = verifyToken(app.db, aliceVia);
149 expect(verified.ok).toBe(true);
150 if (!verified.ok) return;
151 expect(verified.value.user.id).toBe(alice.id);
152 expect(verified.value.scope).toBe("rw");
153 expect(verified.value.via).toBe("conversation:c1");
154 const row = listTokens(app.db, alice.id).find((t) => t.name === "via:conversation:c1")!;
155 expect(Date.parse(row.expiresAt!) - Date.now()).toBeLessThanOrEqual(VIA_TOKEN_MAX_MS);
156 const plain = verifyToken(app.db, alicePlain);
157 expect(plain.ok && plain.value.via).toBeUndefined();
158 });
159
160 test("an issue closed through it is the person's, via the agent", async () => {
161 const number = 1;
162 const closed = await mcp(aliceVia, "close", { repo: "alice/notes", number });
163 expect(closed.error).toBe(false);
164
165 const events = listEvents(app.db, {}).filter((e) => e.payload.number === number);
166 const kinds = events.map((e) => e.kind);
167 expect(kinds).toContain("issue.closed");
168 for (const e of events.filter((x) => x.actorId !== null)) {
169 expect(e.actorId).toBe(alice.id);
170 expect(e.via).toBe("conversation:c1");
171 }
172
173 // MCP `events` and the trail's words say who and how.
174 const page = await mcp(alicePlain, "events", { repo: "alice/notes" });
175 const items = (JSON.parse(page.text) as { items: { kind: string; via?: string; by?: string }[] }).items;
176 const close = items.find((i) => i.kind === "issue.closed")!;
177 expect(close.via).toBe("conversation:c1");
178 expect(close.by).toBe("alice via the forge's agent");
179 expect(eventActor(events.find((e) => e.kind === "issue.closed")!, "alice")).toBe("alice via the forge's agent");
180
181 // The issue's own timeline says it too, after the person's name.
182 const thread = await (await fetch(`${servers.url}/alice/notes/issues/${number}`)).text();
183 expect(thread).toMatch(/>alice<\/a[^]{0,300}?> <span>via the forge&#39;s agent<\/span> <span>closed this<\/span>/);
184 });
185
186 test("names nobody via the agent when the row names nobody", () => {
187 const at = new Date().toISOString();
188 const via = "conversation:c1";
189 expect(eventActor({ id: "e", kind: "issue.closed", repoId: null, actorId: "gone", payload: {}, createdAt: at, via }, undefined)).toBe("someone");
190 expect(eventActor({ id: "e", kind: "run.started", repoId: null, actorId: null, payload: {}, createdAt: at, via }, undefined)).toBe("the forge");
191 });
192
193 test("a person's own token writes without via", async () => {
194 const opened = await call(alicePlain, "/api/v1/repos/alice/notes/issues", {
195 method: "POST",
196 body: JSON.stringify({ title: "By hand" }),
197 });
198 expect(opened.status).toBe(201);
199 const { number } = (await opened.json()) as { number: number };
200 const event = listEvents(app.db, { kind: "issue.opened" }).find((e) => e.payload.number === number)!;
201 expect(event.actorId).toBe(alice.id);
202 expect(event.via).toBeUndefined();
203 const item = eventsPage(app.db, getRepo(app.db, "alice", "notes")!.id, alice, 50).items.find((i) => i.id === event.id)!;
204 expect("by" in item).toBe(false);
205 const page = await (await fetch(`${servers.url}/alice/notes/issues/${number}`)).text();
206 expect(page).not.toMatch(/via the forge/);
207 });
208
209 test("marks only the person it acts for, never what the forge writes on the way", () => {
210 const written = actingVia({ userId: alice.id, via: "conversation:c1" }, () => [
211 app.bus.append({ kind: "test.mine", actorId: alice.id }),
212 app.bus.append({ kind: "test.forge" }),
213 app.bus.append({ kind: "test.other", actorId: bob.id }),
214 ]);
215 expect(written.map((e) => e.via)).toEqual(["conversation:c1", undefined, undefined]);
216 });
217
218 test("person B's token cannot read person A's private repository", async () => {
219 expect((await mcp(bobVia, "repo", { repo: "alice/vault" })).error).toBe(true);
220 expect((await mcp(bobVia, "events", { repo: "alice/vault" })).error).toBe(true);
221 // The owner's own conversation reads it: the agent holds what its person holds.
222 expect((await mcp(aliceVia, "repo", { repo: "alice/vault" })).error).toBe(false);
223 // And no door but /mcp opens to either.
224 expect((await call(aliceVia, "/api/v1/repos/alice/vault")).status).toBe(403);
225 });
226
227 test("cannot mint tokens: the account itself is never the agent", async () => {
228 const res = await call(aliceVia, "/api/v1/tokens", {
229 method: "POST",
230 body: JSON.stringify({ name: "escalate", scope: "admin" }),
231 });
232 expect(res.status).toBe(403);
233 expect(listTokens(app.db, alice.id).some((t) => t.name === "escalate")).toBe(false);
234 });
235 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs