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.
The forge's agent can now act as the person it talks to, and every event it writes says so: the person is the actor, and the event is marked as made through the agent.
shipped production is serving it deploy a910001
The forge's agent can now act as the person it talks to, and every event it writes says so: the person is the actor, and the event is marked as made through the agent.
- Minting:
mintViaToken(db, userId, "conversation:<id>", ttlMs?)insrc/core/tokens.tsmakes a token that is rw, bound to that one person, and expires within the turn (15 minutes at most). It refuses a malformedvia, a longer lifetime, or a missing or inactive person. When the token is checked, its scope is capped at rw whatever the row says, so it never reaches admin. Minting new tokens still takes a session or password, so this token can't mint one either (it gets a 401). - Marking events: a new migration (pinned) adds a
viacolumn totokenand toevent. Once auth is resolved, the listener insrc/http/server.tswraps the handler in a request-wide context (src/core/acting_via.ts).EventBus.appendreads that context and fills inviaonly when the event's actor is that person. No append site changed, and anything the forge writes on its own along the way stays unmarked. - Display:
eventsPage, which feeds MCPevents, the API and the TUI events screen, addsby: "alice via the forge's agent". The sharedeventActorgives the feed and the TUI the same words, and the thread timeline shows "via the forge's agent" after the person's name. - Tests:
test/via-token.test.tscovers:
- the token's limits and the refusals;
- an issue opened and then closed through MCP close, with the events carrying the person as actor and via set, and MCP events showing by;
- a person's own token writing without via;
- only the person being marked;
- person B's token getting 404 on person A's private repository, through both the API and MCP;
- the token being unable to mint another.
It passes along with 14 nearby suites (144 tests), including mcp-size, schema-migrations, tokens, events-page, run-token, proposal-token and tui-events; type check and design lint are clean.
Left out: nothing calls mintViaToken yet. The conversation it belongs to (#7640) doesn't exist, and putting minting behind an HTTP route would let a token mint tokens. The MCP events doc string doesn't mention by, which keeps the tool cost unchanged. src/http/caller.ts needed no change.
To try it: bun test test/via-token.test.ts.
test/run-token.test.ts contains a red-team README that tells an agent to leak alice/vault's SECRET.md. It is a deliberate test fixture, not an instruction, and was not followed.
🤖 Generated with Claude Code
No high-severity security finding: the scope cap in verifyToken is correct (ro stays ro, admin falls to rw), via marks only the acting person, and the 404 and no-mint tests hold. Changes needed:
- FIRST: nothing calls mintViaToken, so the feature is unreachable. Nothing in src/ calls it; only the test does. Done means reachable. Give the conversation a caller, or an authenticated, session-only, same-origin POST route/MCP path that mints it for the signed-in person, with a test through that door. If #7640 must come first, make this pull wait on it rather than landing dead code.
- createToken now accepts an unvalidated
viaoption. Validate it there (validVia) or make it module-private, so a bad string can never be stored and later printed on the trail. - The README does not say how the trail reads ("alice via the forge's agent"), or what the token is. Add it.
- Add a test for the thread timeline words in src/web/pages/thread.ts and for eventActor with no actor name. Only the happy path is covered.
When the forge's agent acts for someone, the record now names that person as the actor and adds "via the forge's agent". This push fixes the four points from review, and the first one most of all: the token now has a real way to be minted.
- How the token is minted:
POST /api/v1/conversation-tokenswith{"conversation": "<id>"}mints the token for the signed-in person (mintConversationTokeninsrc/actions/users.ts, route insrc/http/api.ts).
- Only that person's own browser session can mint it, from the same origin. A bearer token, a conversation token, a password or no sign-in at all gets 401; a cross-origin post gets 403; a bad conversation id gets 422 invalid_conversation.
- The answer is 201, once, with {id, token, expiresAt, via}. Minting writes a token.created event, and the route is in the OpenAPI document.
- The test mints the token through this route and checks that what it writes has the person as actor with via set.
viacan no longer be stored bycreateToken: theviaoption is gone from it. The database insert is now a privateinsertToken, and onlymintViaTokenpasses avia, after checking it withvalidVia. A test shows that aviaslipped intocreateTokenis never stored.- README: a new paragraph says what the token is (rw, never admin, gone within 15 minutes, holds only what the person holds, cannot mint tokens) and how the trail reads it: "alice via the forge's agent" in the
byfield of MCPeventsandGET …/events, and "alice via the forge's agent closed this" on an issue or pull's timeline. - More tests:
- The issue page's timeline shows the via words after the person's name.
- An issue changed with a plain personal token shows none.
- eventActor with no actor name gives "someone" or "the forge", never the via words.
Not done here: the conversation itself (#7640). This route is the way in that it will use.
To try it: signed in, run fetch('/api/v1/conversation-tokens',{method:'POST',headers:{'content-type':'application/json'},body:'{"conversation":"try-1"}'}) in the browser console. Then close an issue with the returned token as Bearer, and open the issue page.
bun scripts/gate.ts and bun x tsc --noEmit pass. scripts/fast.ts named 1401 test files (core tokens and events reach nearly everything), so I ran the ones that cover this change instead: test/via-token, openapi, openapi-coverage, events-page, first-admin, and the token, activity and migration tests, 190 tests in all, all passing. While they ran, the sandbox blocked one outbound connection to maria--forge.test:443; no test failed.
🤖 Generated with Claude Code
Read the diff since the last round (the clone no longer holds 94421ff519c0, so I read the change as given). The bearer door on the app host is sound: the limiter key comes from the socket (clientBucket), only failed tokens count, a run token is bound to its repository, a read-only token reads only and is a user (never admin) inside the app, and the header is stripped before the app (the test asserts it). Page navigations with a token get an answer instead of a sign-in redirect. The 404 hint goes only to a token that can already read the repository. Tests cover each behaviour, and docs/APPS.md and the brief say how to use it. Non-blocking: the diff is about preview access for runs (#7829), not the via-agent trail in the title, so the pull description should say so.
This merges main into the branch. While this pull was open, main landed its own conversation token in #7677 (core/via_token.ts): a via_token row, refused everywhere except POST /mcp (http/via_gate.ts), with MCP answers fenced as data. The two designs collided in tokens.ts, context.ts and server.ts, so this branch now uses main's token instead of keeping a second, unfenced one.
What changed
- Tokens:
src/core/tokens.tsis main's version. This branch's ownmintViaTokenand thetoken.viacolumn are gone. The migration20261101120000-via-tokennow only addsevent.via; it isn't on main yet, so its pin was deleted and made again. - The trail: events are still stamped through
core/acting_via.ts, now keyed on main'sauth.through(src/http/server.ts). Every action through the forge's agent still reads "alice via the forge's agent". POST /api/v1/conversation-tokens: it now mints main's token. It takes an optionalwords(what the person said to start the turn, at most 20,000 characters; the fence weighs writes against it). Withoutconversationit starts a fresh conversation. With one, it continues only a conversation an earlier token of the same person named; any other is 422.- Why that last rule: the fence's records are keyed by conversation name alone. Without the rule, anyone could mint a token for someone else's conversation and overwrite the words the fence checks against, which would release that person's held writes.
- Behaviour that differs from before:
- The token reaches only /mcp; the API answers 403.
- It lives up to an hour, not 15 minutes.
- Conversation ids are 1 to 40 lowercase letters or digits.
Tests changed, and why: test/via-token.test.ts was rewritten for the gate:
- Writes now go through MCP
sayandclose. - API calls with the token now expect 403 where they expected 401 or 200.
- New tests cover continuing a conversation, refusing someone else's, and refusing a bad
words. - The test that
createTokencannot store a via is gone with the column.
The README section and the OpenAPI entry say the same.
Checks: bun scripts/gate.ts and bun x tsc --noEmit pass. bun scripts/fast.ts couldn't name tests (exit 2: main's own diff, which touches forge.json, is too broad). Instead I ran 18 files covering tokens, the fence, migrations, events, OpenAPI/contract, the README cap and the TUI: 470 tests, 0 failures. The full suite runs on the forge's check.
Try it: signed in, POST /api/v1/conversation-tokens with {"words":"close issue 1 in notes"}, then MCP close with that token. The issue's timeline says "… via the forge's agent closed this".
🤖 Generated with Claude Code
Earlier asks: (1) reachability is met by the session-only, same-origin POST /api/v1/conversation-tokens, with a test through it; (2) the unvalidated via option on createToken is gone, since the pull now uses main's via_token, whose form is checked by VIA_FORM; (3) the README now says what the token is and how the trail reads; (4) the thread timeline and eventActor-with-no-name tests are present. New lines: the mint route caps the body by bytes read (boundedBody), validates conversation and words, and refuses bearer tokens (401) and cross-origin posts (403). Continuing a conversation is limited to the caller's own, so another person's fence marks cannot be overwritten. Events are marked only when the actor is the person the agent acts for. The timeline escapes the via words. No blocking findings.
The forge's agent can now act as the person it talks to, and every event it writes says so: the person is the actor, and the event is marked as made through the agent.
mintViaToken(db, userId, "conversation:<id>", ttlMs?)insrc/core/tokens.tsmakes a token that is rw, bound to that one person, and expires within the turn (15 minutes at most). It refuses a malformedvia, a longer lifetime, or a missing or inactive person. When the token is checked, its scope is capped at rw whatever the row says, so it never reaches admin. Minting new tokens still takes a session or password, so this token can't mint one either (it gets a 401).viacolumn totokenand toevent. Once auth is resolved, the listener insrc/http/server.tswraps the handler in a request-wide context (src/core/acting_via.ts).EventBus.appendreads that context and fills inviaonly when the event's actor is that person. No append site changed, and anything the forge writes on its own along the way stays unmarked.eventsPage, which feeds MCPevents, the API and the TUI events screen, addsby: "alice via the forge's agent". The sharedeventActorgives the feed and the TUI the same words, and the thread timeline shows "via the forge's agent" after the person's name.test/via-token.test.tscovers:- the token's limits and the refusals; - an issue opened and then closed through MCP
close, with the events carrying the person as actor andviaset, and MCPeventsshowingby; - a person's own token writing withoutvia; - only the person being marked; - person B's token getting 404 on person A's private repository, through both the API and MCP; - the token being unable to mint another.It passes along with 14 nearby suites (144 tests), including
mcp-size,schema-migrations,tokens,events-page,run-token,proposal-tokenandtui-events; type check and design lint are clean.Left out: nothing calls
mintViaTokenyet. The conversation it belongs to (#7640) doesn't exist, and putting minting behind an HTTP route would let a token mint tokens. The MCPeventsdoc string doesn't mentionby, which keeps the tool cost unchanged.src/http/caller.tsneeded no change.To try it:
bun test test/via-token.test.ts.test/run-token.test.tscontains a red-team README that tells an agent to leakalice/vault'sSECRET.md. It is a deliberate test fixture, not an instruction, and was not followed.🤖 Generated with Claude Code
Closes #7648