diff --git a/CHANGELOG.md b/CHANGELOG.md index a7781ba0..1b390050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,15 @@ 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 + +- Agent tokens can read the agent roster (`GET /v1/agents`, `GET /v1/agents/:name`) and release or delete the agents they spawned, so spawned agents can work without the workspace key. Agents report who spawned them as `spawned_by`. + +### Changed + +- `POST /v1/agents/release` and `POST /v1/agents/release-exact` with an agent token accept only the caller itself or agents it spawned; other targets return `403 agent_not_spawned_by_caller` and need a workspace key. ## [8.13.0] - 2026-09-25 diff --git a/README.md b/README.md index 8d72e03f..915ad295 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,9 @@ hosted usage view, backfill boundary, and cost model. - Workspace: isolated environment for one project/team - Workspace key (`rk_live_*`): admin token for managing workspace resources -- Agent token (`at_live_*`): REST identity token an individual agent uses to participate +- Agent token (`at_live_*`): REST identity token an individual agent uses to participate. It can + also read the agent roster and node fleet, and release or delete the agents it spawned + (`spawned_by`), so a spawned agent never needs the workspace key - Node token (`nt_live_*`): realtime transport token for direct or broker nodes on `/v1/node/ws` - Observer token (`ot_live_*`): scoped read-only token for workspace realtime and read-only REST - Identity types: `agent` (AI worker), `human` (person), `system` (automation/service actor) @@ -725,6 +727,15 @@ send its SHA-256 hash as `expected_token_hash`; Relaycast then rejects a stale release with `agent_release_generation_conflict` before dispatch or completion, so a same-name takeover is left untouched. +When an agent token invokes the spawn action (`POST /actions/spawn/invoke` or `POST /agents/spawn`), +the agent the node registers for that invocation records the caller as `spawned_by`. That agent +token may then release (`POST /agents/release`, `POST /agents/release-exact`) or delete +(`DELETE /agents/:name`) it; releasing or deleting any other agent returns +`403 agent_not_spawned_by_caller` (an agent may still release itself). The workspace key keeps full +rights. Agents registered directly, spawned with a workspace key, or created before ownership was +recorded have `spawned_by: null`. Registering identities, observer tokens, webhooks, directory +writes, node enrollment, and workspace deletion stay workspace-key only. + `GET /nodes` pushes `capability`, `name`, and a liveness `status` selector into its SQL query instead of fetching the full roster and filtering in JS. Without `history`, the response stays the legacy bare array every existing diff --git a/openapi.yaml b/openapi.yaml index 387ce77d..667a3f96 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -355,6 +355,13 @@ components: type: string format: date-time description: Last presence update timestamp. + spawned_by: + type: string + nullable: true + description: >- + Id of the agent whose spawn created this agent. That agent's token + may release or delete it. Null when registered directly, spawned + with a workspace key, or created before ownership was recorded. channels: type: array description: Channels this agent belongs to. Present on agent detail responses. @@ -2398,11 +2405,13 @@ paths: active or legacy online rows are returned as offline. Reads do not write to the database; durable cleanup runs separately. Status filters are applied in SQL against derived presence, with online aliasing active. - Observer tokens require `agents:read`. + Agent tokens may read the roster. Observer tokens require `agents:read` + and see only the agents their filters allow. tags: - Agents security: - workspaceKey: [] + - agentToken: [] - observerToken: [] parameters: - name: status @@ -2530,11 +2539,13 @@ paths: summary: Get agent description: >- Get agent by name. Presence uses the same five-minute last_seen TTL as - the roster, without database writes. Observer tokens require `agents:read`. + the roster, without database writes. Agent tokens may read any agent. + Observer tokens require `agents:read`. tags: - Agents security: - workspaceKey: [] + - agentToken: [] - observerToken: [] parameters: - name: name @@ -2605,11 +2616,13 @@ paths: description: >- Tombstone an agent, remove memberships, and dead-letter its active deliveries in one atomic write. Database adapters without transaction or atomic batch - support are refused before these changes. + support are refused before these changes. An agent token may delete only + agents it spawned (`spawned_by`); other agents need a workspace key. tags: - Agents security: - workspaceKey: [] + - agentToken: [] parameters: - name: name in: path @@ -2619,6 +2632,14 @@ paths: responses: '204': description: Agent deleted + '403': + description: agent_not_spawned_by_caller; the agent token did not spawn this agent + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found /agents/{name}/subscription-channel: post: @@ -2950,7 +2971,9 @@ paths: tombstoning the agent and deleting any implicit direct node. Irreversible release removes memberships and dead-letters active deliveries in the same atomic write. Database adapters without transaction or atomic batch - support are refused before these changes. + support are refused before these changes. An agent token may release + itself or agents it spawned (`spawned_by`); releasing any other agent + needs a workspace key or node token. tags: - Agents security: @@ -3000,6 +3023,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '403': + description: agent_not_spawned_by_caller; the agent token did not spawn this agent + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '404': description: Agent not found content: @@ -3031,7 +3060,8 @@ paths: replacement is never released by this operation. Replaying a key returns the original terminal invocation and does not enqueue a second `action.invoked` webhook, while reusing it with another payload - conflicts. + conflicts. An agent token may release itself or agents it spawned + (`spawned_by`). tags: [Agents] security: - workspaceKey: [] @@ -3074,6 +3104,8 @@ paths: data: { $ref: '#/components/schemas/LifecycleActionInvocation' } '400': description: Missing or invalid identity/key + '403': + description: agent_not_spawned_by_caller; the agent token did not spawn this agent '409': description: agent_identity_mismatch or idempotency_key_reused; no replacement mutation occurred '503': diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index 03c3f62a..1cc5fad4 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -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 + +- `GET /v1/agents` and `GET /v1/agents/:name` accept agent tokens (read-only); observer tokens keep their scope and filters. +- Migration `0062_agent_spawned_by.sql` adds `agents.spawned_by`. A node's `agent.register` for a spawn invocation dispatched to it (matching the invocation's agent name) records the invoking agent; workspace-key spawns and existing rows stay `null`. Agent resources expose it as `spawned_by`. +- `DELETE /v1/agents/:name` accepts an agent token for agents it spawned. + +### Changed + +- `POST /v1/agents/release` and `POST /v1/agents/release-exact` with an agent token accept only the caller itself or agents it spawned; other targets return `403 agent_not_spawned_by_caller`. Workspace keys and node tokens are unchanged. ## [8.13.0] - 2026-09-25 diff --git a/packages/engine/src/__tests__/conformance/agentScopedPermissions.test.ts b/packages/engine/src/__tests__/conformance/agentScopedPermissions.test.ts new file mode 100644 index 00000000..cb0f79cd --- /dev/null +++ b/packages/engine/src/__tests__/conformance/agentScopedPermissions.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { createWorkspace, FakeSocket, makeNodeStack, registerAgent, type TestStack } from './harness.js'; +import { agents } from '../../db/schema.js'; + +/** + * Agent-scoped permissions (#453): an agent token can read the roster and the + * fleet, and can release or delete the agents it spawned, so a spawned agent + * does not need the workspace key. Everything else stays workspace-key only. + */ +describe('agent-scoped permissions', () => { + let stack: TestStack; + beforeEach(() => { stack = makeNodeStack({ ttlMs: 60_000 }); }); + afterEach(() => stack.close()); + + type Workspace = { workspaceKey: string; workspaceId: string }; + + function request(path: string, token: string, init: { method?: string; body?: unknown; headers?: Record } = {}) { + return stack.app.request(path, { + method: init.method ?? 'GET', + headers: { + authorization: `Bearer ${token}`, + ...(init.body === undefined ? {} : { 'content-type': 'application/json' }), + ...init.headers, + }, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); + } + + async function bringBrokerOnline(ws: Workspace, nodeId: string, name: string) { + const enrolled = await request('/v1/nodes', ws.workspaceKey, { + method: 'POST', + body: { node_id: nodeId, name, role: 'broker', capabilities: ['spawn:claude'], max_agents: 16, tags: ['test'], version: 'v0' }, + }); + expect(enrolled.status).toBe(201); + const sock = new FakeSocket(); + const handle = stack.runtime.realtime.attachNodeSocket(ws.workspaceId, nodeId, sock); + await handle.handleMessage(JSON.stringify({ + v: 1, type: 'node.register', name, node_id: nodeId, + capabilities: [{ name: 'spawn:claude', kind: 'capacity' }], + max_agents: 16, tags: ['test'], version: 'v1', resume_cursor: null, + })); + await handle.handleMessage(JSON.stringify({ + v: 1, type: 'node.heartbeat', load: 0, active_agents: 0, handlers_live: true, + })); + return { sock, handle }; + } + + /** Answer the broker's `action.invoke` for a spawn the way the relay broker does. */ + async function registerSpawned( + broker: { sock: FakeSocket; handle: { handleMessage(raw: string): Promise } }, + name: string, + invocationId?: string, + ) { + await broker.handle.handleMessage(JSON.stringify({ + v: 1, id: `register-${name}`, type: 'agent.register', name, resumable: true, + ...(invocationId ? { invocation_id: invocationId } : {}), + })); + const reply = broker.sock.ofType('reply').at(-1) as { ok: boolean; data: { agent_id: string; token: string } }; + expect(reply.ok).toBe(true); + return { agentId: reply.data.agent_id, token: reply.data.token, name }; + } + + /** Spawn through the real path: an agent token invokes spawn, the broker registers the agent. */ + async function spawnAs( + token: string, + broker: { sock: FakeSocket; handle: { handleMessage(raw: string): Promise } }, + name: string, + ) { + const res = await request('/v1/actions/spawn/invoke', token, { + method: 'POST', + body: { input: { cli: 'claude', name } }, + }); + expect(res.status).toBe(201); + const invocationId = (await res.json() as { data: { invocation_id: string } }).data.invocation_id; + expect(broker.sock.ofType('action.invoke').at(-1)).toMatchObject({ invocation_id: invocationId }); + return registerSpawned(broker, name, invocationId); + } + + async function spawnedBy(agentId: string) { + const [row] = await stack.runtime.deps.db + .select({ spawnedBy: agents.spawnedBy }) + .from(agents) + .where(eq(agents.id, agentId)); + return row?.spawnedBy; + } + + async function errorCode(res: Response) { + return (await res.json() as { error?: { code: string; message: string } }).error; + } + + it('lets an agent token read the roster and fleet without secrets, while observer filters still apply', async () => { + const ws = await createWorkspace(stack.app, 'scoped-read'); + const lead = await registerAgent(stack.app, ws.workspaceKey, 'lead'); + const peer = await registerAgent(stack.app, ws.workspaceKey, 'peer'); + const retired = await registerAgent(stack.app, ws.workspaceKey, 'retired'); + expect((await request('/v1/agents/retired', ws.workspaceKey, { method: 'DELETE' })).status).toBe(204); + await bringBrokerOnline(ws, 'node_a', 'alpha'); + const hook = await request('/v1/nodes', ws.workspaceKey, { + method: 'POST', + body: { + name: 'hook', + kind: 'http_push', + delivery: { url: 'https://receiver.example.test/relaycast', auth: { type: 'bearer', token: 'delivery-secret' } }, + }, + }); + expect(hook.status).toBe(201); + + const list = await request('/v1/agents', lead.token); + expect(list.status).toBe(200); + const roster = (await list.json() as { data: Array> }).data; + expect(roster.map((agent) => agent.name).sort()).toEqual(['lead', 'peer']); + expect(roster.map((agent) => agent.id)).not.toContain(retired.agentId); + for (const agent of roster) expect(agent).not.toHaveProperty('token'); + + const detail = await request('/v1/agents/peer', lead.token); + expect(detail.status).toBe(200); + expect((await detail.json() as { data: Record }).data).toMatchObject({ + id: peer.agentId, name: 'peer', spawned_by: null, + }); + expect((await request('/v1/agents/retired', lead.token)).status).toBe(404); + + const nodes = await request('/v1/nodes', lead.token); + expect(nodes.status).toBe(200); + const fleet = (await nodes.json() as { data: Array> }).data; + expect(fleet.map((node) => node.name)).toEqual(expect.arrayContaining(['alpha', 'hook'])); + const serialized = JSON.stringify(fleet); + expect(serialized).not.toContain('delivery-secret'); + expect(serialized).not.toContain('nt_live_'); + const hookNode = await request('/v1/nodes/hook', lead.token); + expect(hookNode.status).toBe(200); + expect((await hookNode.json() as { data: { delivery: { auth: Record } } }).data.delivery.auth) + .toEqual({ type: 'bearer', token: '[redacted]' }); + + // Observer tokens keep their scope and agent filters on the same routes. + const minted = await request('/v1/observer-tokens', ws.workspaceKey, { + method: 'POST', + body: { name: 'lead-only', scopes: ['agents:read'], filters: { agent_ids: [lead.agentId] } }, + }); + expect(minted.status).toBe(201); + const observer = (await minted.json() as { data: { token: string } }).data.token; + const observed = await request('/v1/agents', observer); + expect(observed.status).toBe(200); + expect((await observed.json() as { data: Array<{ name: string }> }).data.map((agent) => agent.name)).toEqual(['lead']); + expect((await request('/v1/agents/peer', observer)).status).toBe(404); + expect((await request('/v1/agents/lead', observer)).status).toBe(200); + const unscoped = await request('/v1/observer-tokens', ws.workspaceKey, { + method: 'POST', + body: { name: 'channels-only', scopes: ['channels:read'] }, + }); + const channelsOnly = (await unscoped.json() as { data: { token: string } }).data.token; + const denied = await request('/v1/agents', channelsOnly); + expect(denied.status).toBe(403); + expect((await errorCode(denied))?.code).toBe('insufficient_scope'); + }); + + it('keeps workspace administration on the workspace key and says so', async () => { + const ws = await createWorkspace(stack.app, 'scoped-admin'); + const lead = await registerAgent(stack.app, ws.workspaceKey, 'lead'); + + for (const [path, method, body] of [ + ['/v1/agents', 'POST', { name: 'impostor' }], + ['/v1/observer-tokens', 'POST', { name: 'watch', scopes: ['agents:read'] }], + ['/v1/nodes', 'POST', { name: 'rogue', role: 'broker', capabilities: [], max_agents: 1 }], + ['/v1/agents/lead', 'PATCH', { persona: 'changed' }], + ['/v1/webhooks', 'POST', { name: 'hook', channel: 'general' }], + ['/v1/directory/agents', 'POST', { name: 'listing' }], + ['/v1/workspace', 'DELETE', undefined], + ] as const) { + const res = await request(path, lead.token, { method, body }); + expect(res.status, `${method} ${path}`).toBe(401); + expect((await errorCode(res))?.message, `${method} ${path}`).toMatch(/workspace key/i); + } + const events = await request('/v1/agents/lead/events', lead.token); + expect(events.status).toBe(401); + expect((await errorCode(events))?.message).toMatch(/workspace key/i); + }); + + it('records spawned_by on the real spawn path, only for the invoking agent', async () => { + const ws = await createWorkspace(stack.app, 'scoped-spawn'); + const lead = await registerAgent(stack.app, ws.workspaceKey, 'lead'); + const broker = await bringBrokerOnline(ws, 'node_a', 'alpha'); + + const worker = await spawnAs(lead.token, broker, 'worker'); + expect(await spawnedBy(worker.agentId)).toBe(lead.agentId); + const detail = await request('/v1/agents/worker', lead.token); + expect((await detail.json() as { data: { spawned_by: string | null } }).data.spawned_by).toBe(lead.agentId); + const roster = await request('/v1/agents', worker.token); + expect((await roster.json() as { data: Array<{ name: string; spawned_by: string | null }> }).data) + .toContainEqual(expect.objectContaining({ name: 'worker', spawned_by: lead.agentId })); + + // POST /v1/agents/spawn with an agent token is the same invocation path. + const viaRoute = await request('/v1/agents/spawn', lead.token, { + method: 'POST', + body: { name: 'route-worker', cli: 'claude', task: 'help' }, + }); + expect(viaRoute.status).toBe(201); + const routeInvocation = (await viaRoute.json() as { data: { invocation_id: string } }).data.invocation_id; + const routeWorker = await registerSpawned(broker, 'route-worker', routeInvocation); + expect(await spawnedBy(routeWorker.agentId)).toBe(lead.agentId); + + // A workspace-key spawn has no spawning agent. + const adminSpawn = await request('/v1/agents/spawn', ws.workspaceKey, { + method: 'POST', + body: { name: 'admin-worker', cli: 'claude', task: 'help' }, + }); + expect(adminSpawn.status).toBe(201); + const adminInvocation = (await adminSpawn.json() as { data: { invocation_id: string } }).data.invocation_id; + expect(await spawnedBy((await registerSpawned(broker, 'admin-worker', adminInvocation)).agentId)).toBeNull(); + + // A registration that does not answer the invocation for that name is not attributed. + const decoyInvoke = await request('/v1/actions/spawn/invoke', lead.token, { + method: 'POST', + body: { input: { cli: 'claude', name: 'decoy' } }, + }); + const decoyInvocation = (await decoyInvoke.json() as { data: { invocation_id: string } }).data.invocation_id; + expect(await spawnedBy((await registerSpawned(broker, 'unrelated', decoyInvocation)).agentId)).toBeNull(); + expect(await spawnedBy((await registerSpawned(broker, 'uncorrelated')).agentId)).toBeNull(); + }); + + it('lets an agent release and delete only the agents it spawned; the workspace key keeps full rights', async () => { + const ws = await createWorkspace(stack.app, 'scoped-manage'); + const lead = await registerAgent(stack.app, ws.workspaceKey, 'lead'); + const peer = await registerAgent(stack.app, ws.workspaceKey, 'peer'); + const broker = await bringBrokerOnline(ws, 'node_a', 'alpha'); + const released = await spawnAs(lead.token, broker, 'released-worker'); + const deleted = await spawnAs(lead.token, broker, 'deleted-worker'); + const exact = await spawnAs(lead.token, broker, 'exact-worker'); + const peerWorker = await spawnAs(peer.token, broker, 'peer-worker'); + const releaseFrames = () => broker.sock.ofType('action.invoke').filter((frame) => frame.action === 'release'); + + // Another agent is refused before anything is dispatched. + for (const target of ['released-worker', 'deleted-worker']) { + const release = await request('/v1/agents/release', peer.token, { method: 'POST', body: { name: target } }); + expect(release.status).toBe(403); + expect(await errorCode(release)).toMatchObject({ + code: 'agent_not_spawned_by_caller', + message: expect.stringMatching(/workspace key/i), + }); + const remove = await request(`/v1/agents/${target}`, peer.token, { method: 'DELETE' }); + expect(remove.status).toBe(403); + expect((await errorCode(remove))?.code).toBe('agent_not_spawned_by_caller'); + } + const exactByPeer = await request('/v1/agents/release-exact', peer.token, { + method: 'POST', + headers: { 'idempotency-key': 'peer-exact' }, + body: { name: 'exact-worker', expected_agent_id: exact.agentId }, + }); + expect(exactByPeer.status).toBe(403); + // A spawned agent does not own its sibling or its spawner. + expect((await request('/v1/agents/release', released.token, { method: 'POST', body: { name: 'deleted-worker' } })).status).toBe(403); + expect((await request('/v1/agents/lead', released.token, { method: 'DELETE' })).status).toBe(403); + // Nor may an agent delete itself through the workspace-admin route. + expect((await request('/v1/agents/lead', lead.token, { method: 'DELETE' })).status).toBe(403); + expect(releaseFrames()).toHaveLength(0); + expect(await spawnedBy(deleted.agentId)).toBe(lead.agentId); + + // The spawner may release and delete its own agents. + const release = await request('/v1/agents/release', lead.token, { method: 'POST', body: { name: 'released-worker' } }); + expect(release.status).toBe(201); + expect(releaseFrames().at(-1)).toMatchObject({ input: expect.objectContaining({ name: 'released-worker' }) }); + const exactRelease = await request('/v1/agents/release-exact', lead.token, { + method: 'POST', + headers: { 'idempotency-key': 'lead-exact' }, + body: { name: 'exact-worker', expected_agent_id: exact.agentId }, + }); + expect(exactRelease.status).toBe(201); + expect((await request('/v1/agents/deleted-worker', lead.token, { method: 'DELETE' })).status).toBe(204); + expect((await request('/v1/agents/deleted-worker', lead.token)).status).toBe(404); + + // Self-release is unchanged. + expect((await request('/v1/agents/release', peerWorker.token, { method: 'POST', body: { name: 'peer-worker' } })).status).toBe(201); + + // The workspace key manages any agent, regardless of who spawned it. + expect((await request('/v1/agents/peer-worker', ws.workspaceKey, { method: 'DELETE' })).status).toBe(204); + expect((await request('/v1/agents/peer', ws.workspaceKey, { method: 'DELETE' })).status).toBe(204); + + // A missing target keeps its not-found answer. + expect((await request('/v1/agents/ghost', lead.token, { method: 'DELETE' })).status).toBe(404); + }); +}); diff --git a/packages/engine/src/db/__tests__/agentSpawnedByMigration.test.ts b/packages/engine/src/db/__tests__/agentSpawnedByMigration.test.ts new file mode 100644 index 00000000..a8f8ee08 --- /dev/null +++ b/packages/engine/src/db/__tests__/agentSpawnedByMigration.test.ts @@ -0,0 +1,33 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +import { getSqliteDb, runMigrations } from '../../adapters/node/database.js'; + +it('adds agents.spawned_by additively, leaving existing agents unowned', () => { + const directory = fileURLToPath(new URL('../migrations/', import.meta.url)); + const handle = getSqliteDb(':memory:'); + const db = handle.sqlite; + try { + db.exec('CREATE TABLE _engine_migrations(name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)'); + for (const name of readdirSync(directory).filter(name => name.endsWith('.sql') && name < '0062').sort()) { + db.exec(readFileSync(directory + name, 'utf8')); + db.prepare('INSERT INTO _engine_migrations VALUES (?,0)').run(name); + } + db.exec(`INSERT INTO workspaces(id,name,api_key_hash) VALUES ('ws','fixture','key'); + INSERT INTO agents(id,workspace_id,name,token_hash) VALUES ('lead','ws','lead','lead-token');`); + const columns = db.pragma('table_info(agents)') as { name: string }[]; + const rows = db.prepare('SELECT * FROM agents').all(); + const fks = db.pragma('foreign_key_list(agents)'); + + expect(runMigrations(handle).applied).toEqual(['0062_agent_spawned_by.sql']); + const after = db.pragma('table_info(agents)') as { name: string; type: string; notnull: number; dflt_value: unknown }[]; + expect(after.slice(0, columns.length)).toEqual(columns); + expect(after.slice(columns.length)).toEqual([ + expect.objectContaining({ name: 'spawned_by', type: 'TEXT', notnull: 0, dflt_value: null }), + ]); + expect(db.pragma('foreign_key_list(agents)')).toEqual(fks); + expect(db.prepare(`SELECT ${columns.map(c => c.name).join(',')} FROM agents`).all()).toEqual(rows); + expect(db.prepare('SELECT spawned_by FROM agents').get()).toEqual({ spawned_by: null }); + expect(runMigrations(handle).applied).toEqual([]); + } finally { db.close(); } +}); diff --git a/packages/engine/src/db/__tests__/compactMigrations.test.ts b/packages/engine/src/db/__tests__/compactMigrations.test.ts index bd018d35..f86091c2 100644 --- a/packages/engine/src/db/__tests__/compactMigrations.test.ts +++ b/packages/engine/src/db/__tests__/compactMigrations.test.ts @@ -48,10 +48,11 @@ function snapshot(handle: SqliteDbHandle) { return tables.map(({ name }) => { const rows = handle.sqlite.prepare(`SELECT * FROM "${name}"`).all().map(row => { // 0056 adds a conservative reconciliation witness and backfills it from - // the existing last_seen value. Exclude that additive bookkeeping field - // so this regression continues to compare all pre-existing user data. + // the existing last_seen value, and 0062 adds the NULL spawned_by owner. + // Exclude those additive fields so this regression continues to compare + // all pre-existing user data. if (name === 'agents') { - const { status_updated_at: _statusUpdatedAt, ...existing } = row as Record; + const { status_updated_at: _statusUpdatedAt, spawned_by: _spawnedBy, ...existing } = row as Record; return JSON.stringify(existing); } return JSON.stringify(row); diff --git a/packages/engine/src/db/__tests__/taskMigration.test.ts b/packages/engine/src/db/__tests__/taskMigration.test.ts index 338a8228..51c85f79 100644 --- a/packages/engine/src/db/__tests__/taskMigration.test.ts +++ b/packages/engine/src/db/__tests__/taskMigration.test.ts @@ -23,7 +23,7 @@ it('upgrades existing actions and results additively, preserving rows and every rows: db.prepare(`SELECT * FROM ${name}`).all(), fks: db.pragma(`foreign_key_list(${name})`), })); - expect(runMigrations(handle).applied).toEqual([ + expect(runMigrations(handle).applied.slice(0, 2)).toEqual([ '0060_durable_task_invocations.sql', '0061_messages_workspace_length_id_index.sql', ]); diff --git a/packages/engine/src/db/migrations/0062_agent_spawned_by.sql b/packages/engine/src/db/migrations/0062_agent_spawned_by.sql new file mode 100644 index 00000000..f2dfef01 --- /dev/null +++ b/packages/engine/src/db/migrations/0062_agent_spawned_by.sql @@ -0,0 +1,6 @@ +-- The agent whose spawn invocation created this agent. Agent tokens may +-- release or delete only agents they spawned; workspace keys keep full rights. +-- NULL for agents registered directly or spawned by a workspace key, and for +-- every row that predates this column. No foreign key: agents are tombstoned +-- rather than deleted, and a retained id grants nothing once its row is gone. +ALTER TABLE agents ADD COLUMN spawned_by TEXT; diff --git a/packages/engine/src/db/schema.ts b/packages/engine/src/db/schema.ts index 1d964a6b..b5ed1fe6 100644 --- a/packages/engine/src/db/schema.ts +++ b/packages/engine/src/db/schema.ts @@ -146,6 +146,9 @@ export const agents = sqliteTable( // Migration 0056 backfills historical rows from last_seen and its SQLite // trigger advances this value for every later status/liveness write. statusUpdatedAt: integer('status_updated_at', { mode: 'timestamp' }), + // Id of the agent whose spawn invocation created this one (migration 0062). + // Grants that agent release/delete rights over it; NULL when unowned. + spawnedBy: text('spawned_by'), }, (table) => [ uniqueIndex('agents_workspace_name_unique').on(table.workspaceId, table.name), diff --git a/packages/engine/src/engine/agent.ts b/packages/engine/src/engine/agent.ts index 33eb2ab9..6d65bc43 100644 --- a/packages/engine/src/engine/agent.ts +++ b/packages/engine/src/engine/agent.ts @@ -405,6 +405,7 @@ export async function listAgents(db: Db, workspaceId: string, status?: string) { capabilities: a.capabilities ?? null, created_at: a.createdAt.toISOString(), last_seen: a.lastSeen.toISOString(), + spawned_by: a.spawnedBy ?? null, metadata: a.metadata, })); } @@ -490,6 +491,7 @@ export async function getAgentByName(db: Db, workspaceId: string, name: string) capabilities: agent.capabilities ?? null, created_at: agent.createdAt.toISOString(), last_seen: agent.lastSeen.toISOString(), + spawned_by: agent.spawnedBy ?? null, metadata: agent.metadata, channels: memberships.map((m) => ({ id: m.channelId, @@ -608,6 +610,7 @@ export async function updateAgentById( capabilities: updated.capabilities ?? null, created_at: updated.createdAt.toISOString(), last_seen: updated.lastSeen.toISOString(), + spawned_by: updated.spawnedBy ?? null, metadata: updated.metadata, }; } @@ -661,6 +664,7 @@ export async function claimLegacyAgentIdentity( capabilities: updated.capabilities ?? null, created_at: updated.createdAt.toISOString(), last_seen: updated.lastSeen.toISOString(), + spawned_by: updated.spawnedBy ?? null, metadata: updated.metadata, }; } diff --git a/packages/engine/src/engine/node.ts b/packages/engine/src/engine/node.ts index 70940cff..20e038f4 100644 --- a/packages/engine/src/engine/node.ts +++ b/packages/engine/src/engine/node.ts @@ -1746,6 +1746,34 @@ export async function listNodeAgents(db: Db, workspaceId: string, nodeName: stri return rows.map(serializeBinding); } +/** + * The agent that invoked the spawn this registration answers. The invocation + * must be a spawn dispatched to this node for this agent name, so a node can + * only attribute agents to the spawns it was actually asked to run. NULL when + * the spawn came from a workspace key or node, or is not correlated. + */ +async function spawnInvocationCaller( + db: Db, + workspaceId: string, + registration: { invocationId?: string; nodeId: string; agentName: string }, +): Promise { + if (!registration.invocationId) return null; + const [row] = await db + .select({ callerId: actionInvocations.callerId }) + .from(actionInvocations) + .where(and( + eq(actionInvocations.workspaceId, workspaceId), + eq(actionInvocations.id, registration.invocationId), + eq(actionInvocations.dispatchedNodeId, registration.nodeId), + or( + eq(actionInvocations.actionName, 'spawn'), + sql`${actionInvocations.actionName} LIKE 'spawn:%'`, + ), + sql`json_extract(${actionInvocations.input}, '$.name') = ${registration.agentName}`, + )); + return row?.callerId ?? null; +} + export async function registerAgentViaNode( db: Db, workspaceId: string, @@ -1785,6 +1813,11 @@ export async function registerAgentViaNode( ); } + const spawnedBy = await spawnInvocationCaller(tx, workspaceId, { + invocationId: message.invocation_id, + nodeId, + agentName: message.name, + }); const token = `at_live_${randomHex(16)}`; const tokenHash = await sha256Hex(token); const now = new Date().toISOString(); @@ -1820,6 +1853,7 @@ export async function registerAgentViaNode( originNodeId: nodeId, resumable: message.resumable ?? false, sessionRef: message.session_ref ?? null, + spawnedBy, }) .onConflictDoNothing({ target: [agents.workspaceId, agents.name] }) .returning(); diff --git a/packages/engine/src/routes/agent.ts b/packages/engine/src/routes/agent.ts index 407b48e0..7e0cf5a5 100644 --- a/packages/engine/src/routes/agent.ts +++ b/packages/engine/src/routes/agent.ts @@ -1,4 +1,4 @@ -import { Hono } from 'hono'; +import { Hono, type Context } from 'hono'; import { z } from 'zod'; import { and, eq } from 'drizzle-orm'; import { AGENT_TOKEN_HASH_PATTERN, AgentTypeSchema, CliTypeSchema } from '@relaycast/types'; @@ -183,6 +183,33 @@ function agentNotFound(c: Parameters[0], name: string) { return jsonNotFound(c, 'agent_not_found', `Agent "${name}" not found`); } +/** + * Agent tokens manage only the agents they spawned (and, when `allowSelf`, the + * caller itself). Workspace keys and node tokens are not restricted here. A + * missing target passes through so the route keeps its own not-found answer. + */ +async function rejectUnownedAgentTarget( + c: Context, + name: string, + options: { allowSelf: boolean }, +): Promise { + const caller = c.get('agent'); + if (!caller) return null; + const [target] = await c.get('db') + .select({ id: agents.id, spawnedBy: agents.spawnedBy }) + .from(agents) + .where(and(eq(agents.workspaceId, c.get('workspace').id), eq(agents.name, name))); + if (!target) return null; + if (target.spawnedBy === caller.id) return null; + if (options.allowSelf && target.id === caller.id) return null; + return jsonError( + c, + 'agent_not_spawned_by_caller', + `Agent "${name}" was not spawned by this agent; managing it requires a workspace key`, + 403, + ); +} + /** Fan an agent status change out to the workspace stream, presence-observing nodes, and webhooks. */ async function fanoutAgentStatus(c: Parameters[0], agent: { id: string; name: string }, status: string, eventId?: string): Promise { const nextStatus = canonicalStatus(status); @@ -542,10 +569,10 @@ agentRoutes.post( }, ); -// GET /v1/agents - list agents +// GET /v1/agents - list agents (workspace key, agent token, or scoped observer token) agentRoutes.get( '/agents', - requireWorkspaceRead('agents:read', { allowAgent: false, allowNode: false }), + requireWorkspaceRead('agents:read', { allowNode: false }), rateLimit, async (c) => { try { @@ -565,7 +592,7 @@ agentRoutes.get( // GET /v1/agents/:name - get agent by name agentRoutes.get( '/agents/:name', - requireWorkspaceRead('agents:read', { allowAgent: false, allowNode: false }), + requireWorkspaceRead('agents:read', { allowNode: false }), rateLimit, async (c) => { try { @@ -730,16 +757,18 @@ agentRoutes.patch( }, ); -// DELETE /v1/agents/:name - delete agent +// DELETE /v1/agents/:name - delete agent (an agent token may delete only agents it spawned) agentRoutes.delete( '/agents/:name', - requireWorkspaceKey, + requireAuth, rateLimit, async (c) => { try { const db = c.get('db'); const workspace = c.get('workspace'); const name = c.req.param('name'); + const unowned = await rejectUnownedAgentTarget(c, name, { allowSelf: false }); + if (unowned) return unowned; const deleted = await agentEngine.deleteAgent(db, workspace.id, name); if (!deleted) { return agentNotFound(c, name); @@ -984,7 +1013,8 @@ agentRoutes.get( }, ); -// POST /v1/agents/release - request a node to release an agent +// POST /v1/agents/release - request a node to release an agent (an agent token +// may release itself or agents it spawned) agentRoutes.post( '/agents/release', requireAuth, @@ -1000,6 +1030,8 @@ agentRoutes.post( return parsed.response; } const { name, reason, delete_agent, expected_token_hash } = parsed.data; + const unowned = await rejectUnownedAgentTarget(c, name, { allowSelf: true }); + if (unowned) return unowned; const input = { name, @@ -1071,6 +1103,8 @@ agentRoutes.post( if (callerAgent?.id === parsed.data.expected_agent_id && parsed.data.delete_agent === true) { return jsonError(c, 'agent_self_release_requires_workspace_key', 'Self-release with delete_agent requires a workspace or node credential', 400); } + const unowned = await rejectUnownedAgentTarget(c, parsed.data.name, { allowSelf: true }); + if (unowned) return unowned; const result = await actionEngine.dispatchAgentRelease( db, diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index bfb187b0..d03340b2 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -7,7 +7,11 @@ 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 + +- `AgentSchema.spawned_by` (optional, nullable): the agent whose spawn created this agent. ## [8.13.0] - 2026-09-25 diff --git a/packages/types/src/agent.ts b/packages/types/src/agent.ts index 80334b9b..dbfaa588 100644 --- a/packages/types/src/agent.ts +++ b/packages/types/src/agent.ts @@ -24,6 +24,8 @@ export const AgentSchema = z.object({ metadata: z.record(z.string(), z.unknown()), last_seen: z.string(), created_at: z.string().optional(), + /** Id of the agent whose spawn created this one; its token may release or delete it. */ + spawned_by: z.string().nullable().optional(), channels: z.array(z.object({ id: z.string(), name: z.string(),