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

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

opened this

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?) in src/core/tokens.ts makes a token that is rw, bound to that one person, and expires within the turn (15 minutes at most). It refuses a malformed via, 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 via column to token and to event. Once auth is resolved, the listener in src/http/server.ts wraps the handler in a request-wide context (src/core/acting_via.ts). EventBus.append reads that context and fills in via only 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 MCP events, the API and the TUI events screen, adds by: "alice via the forge's agent". The shared eventActor gives 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.ts covers:

- 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

Closes #7648

commented

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?) in src/core/tokens.ts makes a token that is rw, bound to that one person, and expires within the turn (15 minutes at most). It refuses a malformed via, 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 via column to token and to event. Once auth is resolved, the listener in src/http/server.ts wraps the handler in a request-wide context (src/core/acting_via.ts). EventBus.append reads that context and fills in via only 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 MCP events, the API and the TUI events screen, adds by: "alice via the forge's agent". The shared eventActor gives 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.ts covers:

- 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

Changes requested — before the latest push

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:

  1. 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.
  2. createToken now accepts an unvalidated via option. Validate it there (validVia) or make it module-private, so a bad string can never be stored and later printed on the trail.
  3. The README does not say how the trail reads ("alice via the forge's agent"), or what the token is. Add it.
  4. 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.
commented

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-tokens with {"conversation": "<id>"} mints the token for the signed-in person (mintConversationToken in src/actions/users.ts, route in src/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.

  • via can no longer be stored by createToken: the via option is gone from it. The database insert is now a private insertToken, and only mintViaToken passes a via, after checking it with validVia. A test shows that a via slipped into createToken is 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 by field of MCP events and GET …/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

Approved — before the latest push

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.

commented

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.ts is main's version. This branch's own mintViaToken and the token.via column are gone. The migration 20261101120000-via-token now only adds event.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's auth.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 optional words (what the person said to start the turn, at most 20,000 characters; the fence weighs writes against it). Without conversation it 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 say and close.
  • 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 createToken cannot 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

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs