Agent identity: session tokens, Bearer-only API, honest history attribution - #178
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 91bcb32255
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| def api_user_id | ||
| current_user&.id |
There was a problem hiding this comment.
Propagate agent identity into the initial plan version
When a Bearer-authenticated agent calls POST /api/v1/plans, PlansController#create still invokes Plans::Create with only user, and that service hardcodes version 1 as actor_type: "human" without an agent_name. Consequently, every agent-created plan permanently attributes its initial content to the human, contradicting the new identity model and the documented promise that every version is stamped; pass api_author_type, api_user_id, and api_agent_name through the creation path as well.
AGENTS.md reference: AGENTS.md:L5-L5
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed: API plan creation now passes the actor type, user ID, agent name, and API token ID into Plans::Create, so the initial version is attributed to the agent instead of the human. Added request and service regression coverage.
…bution Agent edits in production were showing up in the history tab as edits the user made themselves. Root cause: hook-authenticated API calls (the production proxy authenticates curl automatically) derived actor_type from the auth mechanism, so an agent arriving on the user's credentials wrote actor_type "human" — and the history partials hid actor_type whenever actor_id resolved to a user anyway. Three changes, one story — every API caller now has a real identity: - Session tokens: each agent run mints its own short-lived token (POST /api/v1/tokens) carrying an agent_name, either as a child of a long-lived settings-UI root token (revoking the root cascades) or bootstrapped straight from the host's request auth. DELETE /api/v1/tokens/current lets a run revoke itself on exit. - Bearer-only API: every other API call now requires the token. Hook auth proves which human is behind the wire, not which agent is acting, so it is good for exactly one thing — minting. - Attribution matching comments: versions and events store the human in actor_id (never the token id) plus the acting agent in a new agent_name column, and the history tab renders "Claude (via Hampton)" the way comment authors already do. Comments themselves now fall back to the token's agent_name instead of rejecting agent posts that omitted the param. The api_tokens migration is guarded with column_exists? because main's db/schema.rb has carried these columns (and the agent-collaboration tables) without backing migrations since the schema regeneration in #175 — schema-loaded databases already have them, production's migrated database does not. Existing production rows written as "human" cannot be re-attributed; the fix is forward-looking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Identity facts beyond a display name — harness, harness version, model —
now travel with the token, not the attribution rows. Minting accepts a
schemaless `metadata` JSON object (capped at 4 KB, non-hash input coerced
to {} rather than failing the mint), and every version, event, and
comment an agent writes records `api_token_id` as a provenance join back
to the token that produced it. actor/author stays the human; agent_name
stays the denormalized display string the history UI already renders.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a01147-8d44-7468-ac98-bf40b04e3cee Co-authored-by: Amp <amp@ampcode.com>
1fed286 to
346d1c0
Compare
The bug
Agent edits made in production showed on the history tab as edits the human made themselves. Two compounding causes:
curlautomatically (the API config even says so), so a local agent's requests arrive on the user's credentials with no Bearer token.api_author_typederived actor type from the auth mechanism (token →local_agent, hook →human), so every agent edit was persisted asactor_type: "human"with the user's id.actor_idresolved to a user, and only showed the actor-type badge in the else branch. Even a correctly-typed agent version rendered as the plain human.The fix: every API caller has a real identity
Session tokens (
POST /api/v1/tokens): each agent run mints its own short-lived token (default 12h, cap 7d) carrying anagent_name— either as a child of a long-lived root token from the settings UI (revoking the root revokes everything it minted; children can't mint, so the tree stays one level deep), or bootstrapped directly from the host's request auth for zero-setup production use.DELETE /api/v1/tokens/currentlets a run revoke itself on exit instead of leaving a live credential until the TTL.Bearer-only API: every other
/api/v1call now requires the token, with a 403 that tells the caller exactly how to mint. Hook auth proves which human is behind the wire, never which agent is acting — so it's accepted for exactly one thing: minting.Attribution matching comments:
coplan_plan_versionsandcoplan_plan_eventsgainagent_name; API writes store the human inactor_id(never the token id — a token id names nobody in a history tab) and the acting agent inagent_name, resolved per-request → token'sagent_name→ token's name. The history tab renders "Claude (via Hampton)", exactly the convention comment authors already use. The expired-session auto-commit job resolves identity the same way. API comments now fall back to the token's agent name instead of 422ing whenagent_nameis omitted.Token metadata + provenance join (second commit): identity is more than a display name — harness, harness version, model. Rather than growing columns on three tables, minting accepts a schemaless
metadataJSON object ({"harness": "claude-code", "harness_version": "2.1.3", "model": "claude-fable-5"}— keys are convention, like a User-Agent; 4 KB cap; non-hash input coerced to{}so a malformed field never costs an agent its identity), and every version, event, and comment recordsapi_token_id(indexed, FK'd) pointing back at the token that wrote it. The history UI stays one string; anything richer joins through the token. No UI surfacing yet — that's deliberate; a tooltip/detail view can come later without touching the data model.The agent instructions document the mint-first flow (they're re-read every session, so agents adopt it automatically), including the
metadataparam.Notes
column_exists?: main'sdb/schema.rbhas carried these columns — plus the unreleased agent-collaboration tables — without backing migrations since the schema regeneration in Voice commenting: push-to-talk dictation pinned to what you were reading #175. Schema-loaded databases already have the columns; production's migrated database doesn't. The straycoplan_agent_sessions/coplan_agent_eventstables in schema.rb are left for the agent-collaboration PR that owns them."human"can't be re-attributed — nothing distinguishes them. The fix is forward-looking.agent_auth_instructions("Authentication is automatic with curl") should be reworded to describe the mint-first flow — I'll include that in the bump PR.Testing
{}, over-budget 422,api_token_idrecorded on versions and comments. Models + services + API + jobs: 937 examples, 0 failures.🤖 Generated with Claude Code
Update — 2026-08-17
mainand resolved the schema-version conflict while preserving the newer schema.Update — 2026-08-17 (attribution audit)