Skip to content
Merged
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
64 changes: 64 additions & 0 deletions docs/decisions/0006-bind-runtime-tokens-to-agent-placement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# ADR 0006: Bind runtime tokens to the exact Agent placement

- Status: Accepted
- Date: 2026-09-05

## Context

Agent runtime JWTs originally pinned only the globally stable Agent id and its
workspace. The runtime server rechecked that workspace and active-participant
state on every request, which revoked a token after a cross-workspace move but
not after a move between Computers in the same workspace. Revoking a paired
Computer likewise revoked its device credential while already minted Agent
JWTs remained usable until their one-hour expiry.

Checking only the Computer id is insufficient. Moving an Agent away and back to
the same Computer would make an older token valid again, and placement changes
can be written by onboarding and repair paths in addition to the main assignment
API.

## Decision

- Every participant row carries an opaque `runtime_assignment_id`. PostgreSQL
replaces it whenever the participant's workspace, Computer, kind, or active
departure state changes.
- Runtime JWTs pin `computerId` and `assignmentId` in addition to the Agent and
workspace ids. Tokens missing either placement claim are rejected.
- Every runtime request compares all four values with the live participant row.
Assigned Computers must also still exist in the same workspace and not be
revoked. The same check protects each long-lived wake stream before a ping or
event is delivered.
- Both BYOA daemons and managed pods mint tokens from the database placement
snapshot. A managed pod spawn aborts if that placement changes before the
Kubernetes mutation.
- The assignment rotation is a database trigger rather than application-only
bookkeeping so bulk and administrative SQL cannot accidentally omit it.

## Consequences

- Moving or offboarding an Agent and revoking its Computer invalidate every
previously minted runtime token immediately, including move-away/move-back
sequences.
- Deployment invalidates runtime tokens minted by older builds because they do
not carry placement claims. BYOA daemons obtain a new token from their
device-authenticated endpoint; managed pods are recreated through the normal
scheduler path.
- Every runtime request adds one indexed participant/Computer authorization
lookup, extending the existing live workspace check rather than adding a
second query.
- A future placement dimension that should revoke runtime authority must be
added to the trigger condition and, when independently meaningful, to the
signed claims.

## Alternatives considered

- **Bind only the workspace:** rejected because a removed or replaced host in
that workspace retains authority until token expiry.
- **Bind only the Computer id:** rejected because an away-and-back assignment
revives the old token.
- **Rotate only in `assignAgentToComputer`:** rejected because free-tier
onboarding, repair tooling, and administrative migrations also update
placement.
- **Keep a server-side JWT denylist:** rejected because it adds distributed
mutable state and cleanup work when the participant row is already the live
authorization source of truth.
14 changes: 8 additions & 6 deletions server/src/__integration__/agent-host-placement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ async function seedPlacementFixture(): Promise<void> {
)
await pool.query(
`INSERT INTO participants
(id, company_id, kind, name, initial, avatar_bg, status, computer_id)
(id, company_id, kind, name, initial, avatar_bg, status, computer_id, runtime_assignment_id)
VALUES
('agent-cloud', 'co-pro', 'agent', 'Cloud', 'C', '#111111', 'resting', 'cloud-pro'),
('agent-local', 'co-pro', 'agent', 'Local', 'L', '#222222', 'resting', 'local-pro'),
('agent-free', 'co-free', 'agent', 'Free', 'F', '#333333', 'resting', NULL),
('agent-revoked', 'co-pro', 'agent', 'Revoked', 'R', '#444444', 'resting', 'revoked-pro'),
('agent-cross', 'co-pro', 'agent', 'Cross', 'X', '#555555', 'resting', 'other-cloud')`,
('agent-cloud', 'co-pro', 'agent', 'Cloud', 'C', '#111111', 'resting', 'cloud-pro', 'assignment-cloud'),
('agent-local', 'co-pro', 'agent', 'Local', 'L', '#222222', 'resting', 'local-pro', 'assignment-local'),
('agent-free', 'co-free', 'agent', 'Free', 'F', '#333333', 'resting', NULL, 'assignment-free'),
('agent-revoked', 'co-pro', 'agent', 'Revoked', 'R', '#444444', 'resting', 'revoked-pro', 'assignment-revoked'),
('agent-cross', 'co-pro', 'agent', 'Cross', 'X', '#555555', 'resting', 'other-cloud', 'assignment-cross')`,
)
}

Expand All @@ -50,12 +50,14 @@ test('[integration] managed placement resolves host and tier from one tenant sna
kind: 'cloud',
computerId: 'cloud-pro',
companyId: 'co-pro',
runtimeAssignmentId: 'assignment-cloud',
tier: 'pro',
})
assert.deepEqual(await verifyManagedPodPlacement('agent-cloud'), {
ok: true,
companyId: 'co-pro',
computerId: 'cloud-pro',
runtimeAssignmentId: 'assignment-cloud',
})

const byoa = await verifyManagedPodPlacement('agent-local')
Expand Down
17 changes: 16 additions & 1 deletion server/src/__integration__/runtime-aux-authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,25 @@ async function seedAgent(companyId?: string, agentId?: string): Promise<{
token: string
}> {
const seeded = await seedCompanyWithAgent({ companyId, agentId })
const { rows } = await pool.query<{
computer_id: string | null
runtime_assignment_id: string
}>(
`SELECT computer_id, runtime_assignment_id
FROM participants
WHERE id = $1 AND company_id = $2 AND kind = 'agent'`,
[seeded.agentId, seeded.companyId],
)
assert.ok(rows[0], 'runtime token fixture requires a live Agent placement')
return {
companyId: seeded.companyId,
agentId: seeded.agentId,
token: signAgentToken({ agentId: seeded.agentId, companyId: seeded.companyId }),
token: signAgentToken({
agentId: seeded.agentId,
companyId: seeded.companyId,
computerId: rows[0].computer_id,
assignmentId: rows[0].runtime_assignment_id,
}),
}
}

Expand Down
140 changes: 124 additions & 16 deletions server/src/__integration__/runtime-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { createServer, type Server } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { after, before, beforeEach, test } from 'node:test'
import { mintAgentRuntimeToken } from '../agents/computer/registry.js'
import { mintAgentRuntimeToken, revokeComputer } from '../agents/computer/registry.js'
import { signAgentToken, verifyAgentToken } from '../agents/runtime/jwt.js'
import { pool } from '../db/pool.js'
import { ensureSchemaOnce, resetAllTables, teardownAll } from './_helpers.js'
Expand Down Expand Up @@ -130,6 +130,31 @@ async function waitForBlockedQuery(pattern: string, minimum = 1): Promise<void>
throw new Error(`query never reached the expected row lock: ${pattern}`)
}

async function signCurrentRuntimeToken(args: {
agentId: string
sourceCompanyId: string
claimCompanyId?: string | null
ttlSeconds?: number
}): Promise<string> {
const { rows } = await pool.query<{
computer_id: string | null
runtime_assignment_id: string
}>(
`SELECT computer_id, runtime_assignment_id
FROM participants
WHERE id = $1 AND company_id = $2 AND kind = 'agent'`,
[args.agentId, args.sourceCompanyId],
)
assert.ok(rows[0], 'runtime token fixture requires a live Agent placement')
return signAgentToken({
agentId: args.agentId,
companyId: args.claimCompanyId === undefined ? args.sourceCompanyId : args.claimCompanyId,
computerId: rows[0].computer_id,
assignmentId: rows[0].runtime_assignment_id,
ttlSeconds: args.ttlSeconds,
})
}

async function seedAgent(): Promise<{ agentId: string; companyId: string; token: string }> {
const companyId = `c-${randomUUID().slice(0, 8)}`
const agentId = `a-${randomUUID().slice(0, 8)}`
Expand All @@ -142,7 +167,7 @@ async function seedAgent(): Promise<{ agentId: string; companyId: string; token:
VALUES ($1, $2, 'agent', $3, 'tester', $4, '#abcdef', 'avail')`,
[agentId, companyId, agentId, agentId.slice(0, 1).toUpperCase()],
)
const token = signAgentToken({ agentId, companyId })
const token = await signCurrentRuntimeToken({ agentId, sourceCompanyId: companyId })
return { agentId, companyId, token }
}

Expand All @@ -153,7 +178,11 @@ async function seedPeerAgent(companyId: string): Promise<{ agentId: string; comp
VALUES ($1, $2, 'agent', $3, 'tester', $4, '#abcdef', 'avail')`,
[agentId, companyId, agentId, agentId.slice(0, 1).toUpperCase()],
)
return { agentId, companyId, token: signAgentToken({ agentId, companyId }) }
return {
agentId,
companyId,
token: await signCurrentRuntimeToken({ agentId, sourceCompanyId: companyId }),
}
}

async function assertCannotMutateForeignRun(args: {
Expand Down Expand Up @@ -230,7 +259,7 @@ async function assertCannotMutateForeignRun(args: {
async function mintAssignedAgentRuntimeToken(args: {
agentId: string
companyId: string
}): Promise<string> {
}): Promise<{ token: string; computerId: string }> {
const computerId = `comp-${randomUUID().slice(0, 8)}`
await pool.query(
`INSERT INTO computers (id, company_id, name, kind, available_engines, status)
Expand All @@ -244,7 +273,7 @@ async function mintAssignedAgentRuntimeToken(args: {
)
const minted = await mintAgentRuntimeToken({ computerId, agentId: args.agentId })
assert.ok(minted, 'the production BYOA path should mint a token for an assigned agent')
return minted.token
return { token: minted.token, computerId }
}

async function seedContextConversation(opts: {
Expand Down Expand Up @@ -347,15 +376,23 @@ test('[integration] runtime: malformed token (not three segments) → 401', asyn
test('[integration] runtime: expired token → 401', async () => {
const { agentId, companyId } = await seedAgent()
// ttlSeconds = -1 → exp = now - 1s, definitely expired.
const tok = signAgentToken({ agentId, companyId, ttlSeconds: -1 })
const tok = await signCurrentRuntimeToken({
agentId,
sourceCompanyId: companyId,
ttlSeconds: -1,
})
const r = await call('/runtime/inbox', { method: 'GET', token: tok })
assert.equal(r.status, 401)
assert.match(String(r.body?.error ?? ''), /expired/i)
})

test('[integration] runtime: /context requires a tenant-pinned token', async () => {
const { agentId } = await seedAgent()
const token = signAgentToken({ agentId, companyId: null })
const { agentId, companyId } = await seedAgent()
const token = await signCurrentRuntimeToken({
agentId,
sourceCompanyId: companyId,
claimCompanyId: null,
})
const r = await call('/runtime/context', { token, body: { conversationIds: [] } })
assert.equal(r.status, 403)
assert.match(String(r.body?.error ?? ''), /companyId claim required/i)
Expand Down Expand Up @@ -412,7 +449,7 @@ test('[integration] runtime: /context enforces tenant and conversation membershi
test('[integration] runtime: every route rejects a stale production token after tenant reassignment', async () => {
const originalTenant = await seedAgent()
const currentTenant = await seedAgent()
const staleToken = await mintAssignedAgentRuntimeToken(originalTenant)
const { token: staleToken } = await mintAssignedAgentRuntimeToken(originalTenant)

// Model a real stale-credential lifecycle without fabricating JWT claims:
// the BYOA path minted the token while the agent belonged to tenant A, then
Expand Down Expand Up @@ -457,7 +494,7 @@ test('[integration] runtime: every route rejects a stale production token after
body: request.body,
})
assert.equal(r.status, 403, request.path)
assert.match(String(r.body?.error ?? ''), /token tenant/i, request.path)
assert.match(String(r.body?.error ?? ''), /assignment changed|revoked/i, request.path)
}

const { rows: statusRows } = await pool.query<{ status: string }>(
Expand All @@ -467,10 +504,77 @@ test('[integration] runtime: every route rejects a stale production token after
assert.deepEqual(statusRows, [{ status: 'avail' }])
})

test('[integration] runtime: host reassignment generations and Computer revocation invalidate old tokens', async () => {
const agent = await seedAgent()
const first = await mintAssignedAgentRuntimeToken(agent)
const firstClaims = verifyAgentToken(first.token)
assert.equal(firstClaims.computerId, first.computerId)

const secondComputerId = `comp-${randomUUID().slice(0, 8)}`
await pool.query(
`INSERT INTO computers (id, company_id, name, kind, available_engines, status)
VALUES ($1, $2, $3, 'local', '["codex"]'::jsonb, 'online')`,
[secondComputerId, agent.companyId, `Computer ${secondComputerId}`],
)
const moved = await pool.query<{ runtime_assignment_id: string }>(
`UPDATE participants SET computer_id = $1
WHERE id = $2 AND company_id = $3
RETURNING runtime_assignment_id`,
[secondComputerId, agent.agentId, agent.companyId],
)
assert.equal(moved.rowCount, 1)
assert.notEqual(moved.rows[0].runtime_assignment_id, firstClaims.assignmentId)

const movedAway = await call('/runtime/status', {
token: first.token,
body: { status: 'working' },
})
assert.equal(movedAway.status, 403)
assert.match(String(movedAway.body?.error ?? ''), /assignment changed|revoked/i)

const movedBack = await pool.query<{ runtime_assignment_id: string }>(
`UPDATE participants SET computer_id = $1
WHERE id = $2 AND company_id = $3
RETURNING runtime_assignment_id`,
[first.computerId, agent.agentId, agent.companyId],
)
assert.equal(movedBack.rowCount, 1)
assert.notEqual(movedBack.rows[0].runtime_assignment_id, moved.rows[0].runtime_assignment_id)
assert.notEqual(movedBack.rows[0].runtime_assignment_id, firstClaims.assignmentId)

// Computer id and tenant now equal the original claims again. Only the
// database-owned assignment generation prevents this ABA replay.
const replayed = await call('/runtime/inbox', { method: 'GET', token: first.token })
assert.equal(replayed.status, 403)
assert.match(String(replayed.body?.error ?? ''), /assignment changed|revoked/i)

const current = await mintAgentRuntimeToken({
computerId: first.computerId,
agentId: agent.agentId,
})
assert.ok(current)
assert.equal((await call('/runtime/inbox', { method: 'GET', token: current.token })).status, 200)

assert.equal(await revokeComputer({
computerId: first.computerId,
companyId: agent.companyId,
}), true)
assert.equal(await mintAgentRuntimeToken({
computerId: first.computerId,
agentId: agent.agentId,
}), null)
const revoked = await call('/runtime/status', {
token: current.token,
body: { status: 'working' },
})
assert.equal(revoked.status, 403)
assert.match(String(revoked.body?.error ?? ''), /assignment changed|revoked/i)
})

test('[integration] runtime: /inbox-triage/payload rejects a stale token before loading the new tenant inbox', async () => {
const originalTenant = await seedAgent()
const currentTenant = await seedAgent()
const staleToken = await mintAssignedAgentRuntimeToken(originalTenant)
const { token: staleToken } = await mintAssignedAgentRuntimeToken(originalTenant)

const moved = await pool.query(
`UPDATE participants
Expand All @@ -493,7 +597,7 @@ test('[integration] runtime: /inbox-triage/payload rejects a stale token before
})

assert.equal(r.status, 403)
assert.match(String(r.body?.error ?? ''), /token tenant/i)
assert.match(String(r.body?.error ?? ''), /assignment changed|revoked/i)
})

test('[integration] runtime: a current token cannot use a stale member id to read or write the old tenant through /cli', async () => {
Expand Down Expand Up @@ -560,9 +664,9 @@ test('[integration] runtime: a current token cannot use a stale member id to rea
} finally {
mover.release()
}
const currentToken = signAgentToken({
const currentToken = await signCurrentRuntimeToken({
agentId: originalTenant.agentId,
companyId: currentTenant.companyId,
sourceCompanyId: currentTenant.companyId,
})

for (const argv of [
Expand Down Expand Up @@ -732,8 +836,12 @@ test('[integration] runtime: /cli still sends text-only agent email in mock mode
})

test('[integration] runtime: /faces requires a tenant-pinned token', async () => {
const { agentId } = await seedAgent()
const token = signAgentToken({ agentId, companyId: null })
const { agentId, companyId } = await seedAgent()
const token = await signCurrentRuntimeToken({
agentId,
sourceCompanyId: companyId,
claimCompanyId: null,
})
const r = await call('/runtime/faces', { token, body: { participantIds: [agentId] } })
assert.equal(r.status, 403)
assert.match(String(r.body?.error ?? ''), /companyId claim required/i)
Expand Down
16 changes: 14 additions & 2 deletions server/src/__integration__/workspace-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,19 @@ test('[integration] workspace deletion purges FK-backed and legacy soft-scoped d
VALUES ('board-managed', 'co-managed', 'Managed board', $1)`, [OWNER_ID],
)

assert.equal(await isRuntimeAgentAuthorized('agent-managed', 'co-managed'), true)
const runtimeIdentity = await pool.query<{ runtime_assignment_id: string }>(
`SELECT runtime_assignment_id
FROM participants
WHERE id = 'agent-managed' AND company_id = 'co-managed'`,
)
assert.ok(runtimeIdentity.rows[0])
const runtimeClaims = {
sub: 'agent-managed',
companyId: 'co-managed',
computerId: null,
assignmentId: runtimeIdentity.rows[0].runtime_assignment_id,
}
assert.equal(await isRuntimeAgentAuthorized(runtimeClaims), true)

const response = await fetch(`${ownerBase}/api/companies/co-managed`, {
method: 'DELETE', headers: companyHeaders('co-managed'),
Expand All @@ -454,7 +466,7 @@ test('[integration] workspace deletion purges FK-backed and legacy soft-scoped d
}
const alternative = await pool.query(`SELECT 1 FROM companies WHERE id = 'co-alternative'`)
assert.equal(alternative.rowCount, 1)
assert.equal(await isRuntimeAgentAuthorized('agent-managed', 'co-managed'), false)
assert.equal(await isRuntimeAgentAuthorized(runtimeClaims), false)
assert.equal((await pool.query(`SELECT 1 FROM user_preferences WHERE user_id = $1`, [TARGET_ID])).rowCount, 1)
assert.equal((await pool.query(`SELECT 1 FROM agent_autonomy WHERE user_id = $1`, [TARGET_ID])).rowCount, 1)
assert.equal((await pool.query(`SELECT 1 FROM llm_calls WHERE id = 'llm-managed'`)).rowCount, 1)
Expand Down
Loading
Loading