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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7648-an-action-taken-through-the-forge-s-agen → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 235 | 235 | can make a token with admin scope; ro and rw are open to you."), and the |
| 236 | 236 | Settings form offers `admin` only to an admin. |
| 237 | 237 | |
| 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 | ||
| 238 | 253 | The same token spawns a child forge (`dryRun` is true unless `false`, so |
| 239 | 254 | without it you get the plan and nothing is made). The child's admin comes |
| 240 | 255 | from the operator's `--first-admin --child`, or from the `claimUrl` the spawn |
src/actions/users.ts +38
| @@ -22,6 +22,8 @@ import { | ||
| 22 | 22 | deleteToken as revokeToken, |
| 23 | 23 | type TokenScope, |
| 24 | 24 | } from "../core/tokens.ts"; |
| 25 | import { mintViaToken, VIA_TOKEN_MAX_MS } from "../core/via_token.ts"; | |
| 26 | import { id } from "../kernel/id.ts"; | |
| 25 | 27 | import { inviteSpentBy } from "../core/invites.ts"; |
| 26 | 28 | import { |
| 27 | 29 | changePassword as rehash, |
| @@ -256,6 +258,42 @@ export function createToken( | ||
| 256 | 258 | return ok(minted); |
| 257 | 259 | } |
| 258 | 260 | |
| 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 | ||
| 259 | 297 | export function deleteToken( |
| 260 | 298 | app: App, |
| 261 | 299 | actor: User | undefined, |
src/contract/openapi/routes/index.ts +1
| @@ -184,6 +184,7 @@ export { route as postApiV1AsksGiveBack } from "./postApiV1AsksGiveBack.ts"; | ||
| 184 | 184 | export { route as postApiV1AsksProposalIdVerb } from "./postApiV1AsksProposalIdVerb.ts"; |
| 185 | 185 | export { route as postApiV1BoardsPins } from "./postApiV1BoardsPins.ts"; |
| 186 | 186 | export { route as postApiV1BoardsQuery } from "./postApiV1BoardsQuery.ts"; |
| 187 | export { route as postApiV1ConversationTokens } from "./postApiV1ConversationTokens.ts"; | |
| 187 | 188 | export { route as postApiV1Drills } from "./postApiV1Drills.ts"; |
| 188 | 189 | export { route as postApiV1FeedChips } from "./postApiV1FeedChips.ts"; |
| 189 | 190 | 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 @@ | ||
| 8 | 8 | import type { Database } from "bun:sqlite"; |
| 9 | 9 | import { id } from "../kernel/id.ts"; |
| 10 | 10 | import { anyHeld, createLog, redactValues } from "../kernel/log.ts"; |
| 11 | import { currentVia } from "./acting_via.ts"; | |
| 11 | 12 | import { repoVisible } from "./visibility.ts"; |
| 12 | 13 | |
| 13 | 14 | const log = createLog("events"); |
| @@ -19,6 +20,8 @@ export interface ForgeEvent { | ||
| 19 | 20 | readonly actorId: string | null; |
| 20 | 21 | readonly payload: Record<string, unknown>; |
| 21 | 22 | readonly createdAt: string; |
| 23 | /** Set when the actor acted through the forge's agent: `conversation:<id>`. */ | |
| 24 | readonly via?: string; | |
| 22 | 25 | } |
| 23 | 26 | |
| 24 | 27 | export type EventListener = (event: ForgeEvent) => void; |
| @@ -51,6 +54,7 @@ export class EventBus { | ||
| 51 | 54 | actorId?: string | undefined; |
| 52 | 55 | payload?: Record<string, unknown> | undefined; |
| 53 | 56 | }): ForgeEvent { |
| 57 | const via = currentVia(input.actorId); | |
| 54 | 58 | const event: ForgeEvent = { |
| 55 | 59 | id: id(), |
| 56 | 60 | kind: input.kind, |
| @@ -58,10 +62,11 @@ export class EventBus { | ||
| 58 | 62 | actorId: input.actorId ?? null, |
| 59 | 63 | payload: scrubPayload(input.payload ?? {}), |
| 60 | 64 | createdAt: new Date().toISOString(), |
| 65 | ...(via === undefined ? {} : { via }), | |
| 61 | 66 | }; |
| 62 | 67 | this.db |
| 63 | 68 | .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)", | |
| 65 | 70 | ) |
| 66 | 71 | .run( |
| 67 | 72 | event.id, |
| @@ -70,6 +75,7 @@ export class EventBus { | ||
| 70 | 75 | event.actorId, |
| 71 | 76 | JSON.stringify(event.payload), |
| 72 | 77 | event.createdAt, |
| 78 | via ?? null, | |
| 73 | 79 | ); |
| 74 | 80 | |
| 75 | 81 | for (const listener of this.listeners) { |
| @@ -176,6 +182,7 @@ interface EventRow { | ||
| 176 | 182 | actor_id: string | null; |
| 177 | 183 | payload: string; |
| 178 | 184 | created_at: string; |
| 185 | via?: string | null; | |
| 179 | 186 | } |
| 180 | 187 | |
| 181 | 188 | const toEvent = (r: EventRow): ForgeEvent => ({ |
| @@ -185,6 +192,7 @@ const toEvent = (r: EventRow): ForgeEvent => ({ | ||
| 185 | 192 | actorId: r.actor_id, |
| 186 | 193 | payload: JSON.parse(r.payload) as Record<string, unknown>, |
| 187 | 194 | createdAt: r.created_at, |
| 195 | ...(r.via ? { via: r.via } : {}), | |
| 188 | 196 | }); |
| 189 | 197 | |
| 190 | 198 | /** One event by id, or undefined. No access check: the caller owes one. */ |
src/core/events_page.ts +13 −2
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import type { Database } from "bun:sqlite"; |
| 2 | 2 | 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"; | |
| 4 | 6 | |
| 5 | 7 | export const EVENTS_LIMIT_DEFAULT = 50; |
| 6 | 8 | export const EVENTS_LIMIT_MAX = 200; |
| @@ -54,5 +56,14 @@ export function eventsPage( | ||
| 54 | 56 | const page = found.slice(0, limit); |
| 55 | 57 | const next = |
| 56 | 58 | 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 }; | |
| 58 | 69 | } |
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 @@ | ||
| 9 | 9 | * when the row has no actor: a deploy is the forge's own doing, and says so. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { VIA_WORDS } from "../core/acting_via.ts"; | |
| 12 | 13 | import type { ForgeEvent } from "../core/events.ts"; |
| 13 | 14 | |
| 14 | 15 | /** The pull number an environment name carries, for `pull-12`. */ |
| @@ -86,7 +87,8 @@ export function eventActor( | ||
| 86 | 87 | event: ForgeEvent, |
| 87 | 88 | actorName: string | undefined, |
| 88 | 89 | ): string { |
| 89 | if (actorName !== undefined) return actorName; | |
| 90 | if (actorName !== undefined) | |
| 91 | return event.via === undefined ? actorName : `${actorName} ${VIA_WORDS}`; | |
| 90 | 92 | return event.kind.startsWith("run.") || |
| 91 | 93 | event.kind.startsWith("app.") || |
| 92 | 94 | event.kind.startsWith("prompt.") || |
src/http/api.ts +27
| @@ -38,6 +38,7 @@ import { | ||
| 38 | 38 | signOutHere, |
| 39 | 39 | } from "../actions/signins.ts"; |
| 40 | 40 | import { STRANGER_MAX } from "./limits.ts"; |
| 41 | import { VIA_FORM, WORDS_MAX } from "../core/fence.ts"; | |
| 41 | 42 | import { |
| 42 | 43 | askedIn, |
| 43 | 44 | askedJson, |
| @@ -110,6 +111,7 @@ import { | ||
| 110 | 111 | createToken, |
| 111 | 112 | deleteToken, |
| 112 | 113 | HUMAN_CHECK_MESSAGE, |
| 114 | mintConversationToken, | |
| 113 | 115 | PASSKEY_REQUIRED_MESSAGE, |
| 114 | 116 | signUp, |
| 115 | 117 | SIGNUP_CLOSED_MESSAGE, |
| @@ -1276,6 +1278,31 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1276 | 1278 | return json(minted.value, 201); |
| 1277 | 1279 | }); |
| 1278 | 1280 | |
| 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 | ||
| 1279 | 1306 | // The first admin, headless (`core/first_admin.ts`): the operator's |
| 1280 | 1307 | // one-time code is the credential, so no session or password is asked. |
| 1281 | 1308 | // 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"; | ||
| 31 | 31 | import { boardNew, boardPin, boardPost, pinPost } from "../web/pages/boards-edit.ts"; |
| 32 | 32 | import { boardsHoldPost } from "../web/pages/boards-holds.ts"; |
| 33 | 33 | import { boardLive, boardShow, boardsList } from "../web/pages/boards-view.ts"; |
| 34 | import { actingVia } from "../core/acting_via.ts"; | |
| 34 | 35 | import { |
| 35 | 36 | closeStreamsOf, |
| 36 | 37 | closeStreamsOfSession, |
| @@ -2668,10 +2669,17 @@ async function answerGet( | ||
| 2668 | 2669 | const replayAs = |
| 2669 | 2670 | credential ?? |
| 2670 | 2671 | (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, () => | |
| 2672 | 2679 | 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 | ); | |
| 2675 | 2683 | // A page route's HTML 404 (`/:owner` claims `/nope`) is a refusal an |
| 2676 | 2684 | // agent cannot read; one that asked for JSON gets it as JSON. |
| 2677 | 2685 | if ( |
src/web/pages/thread.ts +4 −1
| @@ -27,6 +27,7 @@ import { | ||
| 27 | 27 | setIssueLabelsThrough, |
| 28 | 28 | setIssueStateThrough, |
| 29 | 29 | } from "../../actions/issues.ts"; |
| 30 | import { VIA_WORDS } from "../../core/acting_via.ts"; | |
| 30 | 31 | import { listEvents } from "../../core/events.ts"; |
| 31 | 32 | import { markOf, marksByName, type Mark } from "../../core/marks.ts"; |
| 32 | 33 | import { |
| @@ -222,7 +223,9 @@ function stateEvents(ctx: Ctx, repo: Repo, issue: Issue): Entry[] { | ||
| 222 | 223 | )}` |
| 223 | 224 | : html`${actor |
| 224 | 225 | ? 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( | |
| 226 | 229 | event.createdAt, |
| 227 | 230 | )}`, |
| 228 | 231 | }), |
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'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 | }); |
new above
offline