Skip to content

ai-bot: read a user's realm with a short-lived, read-only token#5317

Open
jurgenwerk wants to merge 6 commits into
mainfrom
cs-11553-ai-bot-shared-secret-infra-delegated-jwt-retrieval
Open

ai-bot: read a user's realm with a short-lived, read-only token#5317
jurgenwerk wants to merge 6 commits into
mainfrom
cs-11553-ai-bot-shared-secret-infra-delegated-jwt-retrieval

Conversation

@jurgenwerk

@jurgenwerk jurgenwerk commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

(Written by Claude on Matic's behalf.)

Why we need this

ai-bot needs to read realm content on a user's behalf for the pull-model skill loader (CS-11554): the bot reads skills out of the realms a user can access (their own realm, and any other realm they have read access to).

Today the only ways to do that are bad:

  • give @aibot a standing read grant on every realm — far more access than it needs, all the time, with a big blast radius if the bot's credentials leak; or
  • don't let the bot read user realms at all.

This adds a third, least-privilege option: the bot asks for just-in-time access that is read-only, scoped to a single realm, expires in 30 minutes, is tied to the specific user, and can never read more than that user could already read themselves. So the bot reads exactly what it needs, when it needs it, without holding broad standing access.

What this does

The bot asks the realm server for a short-lived token — 30 minutes, one realm, read-only, tied to that user — and uses it to read that realm on the user's behalf. It works for any realm the user can already read, not just their own.

The realm server already shipped the endpoint that hands out these tokens (#5287). This PR is the ai-bot side, plus the small shared module both sides use to sign and check the request so their formats can't drift apart.

What's in the PR

  • Shared sign/verify module that ai-bot and the realm server both import — one definition, no copy-paste.
  • ai-bot token manager: gets a token per (user, realm), caches it, and refreshes it before it expires.
  • Wiring in main.ts behind AI_BOT_DELEGATION_SECRET. If that secret isn't set, the feature is simply off.

Nothing calls this at runtime yet — the consumer is the pull-model skill loader (CS-11554). The manager is wired up but dormant until then.

How I tested it

There's no chat flow that triggers this yet, so I drove it directly against a local stack with the realm server started with AI_BOT_DELEGATION_SECRET set:

  1. Asked for a token for @user:localhost on the catalog realm.
  2. Token came back read-only, 30-min TTL, scoped to just that realm.
  3. Used it to read the realm → 200
  4. Used it to write the realm → 403 ✅ (read-only is enforced — and that user can't write catalog anyway)
  5. Write with no token401 (writes really are gated)

Plus the automated tests below; lint + type-check clean.

Tests

packages/ai-bot/tests/user-delegated-realm-server-session-test.ts (9 tests): the client signs in a way the server accepts; correct endpoint/origin; error-code mapping; and the manager's disabled path, cache hit, proactive refresh, per-key isolation, origin derivation, and invalidate. The realm-server delegate-session tests cover the server side.

🤖 Generated with Claude Code

ai-bot exchanges a shared secret for a user-scoped, read-only realm JWT
via the realm server's /_delegate-session endpoint, so it can read a
realm on a user's behalf without a blanket read grant.

- runtime-common/delegation.ts: canonical HMAC sign/verify over the
  ${timestamp}.${rawBody} payload plus the requestDelegatedToken client;
  single source of truth shared by ai-bot and the realm-server verifier.
- ai-bot DelegatedTokenManager: per-(user, realm) token cache, 30m TTL,
  proactive refresh under 2 minutes remaining; realm-server origin
  derived from the realm URL.
- main.ts wires AI_BOT_DELEGATION_SECRET; the path is inert when unset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files      1 suites   2h 2m 39s ⏱️
3 200 tests 3 185 ✅ 15 💤 0 ❌
3 219 runs  3 204 ✅ 15 💤 0 ❌

Results for commit 4615eea.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   10m 32s ⏱️ +14s
1 733 tests ±0  1 733 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 826 runs  ±0  1 826 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 4615eea. ± Comparison against earlier commit 98e2762.

jurgenwerk and others added 3 commits June 24, 2026 14:18
The merged realm-server endpoint (#5287) shipped its own copy of the
shared-secret signing/verification logic in realm-server/utils/delegation.ts,
duplicating what this PR adds. Point the realm-server handler and its test at
the runtime-common module and delete the duplicate, so the canonical
${timestamp}.${rawBody} HMAC format has exactly one definition that both
ai-bot and the realm server import.

Rename the module from the vague "delegation" (reads like the OOP delegation
pattern) to user-delegated-realm-server-session, matching the /_delegate-session
endpoint and the DelegatedSession type:

- runtime-common/delegation.ts -> user-delegated-realm-server-session.ts
- ai-bot/lib/delegation.ts -> user-delegated-realm-server-session.ts
- ai-bot/tests/delegation-test.ts -> user-delegated-realm-server-session-test.ts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow the module rename through to the exported symbols, which still led with
the vague "delegation"/"delegated token" stem. The concise DelegatedRealmSession
prefix names what is actually delegated — a read-only session on a single realm —
without reading like the OOP delegation pattern:

- DelegatedSession        -> DelegatedRealmSession
- DelegatedTokenManager   -> DelegatedRealmSessionManager
- requestDelegatedToken   -> requestDelegatedRealmSession
- DelegationError(Kind)   -> DelegatedRealmSessionError(Kind)
- delegationSignature     -> delegatedRealmSessionSignature
- verifyDelegationRequest -> verifyDelegatedRealmSessionRequest
- DELEGATION_* headers     -> DELEGATED_REALM_SESSION_* (and x-boxel-delegated-realm-session-* values)
- Assistant.delegatedTokens -> delegatedRealmSessions

The AI_BOT_DELEGATION_SECRET env var is left unchanged (external contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jurgenwerk jurgenwerk changed the title ai-bot: delegated JWT retrieval + shared-secret signing module ai-bot: user-delegated read-only realm sessions (+ shared signing module) Jun 24, 2026
The module had a private copy of ensureTrailingSlash identical to the one
already exported from runtime-common/paths.ts. Import the shared helper instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jurgenwerk jurgenwerk changed the title ai-bot: user-delegated read-only realm sessions (+ shared signing module) ai-bot: read a user's realm with a short-lived, read-only token Jun 24, 2026
Replace the safeText helper with an inline await response.text().catch(() => '')
at its single call site. Same behavior — the realm server's error message is
still folded into the thrown error, and a body that can't be read can't throw —
without the extra function or the unused length cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jurgenwerk jurgenwerk requested review from a team and lukemelia June 24, 2026 15:36
@jurgenwerk jurgenwerk marked this pull request as ready for review June 24, 2026 15:36
@habdelra habdelra requested a review from Copilot June 24, 2026 16:40

Copilot AI left a comment

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.

Pull request overview

Adds shared client/server signing + request helpers for realm-server POST /_delegate-session, wires ai-bot to mint short-lived, read-only, per-(user, realm) delegated tokens (behind AI_BOT_DELEGATION_SECRET), and updates realm-server to use the shared implementation.

Changes:

  • Introduce @cardstack/runtime-common/user-delegated-realm-server-session as the single source of truth for delegation request signing/verification and the client request helper.
  • Update realm-server’s delegate-session handler + tests to use the shared module (and remove the realm-server-local copy).
  • Add ai-bot tests for the delegation client and session manager behavior; wire the manager into packages/ai-bot/main.ts.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/runtime-common/user-delegated-realm-server-session.ts New shared sign/verify + request helper and typed error surface.
packages/realm-server/utils/delegation.ts Removes realm-server-local sign/verify implementation in favor of shared module.
packages/realm-server/tests/server-endpoints/delegate-session-test.ts Updates tests to use shared headers + signature helper.
packages/realm-server/handlers/handle-delegate-session.ts Switches delegate-session auth verification to shared module + new header constants.
packages/ai-bot/tests/user-delegated-realm-server-session-test.ts New tests covering request signing/error mapping and manager caching/refresh behavior.
packages/ai-bot/tests/index.ts Registers the new test module.
packages/ai-bot/main.ts Instantiates and stores the delegation session manager (feature-gated by env secret).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/ai-bot/main.ts
Comment on lines +102 to +104
this.delegatedRealmSessions = new DelegatedRealmSessionManager(
process.env.AI_BOT_DELEGATION_SECRET,
);
Comment on lines 19 to +23
import {
DELEGATION_SIGNATURE_HEADER,
DELEGATION_TIMESTAMP_HEADER,
verifyDelegationRequest,
} from '../utils/delegation.ts';
DELEGATED_REALM_SESSION_SIGNATURE_HEADER,
DELEGATED_REALM_SESSION_TIMESTAMP_HEADER,
verifyDelegatedRealmSessionRequest,
} from '@cardstack/runtime-common/user-delegated-realm-server-session';
Comment on lines +17 to +26
// Builds a minimally-valid JWT carrying the given `exp` (epoch seconds) in its
// payload — the manager only ever reads `exp` off the token, so the header and
// signature segments are placeholders. Payload is standard base64 to match the
// manager's `atob` decode.
function makeToken(expSeconds: number): string {
let payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString(
'base64',
);
return `header.${payload}.signature`;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants