diff --git a/docs/decisions/0006-bind-runtime-tokens-to-agent-placement.md b/docs/decisions/0006-bind-runtime-tokens-to-agent-placement.md new file mode 100644 index 00000000..b069d61f --- /dev/null +++ b/docs/decisions/0006-bind-runtime-tokens-to-agent-placement.md @@ -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. diff --git a/server/src/__integration__/agent-host-placement.test.ts b/server/src/__integration__/agent-host-placement.test.ts index 169e2bbb..74eac4d5 100644 --- a/server/src/__integration__/agent-host-placement.test.ts +++ b/server/src/__integration__/agent-host-placement.test.ts @@ -31,13 +31,13 @@ async function seedPlacementFixture(): Promise { ) 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')`, ) } @@ -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') diff --git a/server/src/__integration__/runtime-aux-authorization.test.ts b/server/src/__integration__/runtime-aux-authorization.test.ts index f0a4310f..47c8c8ae 100644 --- a/server/src/__integration__/runtime-aux-authorization.test.ts +++ b/server/src/__integration__/runtime-aux-authorization.test.ts @@ -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, + }), } } diff --git a/server/src/__integration__/runtime-server.test.ts b/server/src/__integration__/runtime-server.test.ts index 30a2726e..7376fbcf 100644 --- a/server/src/__integration__/runtime-server.test.ts +++ b/server/src/__integration__/runtime-server.test.ts @@ -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' @@ -130,6 +130,31 @@ async function waitForBlockedQuery(pattern: string, minimum = 1): Promise 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 { + 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)}` @@ -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 } } @@ -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: { @@ -230,7 +259,7 @@ async function assertCannotMutateForeignRun(args: { async function mintAssignedAgentRuntimeToken(args: { agentId: string companyId: string -}): Promise { +}): 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) @@ -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: { @@ -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) @@ -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 @@ -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 }>( @@ -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 @@ -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 () => { @@ -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 [ @@ -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) diff --git a/server/src/__integration__/workspace-management.test.ts b/server/src/__integration__/workspace-management.test.ts index bc2ef2b4..f023b594 100644 --- a/server/src/__integration__/workspace-management.test.ts +++ b/server/src/__integration__/workspace-management.test.ts @@ -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'), @@ -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) diff --git a/server/src/__tests__/agents-host-resolution.test.ts b/server/src/__tests__/agents-host-resolution.test.ts index 3baacc9b..4b9c493d 100644 --- a/server/src/__tests__/agents-host-resolution.test.ts +++ b/server/src/__tests__/agents-host-resolution.test.ts @@ -19,6 +19,7 @@ function fakeDb(rows: Record[]): HostDb { const paidCloudRow = { company_id: 'co-1', computer_id: 'cloud-co-1', + runtime_assignment_id: 'assignment-1', resolved_computer_id: 'cloud-co-1', computer_company_id: 'co-1', kind: 'cloud', @@ -51,6 +52,7 @@ test('resolveAgentHost distinguishes a missing agent from an unassigned paid age kind: null, computerId: null, companyId: 'co-1', + runtimeAssignmentId: 'assignment-1', tier: 'pro', }) }) @@ -87,15 +89,16 @@ test('managedPodPlacement permits only paid managed or explicit unassigned hosts kind: 'cloud' as const, computerId: 'cloud-co-1', companyId: 'co-1', + runtimeAssignmentId: 'assignment-1', tier: 'pro' as const, ...overrides, }) assert.deepEqual(managedPodPlacement(found()), { - status: 'allowed', companyId: 'co-1', computerId: 'cloud-co-1', + status: 'allowed', companyId: 'co-1', computerId: 'cloud-co-1', runtimeAssignmentId: 'assignment-1', }) assert.deepEqual(managedPodPlacement(found({ kind: null, computerId: null })), { - status: 'allowed', companyId: 'co-1', computerId: null, + status: 'allowed', companyId: 'co-1', computerId: null, runtimeAssignmentId: 'assignment-1', }) assert.equal(managedPodPlacement(found({ kind: 'local' })).status, 'denied') assert.equal(managedPodPlacement(found({ kind: 'vps' })).status, 'denied') diff --git a/server/src/__tests__/agents-runtime-jwt.test.ts b/server/src/__tests__/agents-runtime-jwt.test.ts new file mode 100644 index 00000000..df6d31a2 --- /dev/null +++ b/server/src/__tests__/agents-runtime-jwt.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' +import { test } from 'node:test' + +process.env.NODE_ENV = 'test' +process.env.OPENAI_API_KEY ??= 'test-key' +process.env.AGENT_RUNTIME_SECRET = 'test-agent-runtime-secret-with-enough-entropy' + +const { env } = await import('../env.js') +const { signAgentToken, verifyAgentToken } = await import('../agents/runtime/jwt.js') + +function b64url(value: string | Buffer): string { + return Buffer.from(value).toString('base64url') +} + +function signRawClaims(claims: Record): string { + const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) + const payload = b64url(JSON.stringify(claims)) + const signature = createHmac('sha256', env.AGENT_RUNTIME_SECRET) + .update(`${header}.${payload}`) + .digest('base64url') + return `${header}.${payload}.${signature}` +} + +test('runtime JWT round-trips the exact placement identity', () => { + const token = signAgentToken({ + agentId: 'agent-1', + companyId: 'company-1', + computerId: 'computer-1', + assignmentId: 'assignment-1', + }) + const claims = verifyAgentToken(token) + assert.deepEqual( + { + sub: claims.sub, + companyId: claims.companyId, + computerId: claims.computerId, + assignmentId: claims.assignmentId, + scope: claims.scope, + }, + { + sub: 'agent-1', + companyId: 'company-1', + computerId: 'computer-1', + assignmentId: 'assignment-1', + scope: 'agent-runner', + }, + ) +}) + +test('runtime JWTs minted before placement binding fail closed', () => { + const now = Math.floor(Date.now() / 1000) + const legacy = signRawClaims({ + sub: 'agent-1', + companyId: 'company-1', + scope: 'agent-runner', + iat: now, + exp: now + 3600, + }) + assert.throws(() => verifyAgentToken(legacy), /computerId|assignmentId/) +}) diff --git a/server/src/__tests__/schema-migrations.test.ts b/server/src/__tests__/schema-migrations.test.ts index b7c1e521..58948c6c 100644 --- a/server/src/__tests__/schema-migrations.test.ts +++ b/server/src/__tests__/schema-migrations.test.ts @@ -15,6 +15,7 @@ process.env.OPENAI_API_KEY ??= 'test-key' const { computedBaselineMigrationChecksum } = await import('../db/migrate.js') const { normalizedConversationMembersChecksum } = await import('../db/migrations/0002-normalized-conversation-members.js') const { workspaceCleanupJobsChecksum } = await import('../db/migrations/0003-workspace-cleanup-jobs.js') +const { agentRuntimeAssignmentChecksum } = await import('../db/migrations/0004-agent-runtime-assignment.js') const { verifySchemaCompatibility } = await import('../db/schema-version.js') type SchemaVersionQueryable = import('../db/schema-version.js').SchemaVersionQueryable @@ -32,6 +33,10 @@ test('the workspace cleanup migration matches its immutable manifest checksum', assert.equal(workspaceCleanupJobsChecksum(), SCHEMA_MIGRATIONS[2].checksum) }) +test('the runtime assignment migration matches its immutable manifest checksum', () => { + assert.equal(agentRuntimeAssignmentChecksum(), SCHEMA_MIGRATIONS[3].checksum) +}) + test('the migration owner accepts an exact prefix and reports its pending suffix', () => { const empty = validateMigrationHistory([], { allowPending: true }) assert.equal(empty.currentVersion, 0) diff --git a/server/src/agents/computer/daemon.ts b/server/src/agents/computer/daemon.ts index 3c28d1ec..c630eebb 100644 --- a/server/src/agents/computer/daemon.ts +++ b/server/src/agents/computer/daemon.ts @@ -1884,6 +1884,13 @@ class AgentRunner { return this.token } + private invalidateToken(token: string): void { + // Do not erase a newer token if an older in-flight request finishes late. + if (this.token !== token) return + this.token = '' + this.tokenExpiresAt = 0 + } + /** Execute one shim request outside the model's sandbox. The broker accepts * only argv strings; identity, URL, Authorization, redirects, and token * refresh remain daemon-owned and cannot be changed by the model. */ @@ -1903,6 +1910,7 @@ class AgentRunner { signal: requestAbort.signal, }) if (!res.ok) { + if (res.status === 401 || res.status === 403) this.invalidateToken(token) const body = await res.text().catch(() => '') return { exitCode: 70, error: `HTTP ${res.status} ${body.slice(0, 200)}`.trim() } } @@ -3112,7 +3120,10 @@ class AgentRunner { const res = await fetch(`${this.cfg.serverUrl}/runtime/wake-stream`, { headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' }, }) - if (!res.ok || !res.body) throw new Error(`wake-stream HTTP ${res.status}`) + if (!res.ok || !res.body) { + if (res.status === 401 || res.status === 403) this.invalidateToken(token) + throw new Error(`wake-stream HTTP ${res.status}`) + } console.log(`[computer] ${this.agent.id} wake-stream connected (engine: ${this.adapter.id})`) connectedAt = Date.now() this.kickTurn('reconnect-catchup') // cold-start / reconnect catch-up diff --git a/server/src/agents/computer/registry.ts b/server/src/agents/computer/registry.ts index 0f10195a..15591b30 100644 --- a/server/src/agents/computer/registry.ts +++ b/server/src/agents/computer/registry.ts @@ -595,15 +595,28 @@ export async function mintAgentRuntimeToken(args: { computerId: string agentId: string }): Promise<{ token: string; expiresInSeconds: number } | null> { - const { rows } = await pool.query<{ company_id: string | null }>( - `SELECT company_id FROM participants - WHERE id = $1 AND kind = 'agent' AND computer_id = $2 LIMIT 1`, + const { rows } = await pool.query<{ + company_id: string + computer_id: string + runtime_assignment_id: string + }>( + `SELECT p.company_id, p.computer_id, p.runtime_assignment_id + FROM participants p + JOIN computers c + ON c.id = p.computer_id + AND c.company_id = p.company_id + AND c.revoked_at IS NULL + WHERE p.id = $1 AND p.kind = 'agent' AND p.computer_id = $2 + AND p.departed_at IS NULL + LIMIT 1`, [args.agentId, args.computerId], ) if (!rows[0]) return null const token = signAgentToken({ agentId: args.agentId, companyId: rows[0].company_id, + computerId: rows[0].computer_id, + assignmentId: rows[0].runtime_assignment_id, ttlSeconds: AGENT_TOKEN_TTL_SECONDS, }) return { token, expiresInSeconds: AGENT_TOKEN_TTL_SECONDS } @@ -725,6 +738,8 @@ export interface ResolvedAgentHost { computerId: string | null /** Active agent tenant. A found result never carries an empty tenant. */ companyId: string + /** Opaque placement generation bound into every runtime token. */ + runtimeAssignmentId: string /** Tenant tier read in the same database snapshot as the host assignment. */ tier: Tier } @@ -747,7 +762,7 @@ export type AgentHostResolution = | FailedAgentHostResolution export type ManagedPodPlacement = - | { status: 'allowed'; companyId: string; computerId: string | null } + | { status: 'allowed'; companyId: string; computerId: string | null; runtimeAssignmentId: string } | { status: 'denied' code: 'agent_not_found' | 'placement_lookup_failed' | 'placement_denied' @@ -778,6 +793,7 @@ export async function resolveAgentHost( const { rows } = await db.query<{ company_id: string | null computer_id: string | null + runtime_assignment_id: string | null resolved_computer_id: string | null computer_company_id: string | null kind: string | null @@ -787,6 +803,7 @@ export async function resolveAgentHost( }>( `SELECT p.company_id, p.computer_id, + p.runtime_assignment_id, c.id AS resolved_computer_id, c.company_id AS computer_company_id, c.kind, @@ -813,7 +830,7 @@ export async function resolveAgentHost( ) const row = rows[0] if (!row) return { status: 'missing' } - if (!row.company_id || !row.resolved_company_id) { + if (!row.company_id || !row.resolved_company_id || !row.runtime_assignment_id) { return { status: 'error', code: 'invalid_assignment', @@ -848,6 +865,7 @@ export async function resolveAgentHost( kind: row.computer_id === null ? null : row.kind as ComputerKind, computerId: row.computer_id, companyId: row.company_id, + runtimeAssignmentId: row.runtime_assignment_id, tier: normalizeTier(row.tier), } } catch (cause) { @@ -892,6 +910,7 @@ export function managedPodPlacement( status: 'allowed', companyId: resolution.companyId, computerId: resolution.computerId, + runtimeAssignmentId: resolution.runtimeAssignmentId, } } diff --git a/server/src/agents/runtime/authorization.ts b/server/src/agents/runtime/authorization.ts index bb9f9ccd..41ac26ed 100644 --- a/server/src/agents/runtime/authorization.ts +++ b/server/src/agents/runtime/authorization.ts @@ -1,22 +1,32 @@ import type { PoolClient } from 'pg' import { pool } from '../../db/pool.js' +import type { AgentRuntimeClaims } from './jwt.js' /** - * A signed runtime token captures the agent's tenant at mint time. Resolve the - * live participant row as the authorization source of truth so moving or - * offboarding an agent revokes every previously minted token. + * A signed runtime token captures the agent's tenant and exact Computer + * placement at mint time. Resolve the live rows as the authorization source of + * truth so moving/offboarding an Agent or revoking its Computer invalidates + * every previously minted token. `assignmentId` closes the move-away/move-back + * case where tenant and Computer values end up equal again. */ export async function isRuntimeAgentAuthorized( - agentId: string, - companyId: string | null, + claims: Pick, ): Promise { - if (!companyId) return false + if (!claims.companyId) return false const { rowCount } = await pool.query( - `SELECT 1 FROM participants - WHERE id = $1 AND company_id = $2 - AND kind = 'agent' AND departed_at IS NULL + `SELECT 1 + FROM participants p + LEFT JOIN computers c + ON c.id = p.computer_id + AND c.company_id = p.company_id + AND c.revoked_at IS NULL + WHERE p.id = $1 AND p.company_id = $2 + AND p.computer_id IS NOT DISTINCT FROM $3::text + AND p.runtime_assignment_id = $4 + AND p.kind = 'agent' AND p.departed_at IS NULL + AND (p.computer_id IS NULL OR c.id IS NOT NULL) LIMIT 1`, - [agentId, companyId], + [claims.sub, claims.companyId, claims.computerId, claims.assignmentId], ) return rowCount === 1 } diff --git a/server/src/agents/runtime/jwt.ts b/server/src/agents/runtime/jwt.ts index cf7f1065..4933c1ae 100644 --- a/server/src/agents/runtime/jwt.ts +++ b/server/src/agents/runtime/jwt.ts @@ -1,11 +1,11 @@ /** * Minimal HS256 JWT for runtime-pod ↔ server auth. * - * Server signs one token per spawned pod with `{ sub: agentId, - * companyId, scope: 'agent-runner', iat, exp }`. Pod presents it on - * every `/runtime/*` request. Server verifies sig + exp + scope and - * pins the agentId to the token — endpoints never trust an agentId - * from the request body. + * Server signs one token per runtime with `{ sub: agentId, companyId, + * computerId, assignmentId, scope: 'agent-runner', iat, exp }`. The runtime + * presents it on every `/runtime/*` request. Server verifies sig + exp + scope + * and pins both identity and live placement — endpoints never trust an agentId + * or Computer from the request body. * * Why homegrown instead of `jose`: the surface area is small (sign + * verify, fixed alg, fixed claim shape), the secret never leaves the @@ -20,6 +20,10 @@ export interface AgentRuntimeClaims { sub: string /** company id the agent belongs to. Pinned so requests can't cross tenants. */ companyId: string | null + /** Exact assigned Computer. Null represents an explicitly unassigned Agent. */ + computerId: string | null + /** Opaque generation rotated by PostgreSQL on every authority-bearing move. */ + assignmentId: string /** Fixed string; lets the server reject tokens minted for other purposes. */ scope: 'agent-runner' /** issued-at, unix seconds. */ @@ -49,12 +53,16 @@ function sign(headerB64: string, payloadB64: string): string { export function signAgentToken(args: { agentId: string companyId: string | null + computerId: string | null + assignmentId: string ttlSeconds?: number }): string { const now = Math.floor(Date.now() / 1000) const claims: AgentRuntimeClaims = { sub: args.agentId, companyId: args.companyId, + computerId: args.computerId, + assignmentId: args.assignmentId, scope: 'agent-runner', iat: now, exp: now + (args.ttlSeconds ?? DEFAULT_TTL_SECONDS), @@ -79,5 +87,14 @@ export function verifyAgentToken(token: string): AgentRuntimeClaims { const now = Math.floor(Date.now() / 1000) if (typeof claims.exp !== 'number' || claims.exp < now) throw new Error('token expired') if (typeof claims.sub !== 'string' || !claims.sub) throw new Error('missing sub') + if (claims.companyId !== null && (typeof claims.companyId !== 'string' || !claims.companyId)) { + throw new Error('invalid companyId') + } + if (claims.computerId !== null && (typeof claims.computerId !== 'string' || !claims.computerId)) { + throw new Error('invalid computerId') + } + if (typeof claims.assignmentId !== 'string' || !claims.assignmentId) { + throw new Error('missing assignmentId') + } return claims } diff --git a/server/src/agents/runtime/orchestrator.ts b/server/src/agents/runtime/orchestrator.ts index a6b537bc..77cf46e2 100644 --- a/server/src/agents/runtime/orchestrator.ts +++ b/server/src/agents/runtime/orchestrator.ts @@ -641,7 +641,7 @@ export type EnsurePodResult = } export type ManagedPodPlacementVerification = - | { ok: true; companyId: string; computerId: string | null } + | { ok: true; companyId: string; computerId: string | null; runtimeAssignmentId: string } | { ok: false code: 'agent_not_found' | 'placement_lookup_failed' | 'placement_denied' @@ -674,6 +674,7 @@ export async function verifyManagedPodPlacement( ok: true, companyId: decision.companyId, computerId: decision.computerId, + runtimeAssignmentId: decision.runtimeAssignmentId, } } @@ -755,12 +756,16 @@ async function ensurePodImpl(agentId: string): Promise { const recheckPlacement = async (): Promise => { const current = await verifyManagedPodPlacement(agentId) if (!current.ok) return { created: false, ...current } - if (current.companyId !== initialPlacement.companyId) { + if ( + current.companyId !== initialPlacement.companyId + || current.computerId !== initialPlacement.computerId + || current.runtimeAssignmentId !== initialPlacement.runtimeAssignmentId + ) { return { created: false, ok: false, code: 'placement_denied', - reason: 'managed pod denied: agent company changed during placement', + reason: 'managed pod denied: agent placement changed during preparation', } } return null @@ -874,6 +879,8 @@ async function ensurePodImpl(agentId: string): Promise { const token = signAgentToken({ agentId, companyId: persona.companyId, + computerId: initialPlacement.computerId, + assignmentId: initialPlacement.runtimeAssignmentId, ttlSeconds: TOKEN_TTL_SECONDS, }) diff --git a/server/src/agents/runtime/probe.ts b/server/src/agents/runtime/probe.ts index 961d8a2d..0598a28b 100644 --- a/server/src/agents/runtime/probe.ts +++ b/server/src/agents/runtime/probe.ts @@ -18,8 +18,13 @@ async function main(): Promise { console.error('usage: tsx server/src/agents/runtime/probe.ts ') process.exit(2) } - const { rows } = await pool.query<{ id: string; company_id: string | null }>( - 'SELECT id, company_id FROM participants WHERE id = $1', + const { rows } = await pool.query<{ + id: string + company_id: string | null + computer_id: string | null + runtime_assignment_id: string + }>( + 'SELECT id, company_id, computer_id, runtime_assignment_id FROM participants WHERE id = $1', [agentId], ) if (rows.length === 0) { @@ -27,7 +32,12 @@ async function main(): Promise { process.exit(1) } const companyId = rows[0].company_id - const token = signAgentToken({ agentId, companyId }) + const token = signAgentToken({ + agentId, + companyId, + computerId: rows[0].computer_id, + assignmentId: rows[0].runtime_assignment_id, + }) console.log(`COMPANY=${companyId ?? ''}`) console.log(`TOKEN=${token}`) await pool.end() diff --git a/server/src/agents/runtime/server.ts b/server/src/agents/runtime/server.ts index 7abea117..a9926ff9 100644 --- a/server/src/agents/runtime/server.ts +++ b/server/src/agents/runtime/server.ts @@ -7,9 +7,10 @@ * JSON shape declared in `client.ts`. * * Auth: every request carries `Authorization: Bearer `. The JWT pins `{ agentId, companyId }`. Endpoints take the - * agentId from the *token* (not the request body) so a compromised pod - * can't operate as someone else's agent. + * JWT>`. The JWT pins `{ agentId, companyId, computerId, assignmentId }`. + * Endpoints take identity and placement from the *token* (not the request + * body) and compare them with the live database row, so a compromised or + * replaced runtime can't operate as another Agent placement. * * Mount at `/runtime` from `server/src/index.ts`. Not nested under * `/api` because the cookie-auth middleware on /api would reject these @@ -93,15 +94,15 @@ async function authMiddleware(req: RuntimeRequest, res: Response, next: NextFunc try { // A valid signature only proves what was true when the token was minted. - // Re-check the globally unique agent row on every request so tenant moves - // and offboarding revoke an old daemon token immediately, across the whole - // runtime surface rather than only on selected data-reading endpoints. - if (!(await isRuntimeAgentAuthorized(claims.sub, claims.companyId))) { - res.status(403).json({ error: 'agent does not belong to token tenant' }) + // Re-check tenant, Computer, and opaque placement generation on every + // request so moves, offboarding, and Computer revocation invalidate an old + // runtime immediately across the whole surface. + if (!(await isRuntimeAgentAuthorized(claims))) { + res.status(403).json({ error: 'agent runtime assignment changed or was revoked' }) return } } catch (err) { - console.error('[runtime] token tenant validation failed', err instanceof Error ? err.message : err) + console.error('[runtime] token assignment validation failed', err instanceof Error ? err.message : err) res.status(503).json({ error: 'runtime authorization unavailable' }) return } @@ -129,7 +130,7 @@ function withAgent( export const runtimeRouter: Router = Router() runtimeRouter.use(authMiddleware as never) -// JWT signature, tenant claim, and current agent assignment are all checked +// JWT signature, tenant claim, and exact current Agent placement are checked // before any body parser reads JSON. Most runtime calls are small; the FUSE // whole-file write endpoint installs its compatibility parser at the route. const runtimeJsonParser = json({ limit: '4mb' }) @@ -147,7 +148,7 @@ runtimeRouter.get('/wake-stream', withAgent(async (c, _req, res) => { await attachWakeStream(c.sub, res, { // The HTTP middleware validates at connection time. Re-check before every // event as well because this response can remain open across a tenant move. - authorize: () => isRuntimeAgentAuthorized(c.sub, c.companyId), + authorize: () => isRuntimeAgentAuthorized(c), }) // Don't end — attachWakeStream keeps the response open until the // client disconnects. diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts index b25120e3..6e1cea81 100644 --- a/server/src/db/migrate.ts +++ b/server/src/db/migrate.ts @@ -21,6 +21,10 @@ import { WORKSPACE_CLEANUP_JOBS_SQL, workspaceCleanupJobsChecksum, } from './migrations/0003-workspace-cleanup-jobs.js' +import { + AGENT_RUNTIME_ASSIGNMENT_SQL, + agentRuntimeAssignmentChecksum, +} from './migrations/0004-agent-runtime-assignment.js' /** Frozen data backfill embedded in migration 0001. Exported so its behavior * can be exercised against PostgreSQL without replaying the whole migration. */ @@ -2355,6 +2359,10 @@ async function applyWorkspaceCleanupJobs(client: import('pg').PoolClient): Promi await client.query(WORKSPACE_CLEANUP_JOBS_SQL) } +async function applyAgentRuntimeAssignment(client: import('pg').PoolClient): Promise { + await client.query(AGENT_RUNTIME_ASSIGNMENT_SQL) +} + const VERSIONED_MIGRATIONS: readonly VersionedMigration[] = [ { ...SCHEMA_MIGRATIONS[0], @@ -2371,6 +2379,11 @@ const VERSIONED_MIGRATIONS: readonly VersionedMigration[] = [ sourceChecksum: workspaceCleanupJobsChecksum(), up: applyWorkspaceCleanupJobs, }, + { + ...SCHEMA_MIGRATIONS[3], + sourceChecksum: agentRuntimeAssignmentChecksum(), + up: applyAgentRuntimeAssignment, + }, ] function validateMigrationDefinitions(): void { diff --git a/server/src/db/migrations/0004-agent-runtime-assignment.ts b/server/src/db/migrations/0004-agent-runtime-assignment.ts new file mode 100644 index 00000000..3dfe3494 --- /dev/null +++ b/server/src/db/migrations/0004-agent-runtime-assignment.ts @@ -0,0 +1,37 @@ +import { createHash } from 'node:crypto' + +/** + * Migration 0004: give every Agent placement an opaque runtime generation. + * + * The trigger is deliberately database-owned. Placement changes also happen in + * bulk onboarding and administrative repair SQL, so rotating only in one API + * helper would eventually leave a stale-token bypass on another write path. + */ +export const AGENT_RUNTIME_ASSIGNMENT_SQL = ` +ALTER TABLE participants + ADD COLUMN runtime_assignment_id TEXT NOT NULL DEFAULT gen_random_uuid()::text; + +CREATE FUNCTION rotate_participant_runtime_assignment_id() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $migration$ +BEGIN + IF NEW.company_id IS DISTINCT FROM OLD.company_id + OR NEW.computer_id IS DISTINCT FROM OLD.computer_id + OR NEW.kind IS DISTINCT FROM OLD.kind + OR NEW.departed_at IS DISTINCT FROM OLD.departed_at THEN + NEW.runtime_assignment_id := gen_random_uuid()::text; + END IF; + RETURN NEW; +END; +$migration$; + +CREATE TRIGGER participants_runtime_assignment_rotation +BEFORE UPDATE OF company_id, computer_id, kind, departed_at ON participants +FOR EACH ROW +EXECUTE FUNCTION rotate_participant_runtime_assignment_id(); +` + +export function agentRuntimeAssignmentChecksum(): string { + return createHash('sha256').update(AGENT_RUNTIME_ASSIGNMENT_SQL).digest('hex') +} diff --git a/server/src/db/migrations/manifest.ts b/server/src/db/migrations/manifest.ts index 70597f25..6d933ac2 100644 --- a/server/src/db/migrations/manifest.ts +++ b/server/src/db/migrations/manifest.ts @@ -32,12 +32,17 @@ export const SCHEMA_MIGRATIONS = [ name: '0003_workspace_cleanup_jobs', checksum: 'cd4047a09fb585ba166e7e0a48a21169168da567728dcb63ae8e6ff5bd204d89', }, + { + version: 4, + name: '0004_agent_runtime_assignment', + checksum: '59956150df026c36238298603ed47438abf154cfc779034758d7141a79ae00b2', + }, ] as const satisfies readonly MigrationMetadata[] /** This build intentionally supports one exact schema range. Expand/contract * releases may widen the range, but both bounds must remain explicit. */ -export const MIN_SUPPORTED_SCHEMA_VERSION = 3 -export const MAX_SUPPORTED_SCHEMA_VERSION = 3 +export const MIN_SUPPORTED_SCHEMA_VERSION = 4 +export const MAX_SUPPORTED_SCHEMA_VERSION = 4 function assertManifestShape(): void { for (let i = 0; i < SCHEMA_MIGRATIONS.length; i++) {