Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Packages without a separate changelog are covered by the cross-package notes below.

## [Unreleased]
## [Unreleased - Minor]

### Added

- `POST /agents/{name}/revoke` invalidates an agent's token while keeping the agent and its history on the record. Requests carrying a revoked credential are refused with `agent_token_revoked` (HTTP 401), which is distinct from `agent_token_invalid` so a deliberate revocation is not mistaken for an unknown token. Use it instead of `DELETE /agents/{name}` to contain a leaked credential — deletion fails for any agent that has posted a message and destroys audit history where it succeeds — and instead of `POST /agents/{name}/rotate-token`, which also invalidates but returns a live replacement token in its response. Requires migration `0034`, which must be applied before or with this release; see `docs/revoking-an-agent-credential.md` for the operator runbook.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This entry is one long bullet full of implementation backstory (why deletion fails, what rotate-token returns) rather than a short impact-first note. Per AGENTS.md CHANGELOG rules, each user-visible change should be "one short impact-first bullet" with backstory omitted.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CHANGELOG.md, line 23:

<comment>This entry is one long bullet full of implementation backstory (why deletion fails, what rotate-token returns) rather than a short impact-first note. Per AGENTS.md CHANGELOG rules, each user-visible change should be "one short impact-first bullet" with backstory omitted.</comment>

<file context>
@@ -16,7 +16,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+### Added
+
+- `POST /agents/{name}/revoke` invalidates an agent's token while keeping the agent and its history on the record. Requests carrying a revoked credential are refused with `agent_token_revoked` (HTTP 401), which is distinct from `agent_token_invalid` so a deliberate revocation is not mistaken for an unknown token. Use it instead of `DELETE /agents/{name}` to contain a leaked credential — deletion fails for any agent that has posted a message and destroys audit history where it succeeds — and instead of `POST /agents/{name}/rotate-token`, which also invalidates but returns a live replacement token in its response. Requires migration `0034`, which must be applied before or with this release; see `docs/revoking-an-agent-credential.md` for the operator runbook.
 
 ## [6.3.2] - 2026-08-02
</file context>


## [6.3.2] - 2026-08-02

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ Relaycast is the messaging backbone:

API errors use `{ ok: false, error: { code, message } }`. Invalid or expired agent tokens return `agent_token_invalid` with HTTP 401; clients should recover by re-registering or rotating the agent identity, then retrying the failed operation.

A deliberately revoked agent token returns `agent_token_revoked` with HTTP 401 instead. This is not a transient failure and retrying will not clear it: the seat was contained on purpose via `POST /agents/{name}/revoke`, which invalidates the credential while keeping the agent and its history on the record. Re-registering under the same identity is not the recovery path — issue a new seat if the work still needs doing.

## Telemetry Attribution

Clients may declare who is driving a request so server-side product telemetry
Expand Down
150 changes: 150 additions & 0 deletions docs/revoking-an-agent-credential.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Revoking an agent credential

For containing a leaked `at_live_` agent token. This invalidates the credential
and keeps the record.

## What counts as done

**A negative-auth receipt: the credential is presented and authentication is
refused.** Nothing weaker is evidence. In particular these are *not* receipts,
and each has been mistaken for one:

| Observation | Why it proves nothing |
|---|---|
| The agent process is gone | `remove_agent` dispatches a *release* to the node. It stops a process. It never touches the credential. |
| The agent is absent from the roster | The roster reflects records, not credentials. |
| `status` is `offline` | `status` is not consulted during authentication at all. |
| An API call returned `dispatched` / `200` | Return shape is not behaviour. Read the state back. |

The only thing that settles it is a request carrying the token coming back
`401 agent_token_revoked`.

## Do not use DELETE for this

`DELETE /v1/agents/:name` is not a containment tool and cannot be made into one:

- **It fails on any seat with history.** Four foreign keys onto `agents(id)` are
`ON DELETE NO ACTION` — `messages.agent_id`, `channels.created_by`,
`files.uploaded_by`, `webhooks.created_by`. A seat that has posted a single
message fails with `FOREIGN KEY constraint failed`.
- **It destroys history on the seats where it does succeed.**
`dm_participants.agent_id` cascades, which is how ordinary two-party DMs
collapsed into one-row rosters (see `scripts/audit-dm-reservations.mjs`).
- **It erases the distinction you need.** A deleted token authenticates as
`agent_token_invalid` — identical to a token that was never issued. You lose
the ability to prove the credential was deliberately contained.

Deletion succeeds only where there is no audit trail to protect and fails exactly
where there is one.

## Do not rotate

`POST /v1/agents/:name/rotate-token` will look like the answer. It does
invalidate the leaked credential — it overwrites `token_hash`, so the old token
stops authenticating immediately. Do not use it for containment anyway: it
returns the replacement token in its response body, and `register_agent` returns
a live token in its reply too (relay#1389). Both put a working credential
straight back into a transcript, which is the leak you are containing. You would
trade a known-leaked token for a freshly-leaked one and call it done.

Rotation is the right tool when a seat must keep working and you control where
the new token lands. It is the wrong tool when the goal is containment. Revoke
without replacement; issue a new seat separately if the work still needs doing.

## Handling the token safely

The token must never reach a shell argument, an environment listing, or shell
history. Keep it in a file with tight permissions and feed it to `curl` on stdin
via `--config`, which is the one path where the value is neither in `argv` nor
echoed:

```sh
umask 077
# Populate this from your secret store — do not paste it into the shell.
TOKEN_FILE=$(mktemp)

printf 'header = "Authorization: Bearer %s"\n' "$(cat "$TOKEN_FILE")" \
| curl --config - -s -o /dev/null -w '%{http_code}\n' \
https://<relaycast-host>/v1/agent

shred -u "$TOKEN_FILE" 2>/dev/null || rm -P "$TOKEN_FILE"
```
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

Never add `-v`, `--trace`, or `--trace-ascii` to a command carrying the token —
they print the `Authorization` header. If a token does appear in a transcript,
flag it for rotation of the *workspace* key and record it against relay#1389.

## Procedure

Per seat, with `$WS_KEY` a workspace key (`rk_live_`) — agents cannot revoke
themselves or each other.

**1. Revoke.**

```sh
curl -s -X POST \
-H "Authorization: Bearer $WS_KEY" \
https://<relaycast-host>/v1/agents/<name>/revoke
```

Returns `revoked_at` and `already_revoked`. It is idempotent: re-running reports
`already_revoked: true` and preserves the original timestamp, so the record of
when containment took effect cannot be rewritten by a repeat run.

**2. Take the receipt.** Present the leaked credential using the `--config`
pattern above and record the response:

```sh
printf 'header = "Authorization: Bearer %s"\n' "$(cat "$TOKEN_FILE")" \
| curl --config - -s -w '\n%{http_code}\n' https://<relaycast-host>/v1/agent
```

Expected — and the only acceptable result:

```text
{"ok":false,"error":{"code":"agent_token_revoked","message":"Agent token revoked"}}
401
```

`GET /v1/agent` is the right probe: it is read-only and does nothing but resolve
a token to its identity, so a live credential is confirmed without acting as the
agent.

`401 agent_token_revoked` is the receipt. Record the seat name, the timestamp,
and that code. Do not record the token.

If you get `200`, the credential is live and the seat is **not** contained —
check you targeted the right workspace and that the deployed build includes the
enforcement in `SqliteApiKeyAuthProvider.authenticate`. If the endpoint 404s, the
code is not deployed and no revocation has occurred, whatever else you saw.

## Deploy ordering (read before shipping this)

**Migration 0034 must be applied before or with the code, never after.** The
drizzle schema enumerates every column on each query, so a build that knows about
`revoked_at` cannot talk to an `agents` table that lacks it — verified: an insert
against an unmigrated schema fails with `table agents has no column named
revoked_at`. That is agent registration and agent authentication down, not a
degraded revoke. The failure is at least loud rather than silent, but the
ordering is not optional.

Confirm which database you are migrating. Production is the D1 instance the
worker binds — resolve it through the SST resource `RelaycastDatabase`, never by
the name that happens to match the repo, and pass `--remote`. Two live instances
carry this data under confusingly similar names and audits have been run against
the wrong one before.

**3. Confirm history survived.** The agent row and its messages must still be
present. Revocation that took history with it has traded one problem for a worse
one.

## Scope limits

- **Node tokens are separate credentials.** This revokes the agent's own token. A
node token that posts on the agent's behalf is unaffected and needs its own
decision.
- **A seat already deleted cannot be revoked.** There is no row to mark, and its
token now reports `agent_token_invalid`. That is containment by accident, not a
revocation receipt, and the audit trail for that seat is already gone.
- **Revocation is one-way here.** There is deliberately no un-revoke endpoint;
restoring access means issuing a new seat.
55 changes: 54 additions & 1 deletion openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1123,7 +1123,12 @@ components:
properties:
code:
type: string
description: Machine-readable error code. Invalid agent tokens are reported as `agent_token_invalid` so clients can re-register or rotate the agent identity.
description: >-
Machine-readable error code. Invalid agent tokens are reported as
`agent_token_invalid` so clients can re-register or rotate the agent
identity. A deliberately revoked token is reported as
`agent_token_revoked` instead — a permanent refusal that retrying or
re-registering the same identity will not clear.
message:
type: string

Expand Down Expand Up @@ -2959,6 +2964,54 @@ paths:
schema:
$ref: '#/components/schemas/SuccessResponse'

/agents/{name}/revoke:
post:
summary: Revoke agent token
description: >-
Invalidate an agent's token while keeping the agent and its history on
the record. The credential stops authenticating immediately and
subsequent requests carrying it are refused with `agent_token_revoked`.

Prefer this to `DELETE /agents/{name}` for credential containment:
deletion fails outright for any agent that has posted a message (foreign
keys onto `agents.id` are declared ON DELETE NO ACTION) and destroys
audit history where it does succeed.

Prefer this to `POST /agents/{name}/rotate-token` when the goal is
containment rather than continuity: rotation also invalidates the old
token, but returns a live replacement in its response body.

Idempotent — repeating the call reports `already_revoked` and preserves
the original `revoked_at`. There is no un-revoke; issue a new agent
instead.
tags:
- Agents
security:
- workspaceKey: []
parameters:
- name: name
in: path
required: true
schema:
type: string
responses:
'200':
description: >-
Token revoked. Returns `revoked_at` and `already_revoked`. A success
response means the revocation is persisted; it is not by itself
proof of containment — confirm by presenting the credential and
observing the 401.
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
'404':
description: No such agent, or the revocation could not be confirmed
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

/agents/{name}/rotate-token:
post:
summary: Rotate agent token
Expand Down
12 changes: 11 additions & 1 deletion packages/engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Minor]

### Added

- `POST /agents/{name}/revoke` invalidates an agent credential while leaving the agent row, its messages, and every record referencing it in place. Enforcement is a new `revoked_at` column checked in the agent branch of `SqliteApiKeyAuthProvider.authenticate`; refused requests return `agent_token_revoked` (401), distinct from `agent_token_invalid`. Requires migration `0034`, which must be applied **before or with** this release — the schema enumerates every column per query, so the code cannot talk to an `agents` table without it. Prefer this to `DELETE /agents/{name}`, which fails for any agent that has posted a message (four foreign keys onto `agents.id` are ON DELETE NO ACTION) and cascades away DM history where it succeeds.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This changelog entry is far longer and more implementation-heavy than the project's documented changelog convention. AGENTS.md asks for "one short bullet per user-visible change" and says to "omit ... implementation backstory unless they explain shipped impact." The Added bullet here packs in internal mechanics (the revoked_at column, SqliteApiKeyAuthProvider.authenticate, "the schema enumerates every column per query", the ON DELETE NO ACTION foreign-key count, and DM-cascade internals). Consider trimming the paragraph to the user-facing contract: the new POST /agents/{name}/revoke endpoint, that it returns agent_token_revoked (401) distinct from agent_token_invalid, the migration 0034 requirement, and a one-line note on when to prefer it over DELETE/rotate-token. The migration syntax requirement and AuthProvider.revokeAgentCredential provider-contract detail can stay in the operator runbook (docs/revoking-an-agent-credential.md) rather than the changelog.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/CHANGELOG.md, line 14:

<comment>This changelog entry is far longer and more implementation-heavy than the project's documented changelog convention. `AGENTS.md` asks for "one short bullet per user-visible change" and says to "omit ... implementation backstory unless they explain shipped impact." The `Added` bullet here packs in internal mechanics (the `revoked_at` column, `SqliteApiKeyAuthProvider.authenticate`, "the schema enumerates every column per query", the ON DELETE NO ACTION foreign-key count, and DM-cascade internals). Consider trimming the paragraph to the user-facing contract: the new `POST /agents/{name}/revoke` endpoint, that it returns `agent_token_revoked` (401) distinct from `agent_token_invalid`, the migration `0034` requirement, and a one-line note on when to prefer it over `DELETE`/`rotate-token`. The migration syntax requirement and `AuthProvider.revokeAgentCredential` provider-contract detail can stay in the operator runbook (`docs/revoking-an-agent-credential.md`) rather than the changelog.</comment>

<file context>
@@ -7,7 +7,17 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight
+
+### Added
+
+- `POST /agents/{name}/revoke` invalidates an agent credential while leaving the agent row, its messages, and every record referencing it in place. Enforcement is a new `revoked_at` column checked in the agent branch of `SqliteApiKeyAuthProvider.authenticate`; refused requests return `agent_token_revoked` (401), distinct from `agent_token_invalid`. Requires migration `0034`, which must be applied **before or with** this release — the schema enumerates every column per query, so the code cannot talk to an `agents` table without it. Prefer this to `DELETE /agents/{name}`, which fails for any agent that has posted a message (four foreign keys onto `agents.id` are ON DELETE NO ACTION) and cascades away DM history where it succeeds.
+- `AuthProvider.revokeAgentCredential` (optional). Providers backed by an external identity store leave it undefined and the endpoint refuses with `revocation_unsupported` (501) rather than recording a revocation their authenticator never consults.
+
</file context>
Suggested change
- `POST /agents/{name}/revoke` invalidates an agent credential while leaving the agent row, its messages, and every record referencing it in place. Enforcement is a new `revoked_at` column checked in the agent branch of `SqliteApiKeyAuthProvider.authenticate`; refused requests return `agent_token_revoked` (401), distinct from `agent_token_invalid`. Requires migration `0034`, which must be applied **before or with** this release — the schema enumerates every column per query, so the code cannot talk to an `agents` table without it. Prefer this to `DELETE /agents/{name}`, which fails for any agent that has posted a message (four foreign keys onto `agents.id` are ON DELETE NO ACTION) and cascades away DM history where it succeeds.
- `POST /agents/{name}/revoke` invalidates an agent credential while leaving the agent row and its history in place. Refused requests return `agent_token_revoked` (401), distinct from `agent_token_invalid`. Requires migration `0034`. Prefer it to `DELETE /agents/{name}` to contain a leaked credential; see `docs/revoking-an-agent-credential.md` for the operator runbook.

- `AuthProvider.revokeAgentCredential` (optional). Providers backed by an external identity store leave it undefined and the endpoint refuses with `revocation_unsupported` (501) rather than recording a revocation their authenticator never consults.

### Changed

- The A2A webhook resolves its bearer token through the configured `AuthProvider` instead of comparing `agents.token_hash` directly, so provider-level checks apply to that route. Previously a revoked A2A proxy credential still authenticated there.


## [6.3.2] - 2026-08-02

Expand Down
Loading
Loading