From 2f5c98a634d8bc7f42e4fed2a9b33b060c58447c Mon Sep 17 00:00:00 2001 From: Thor Haaland Date: Sun, 4 Oct 2026 20:52:16 +0000 Subject: [PATCH 1/4] test(auth): pin personal token, key, route and log parity at e9ad47c7 Deterministic fixture (fixed Ed25519 seed, clock, jti and UUIDs) over the personal identity consumers and routes. The golden file is generated on the unchanged e9ad47c7 tree so later identity work must reproduce it exactly. --- service/src/personal-parity.test.ts | 269 ++++ .../test-support/personal-parity.golden.json | 1276 +++++++++++++++++ service/src/test-support/route-harness.ts | 360 +++++ 3 files changed, 1905 insertions(+) create mode 100644 service/src/personal-parity.test.ts create mode 100644 service/src/test-support/personal-parity.golden.json create mode 100644 service/src/test-support/route-harness.ts diff --git a/service/src/personal-parity.test.ts b/service/src/personal-parity.test.ts new file mode 100644 index 00000000..7428cc15 --- /dev/null +++ b/service/src/personal-parity.test.ts @@ -0,0 +1,269 @@ +/** + * C6 personal parity. One deterministic fixture (fixed Ed25519 key, fixed + * clock, fixed jti/UUIDs) drives every personal identity consumer and the + * personal HTTP routes, and the result is compared with a golden file that + * was generated from the pre-change engine (e9ad47c7). Any change to personal + * token verification, auth context, sessionKeys, output/rate-limit buckets, + * runtime-session ids, manifest/grant claims, replay state, forwarded + * file-server headers, Redis writes, job data or log lines fails here. + * + * Regenerate only on the pre-change tree: PERSONAL_PARITY_WRITE=1. + */ +import { afterAll, beforeAll, expect, test } from 'bun:test'; +import { createHash } from 'crypto'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { + call, + installRouteHarness, + jwksFor, + normalizeIds, + signTestJwt, + testSigningKey, + uploadForm, + type RouteHarness, +} from './test-support/route-harness'; + +const GOLDEN_PATH = join(import.meta.dir, 'test-support', 'personal-parity.golden.json'); +const FIXED_NOW_SECONDS = 1_790_000_000; +const USER_ID = '65f0c0ffee0000000000abcd'; +const TENANT = 'tenant-parity'; +const RUN_UUID = '0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f'; +const PUBLIC_AGENT_KEY = 'agent_AbCdEfGhIjK'; +const SKILL_ID = '65f0c0ffee0000000000beef'; + +let harness: RouteHarness; + +function personalClaims(nowSeconds: number, overrides: Record = {}): Record { + return { + iss: 'librechat', + aud: 'codeapi', + sub: USER_ID, + iat: nowSeconds, + nbf: nowSeconds, + exp: nowSeconds + 300, + jti: '7d1e4c52-3f6a-4b8e-9c0d-1e2f3a4b5c6d', + tenant_id: TENANT, + role: 'USER', + principal_source: 'librechat_jwt', + auth_context_hash: 'b1f1c0de'.repeat(8), + ...overrides, + }; +} + +const TOKEN_VARIANTS: Record> = { + console_personal: {}, + with_plan_and_org: { plan_id: 'pro', org_id: 'org_1', service_id: 'svc_1', external_user_id: 'ext_1' }, + legacy_chc_alias: { chc_user_id: 'chc_legacy_1' }, + openid_reuse: { principal_source: 'openid_reuse' }, + aud_array: { aud: ['other', 'codeapi'] }, + control_agent_looking_sub_with_run_id: { sub: '65f0c0ffee0000000000f00d', role: 'AGENT', run_id: RUN_UUID }, +}; + +beforeAll(async () => { + process.env.CODEAPI_TENANT_ISOLATION_STRICT = 'true'; + harness = await installRouteHarness({ + queueModulePath: join(import.meta.dir, 'queue.ts'), + srcDir: import.meta.dir, + jwksJson: jwksFor([{ kid: 'parity-kid', key: testSigningKey() }]), + }); +}); + +afterAll(async () => { + await harness?.close(); +}); + +async function unitSnapshot(): Promise> { + const { verifyLibreChatJwt } = await import('./auth/librechat-jwt'); + const { applyPrincipal } = await import('./auth/principal'); + const { getExecutionIdentity } = await import('./execution-identity'); + const { resolveSessionKey, resolveOutputBucketSessionKey, parseUploadSessionKeyInput } = await import('./session-key'); + const { keyGenerator } = await import('./middleware/limits'); + const { resolveRuntimeSessionIdForExecRequest } = await import('./runtime-session/id'); + const { buildExecutionManifestClaims } = await import('./execution-manifest-claims'); + const { egressGrantFromExecutionClaims } = await import('./egress-grant'); + const { buildReplayExecutionState } = await import('./service/programmatic-state'); + const { checkContinuationPreconditions } = await import('./service/replay-state'); + type Req = Parameters[0]; + + const realNow = Date.now; + Date.now = () => FIXED_NOW_SECONDS * 1000; + try { + const out: Record = {}; + for (const [name, overrides] of Object.entries(TOKEN_VARIANTS)) { + const token = signTestJwt(personalClaims(FIXED_NOW_SECONDS, overrides)); + const principal = verifyLibreChatJwt(token); + const req = { ip: '127.0.0.1', headers: {}, header: () => undefined } as unknown as Req; + applyPrincipal(req, principal); + const identity = getExecutionIdentity(req); + const sessionKeys = { + user: resolveSessionKey(req, parseUploadSessionKeyInput({ kind: 'user', id: undefined, version: undefined, authContextUserId: principal.userId })), + agentRun: resolveSessionKey(req, { kind: 'agent', id: RUN_UUID }), + agentPublic: resolveSessionKey(req, { kind: 'agent', id: PUBLIC_AGENT_KEY }), + skill: resolveSessionKey(req, { kind: 'skill', id: SKILL_ID, version: 3 }), + outputBucket: resolveOutputBucketSessionKey(req), + }; + const payload = { + lang: 'py', + code: 'print(1)', + session_id: 'sess_output_parity_00', + files: [{ id: 'file_parity_000000001', storage_session_id: 'sess_input_parity_001', name: 'in.csv' }], + }; + const manifest = buildExecutionManifestClaims({ + req, + executionId: 'exec_parity_0000000001', + userId: principal.userId, + sessionKey: sessionKeys.outputBucket, + outputSessionId: 'sess_output_parity_00', + payload: payload as never, + nowSeconds: FIXED_NOW_SECONDS, + }); + const replayState = buildReplayExecutionState({ + executionId: 'exec_parity_0000000001', + sessionId: 'sess_output_parity_00', + sessionKey: sessionKeys.outputBucket, + userId: principal.userId, + apiKeyId: '', + authContext: req.codeApiAuthContext, + identity, + code: 'print(1)', + tools: [], + isPyPlot: false, + timeout: 1000, + language: 'python', + now: FIXED_NOW_SECONDS * 1000, + }); + const delta = { serializedByCallId: new Map(), newCallIds: [], bytesDelta: 0 }; + out[name] = { + tokenSha256: createHash('sha256').update(token).digest('hex'), + principal, + authContext: req.codeApiAuthContext, + executionIdentity: identity, + planId: req.planId ?? null, + sessionKeys, + rateLimitKey: keyGenerator(req as never), + runtimeSessionId: resolveRuntimeSessionIdForExecRequest({ + mode: 'affinity', + storageNamespace: identity.storageNamespace, + canonicalUserId: identity.canonicalUserId, + runtimeSessionHint: 'conv-parity', + isSynthetic: false, + }), + manifest, + grant: egressGrantFromExecutionClaims(manifest, 'grant_parity_00000001'), + replayState, + continuationSameUser: checkContinuationPreconditions({ + state: replayState, + results: [], + userId: principal.userId, + apiKeyId: '', + tenantId: identity.storageNamespace, + authContextHash: identity.authContextHash, + delta, + }), + continuationOtherUser: checkContinuationPreconditions({ + state: replayState, + results: [], + userId: 'someone_else', + apiKeyId: '', + tenantId: identity.storageNamespace, + authContextHash: identity.authContextHash, + delta, + }), + }; + } + return out; + } finally { + Date.now = realNow; + } +} + +async function routeSnapshot(): Promise> { + const now = Math.floor(Date.now() / 1000); + const token = signTestJwt(personalClaims(now)); + const otherUserToken = signTestJwt(personalClaims(now, { sub: '65f0c0ffee0000000000dddd' })); + const steps: Array> = []; + const record = (name: string, result: { status: number; body: unknown }): void => { + steps.push({ name, ...result }); + }; + + const userUpload = await uploadForm(harness.baseUrl, token, { kind: 'user' }, [{ name: 'notes.txt', content: 'hello' }]); + record('upload user', userUpload); + const agentUpload = await uploadForm( + harness.baseUrl, + token, + { kind: 'agent', id: RUN_UUID }, + [{ name: 'a.txt', content: 'aa' }, { name: 'dir/b.txt', content: 'bbb' }], + '/v1/upload/batch', + ); + record('upload/batch agent', agentUpload); + const skillUpload = await uploadForm( + harness.baseUrl, + token, + { kind: 'skill', id: SKILL_ID, version: '3', read_only: 'true' }, + [{ name: 'SKILL.md', content: '# skill' }], + '/v1/upload/batch', + ); + record('upload/batch skill', skillUpload); + + const userSession = (userUpload.body as { storage_session_id: string }).storage_session_id; + const userFile = (userUpload.body as { files: Array<{ fileId: string }> }).files[0].fileId; + const agentSession = (agentUpload.body as { storage_session_id: string }).storage_session_id; + const agentFile = (agentUpload.body as { files: Array<{ fileId: string }> }).files[0].fileId; + + record('metadata user', await call(harness.baseUrl, token, 'GET', `/v1/sessions/${userSession}/objects/${userFile}?kind=user`)); + record('download agent', await call(harness.baseUrl, token, 'GET', `/v1/download/${agentSession}/${agentFile}?kind=agent&id=${RUN_UUID}`)); + record('files list user', await call(harness.baseUrl, token, 'GET', `/v1/files/${userSession}?kind=user`)); + record('download wrong kind', await call(harness.baseUrl, token, 'GET', `/v1/download/${userSession}/${userFile}?kind=agent&id=${RUN_UUID}`)); + record('download other user', await call(harness.baseUrl, otherUserToken, 'GET', `/v1/download/${userSession}/${userFile}?kind=user`)); + record('exec', await call(harness.baseUrl, token, 'POST', '/v1/exec', { + lang: 'py', + code: 'print(1)', + runtime_session_hint: 'conv-parity', + files: [ + { id: userFile, storage_session_id: userSession, name: 'notes.txt', kind: 'user', resource_id: USER_ID }, + { id: agentFile, storage_session_id: agentSession, name: 'a.txt', kind: 'agent', resource_id: RUN_UUID }, + ], + })); + record('exec foreign file', await call(harness.baseUrl, otherUserToken, 'POST', '/v1/exec', { + lang: 'py', + code: 'print(1)', + files: [{ id: userFile, storage_session_id: userSession, name: 'notes.txt', kind: 'user', resource_id: USER_ID }], + })); + record('delete user', await call(harness.baseUrl, token, 'DELETE', `/v1/files/${userSession}/${userFile}?kind=user`)); + + const jobs = harness.jobs.map(job => { + const { egressGrantClaims, ...rest } = job as { egressGrantClaims?: Record }; + /* iat/exp follow the wall clock in this live part; every other claim byte is compared. */ + const { iat: _iat, exp: _exp, ...claims } = egressGrantClaims ?? {}; + return { ...rest, egressGrantClaims: claims }; + }); + const puts = harness.puts.map(put => { + const { 'x-codeapi-internal-token': internal, host: _host, ...headers } = put.headers; + return { url: put.url, bytes: put.bytes, internalTokenPresent: Boolean(internal), headers }; + }); + return { + steps, + redisWrites: harness.redis.writes, + puts, + jobs, + logs: harness.logs, + }; +} + +test('personal identity consumers, routes and log lines are byte-identical to the pre-change engine', async () => { + const snapshot = normalizeIds({ unit: await unitSnapshot(), routes: await routeSnapshot() }) as { + routes: { jobs: Array<{ egressGrantClaims: { input_files?: unknown[]; read_sessions?: unknown[] } }> }; + }; + /* The engine sorts these by raw (random) storage ids; once ids are + * placeholders the order is noise, the membership is the contract. */ + for (const job of snapshot.routes.jobs) { + job.egressGrantClaims.input_files?.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + job.egressGrantClaims.read_sessions?.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + } + if (process.env.PERSONAL_PARITY_WRITE === '1') { + writeFileSync(GOLDEN_PATH, `${JSON.stringify(snapshot, null, 2)}\n`); + } + expect(existsSync(GOLDEN_PATH)).toBe(true); + expect(snapshot).toEqual(JSON.parse(readFileSync(GOLDEN_PATH, 'utf8'))); +}); diff --git a/service/src/test-support/personal-parity.golden.json b/service/src/test-support/personal-parity.golden.json new file mode 100644 index 00000000..9ecec915 --- /dev/null +++ b/service/src/test-support/personal-parity.golden.json @@ -0,0 +1,1276 @@ +{ + "unit": { + "console_personal": { + "tokenSha256": "a8f119ff04134e976b90d11d82e00d351d16065112d12cec78eb951d975ef124", + "principal": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "role": "USER", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "authContext": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "executionIdentity": { + "userId": "65f0c0ffee0000000000abcd", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "storageNamespace": "tenant-parity", + "tenantId": "tenant-parity", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "planId": null, + "sessionKeys": { + "user": "tenant-parity:user:65f0c0ffee0000000000abcd", + "agentRun": "tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "agentPublic": "tenant-parity:agent:agent_AbCdEfGhIjK", + "skill": "tenant-parity:skill:65f0c0ffee0000000000beef:v:3", + "outputBucket": "tenant-parity:user:65f0c0ffee0000000000abcd" + }, + "rateLimitKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "runtimeSessionId": "rt_4fa5a133a9e76a2a52b6c3a1b10555fc021bbc91", + "manifest": { + "v": 1, + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "tool_call_socket": false, + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "grant": { + "grant_id": "", + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "replayState": { + "execution_id": "exec_parity_0000000001", + "session_id": "", + "sessionKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "apiKeyId": "", + "startTime": 1790000000000, + "lastActivity": 1790000000000, + "mode": "replay", + "userCode": "print(1)", + "tools": [], + "isPyPlot": false, + "timeout": 1000, + "callCount": 0, + "language": "python" + }, + "continuationSameUser": { + "ok": true + }, + "": { + "ok": false, + "status": 403, + "error": "Forbidden" + } + }, + "with_plan_and_org": { + "tokenSha256": "5b077067ef2c2e88b96eabf1a53933a62290e28157f8b72fea17d436be6c4850", + "principal": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "role": "USER", + "orgId": "org_1", + "serviceId": "svc_1", + "externalUserId": "ext_1", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "planId": "pro" + }, + "authContext": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "orgId": "org_1", + "serviceId": "svc_1", + "externalUserId": "ext_1", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "executionIdentity": { + "userId": "65f0c0ffee0000000000abcd", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "storageNamespace": "tenant-parity", + "tenantId": "tenant-parity", + "orgId": "org_1", + "serviceId": "svc_1", + "externalUserId": "ext_1", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "planId": "pro" + }, + "planId": "pro", + "sessionKeys": { + "user": "tenant-parity:user:65f0c0ffee0000000000abcd", + "agentRun": "tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "agentPublic": "tenant-parity:agent:agent_AbCdEfGhIjK", + "skill": "tenant-parity:skill:65f0c0ffee0000000000beef:v:3", + "outputBucket": "tenant-parity:user:65f0c0ffee0000000000abcd" + }, + "rateLimitKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "runtimeSessionId": "rt_4fa5a133a9e76a2a52b6c3a1b10555fc021bbc91", + "manifest": { + "v": 1, + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "tool_call_socket": false, + "external_user_id": "ext_1", + "org_id": "org_1", + "service_id": "svc_1", + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "grant": { + "grant_id": "", + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "external_user_id": "ext_1", + "org_id": "org_1", + "service_id": "svc_1", + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "replayState": { + "execution_id": "exec_parity_0000000001", + "session_id": "", + "sessionKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "orgId": "org_1", + "serviceId": "svc_1", + "externalUserId": "ext_1", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "apiKeyId": "", + "startTime": 1790000000000, + "lastActivity": 1790000000000, + "mode": "replay", + "userCode": "print(1)", + "tools": [], + "isPyPlot": false, + "timeout": 1000, + "callCount": 0, + "language": "python" + }, + "continuationSameUser": { + "ok": true + }, + "": { + "ok": false, + "status": 403, + "error": "Forbidden" + } + }, + "legacy_chc_alias": { + "tokenSha256": "9da35cdc028322ccc8473de703be6e47b434da48af558ed4ca3dcac313424879", + "principal": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "role": "USER", + "externalUserId": "chc_legacy_1", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "authContext": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "externalUserId": "chc_legacy_1", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "executionIdentity": { + "userId": "65f0c0ffee0000000000abcd", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "storageNamespace": "tenant-parity", + "tenantId": "tenant-parity", + "externalUserId": "chc_legacy_1", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "planId": null, + "sessionKeys": { + "user": "tenant-parity:user:65f0c0ffee0000000000abcd", + "agentRun": "tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "agentPublic": "tenant-parity:agent:agent_AbCdEfGhIjK", + "skill": "tenant-parity:skill:65f0c0ffee0000000000beef:v:3", + "outputBucket": "tenant-parity:user:65f0c0ffee0000000000abcd" + }, + "rateLimitKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "runtimeSessionId": "rt_4fa5a133a9e76a2a52b6c3a1b10555fc021bbc91", + "manifest": { + "v": 1, + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "tool_call_socket": false, + "external_user_id": "chc_legacy_1", + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "grant": { + "grant_id": "", + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "external_user_id": "chc_legacy_1", + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "replayState": { + "execution_id": "exec_parity_0000000001", + "session_id": "", + "sessionKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "externalUserId": "chc_legacy_1", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "apiKeyId": "", + "startTime": 1790000000000, + "lastActivity": 1790000000000, + "mode": "replay", + "userCode": "print(1)", + "tools": [], + "isPyPlot": false, + "timeout": 1000, + "callCount": 0, + "language": "python" + }, + "continuationSameUser": { + "ok": true + }, + "": { + "ok": false, + "status": 403, + "error": "Forbidden" + } + }, + "openid_reuse": { + "tokenSha256": "b19a74709c62b6e4389c2fcdbb34a281e7272979093fe4120820e1b97e9a6af9", + "principal": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "role": "USER", + "principalSource": "openid_reuse", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "authContext": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "principalSource": "openid_reuse", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "executionIdentity": { + "userId": "65f0c0ffee0000000000abcd", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "storageNamespace": "tenant-parity", + "tenantId": "tenant-parity", + "principalSource": "openid_reuse", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "planId": null, + "sessionKeys": { + "user": "tenant-parity:user:65f0c0ffee0000000000abcd", + "agentRun": "tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "agentPublic": "tenant-parity:agent:agent_AbCdEfGhIjK", + "skill": "tenant-parity:skill:65f0c0ffee0000000000beef:v:3", + "outputBucket": "tenant-parity:user:65f0c0ffee0000000000abcd" + }, + "rateLimitKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "runtimeSessionId": "rt_4fa5a133a9e76a2a52b6c3a1b10555fc021bbc91", + "manifest": { + "v": 1, + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "tool_call_socket": false, + "principal_source": "openid_reuse", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "grant": { + "grant_id": "", + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "principal_source": "openid_reuse", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "replayState": { + "execution_id": "exec_parity_0000000001", + "session_id": "", + "sessionKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "principalSource": "openid_reuse", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "apiKeyId": "", + "startTime": 1790000000000, + "lastActivity": 1790000000000, + "mode": "replay", + "userCode": "print(1)", + "tools": [], + "isPyPlot": false, + "timeout": 1000, + "callCount": 0, + "language": "python" + }, + "continuationSameUser": { + "ok": true + }, + "": { + "ok": false, + "status": 403, + "error": "Forbidden" + } + }, + "aud_array": { + "tokenSha256": "6b64094ac90df282fe7a4205e0a11e77d16330f12c5f0a8b4a3a42a4e895e4da", + "principal": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "role": "USER", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "authContext": { + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "executionIdentity": { + "userId": "65f0c0ffee0000000000abcd", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "storageNamespace": "tenant-parity", + "tenantId": "tenant-parity", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "planId": null, + "sessionKeys": { + "user": "tenant-parity:user:65f0c0ffee0000000000abcd", + "agentRun": "tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "agentPublic": "tenant-parity:agent:agent_AbCdEfGhIjK", + "skill": "tenant-parity:skill:65f0c0ffee0000000000beef:v:3", + "outputBucket": "tenant-parity:user:65f0c0ffee0000000000abcd" + }, + "rateLimitKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "runtimeSessionId": "rt_4fa5a133a9e76a2a52b6c3a1b10555fc021bbc91", + "manifest": { + "v": 1, + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "tool_call_socket": false, + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "grant": { + "grant_id": "", + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "replayState": { + "execution_id": "exec_parity_0000000001", + "session_id": "", + "sessionKey": "tenant-parity:user:65f0c0ffee0000000000abcd", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "apiKeyId": "", + "startTime": 1790000000000, + "lastActivity": 1790000000000, + "mode": "replay", + "userCode": "print(1)", + "tools": [], + "isPyPlot": false, + "timeout": 1000, + "callCount": 0, + "language": "python" + }, + "continuationSameUser": { + "ok": true + }, + "": { + "ok": false, + "status": 403, + "error": "Forbidden" + } + }, + "control_agent_looking_sub_with_run_id": { + "tokenSha256": "cc926025f2bdef447e72495add2f50e4acb5046cd0b2c7a4a252846d6866680e", + "principal": { + "userId": "65f0c0ffee0000000000f00d", + "tenantId": "tenant-parity", + "role": "AGENT", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "authContext": { + "userId": "65f0c0ffee0000000000f00d", + "tenantId": "tenant-parity", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "executionIdentity": { + "userId": "65f0c0ffee0000000000f00d", + "canonicalUserId": "65f0c0ffee0000000000f00d", + "storageNamespace": "tenant-parity", + "tenantId": "tenant-parity", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "planId": null, + "sessionKeys": { + "user": "tenant-parity:user:65f0c0ffee0000000000f00d", + "agentRun": "tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "agentPublic": "tenant-parity:agent:agent_AbCdEfGhIjK", + "skill": "tenant-parity:skill:65f0c0ffee0000000000beef:v:3", + "outputBucket": "tenant-parity:user:65f0c0ffee0000000000f00d" + }, + "rateLimitKey": "tenant-parity:user:65f0c0ffee0000000000f00d", + "runtimeSessionId": "rt_0c7c040bf7dc346b5533a738be6f9372dfcfc686", + "manifest": { + "v": 1, + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000f00d", + "session_key": "tenant-parity:user:65f0c0ffee0000000000f00d", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "tool_call_socket": false, + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "grant": { + "grant_id": "", + "exec_id": "exec_parity_0000000001", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000f00d", + "session_key": "tenant-parity:user:65f0c0ffee0000000000f00d", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "in.csv" + } + ], + "read_sessions": [ + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "iat": 1790000000, + "exp": 1790000360, + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + }, + "replayState": { + "execution_id": "exec_parity_0000000001", + "session_id": "", + "sessionKey": "tenant-parity:user:65f0c0ffee0000000000f00d", + "userId": "65f0c0ffee0000000000f00d", + "tenantId": "tenant-parity", + "canonicalUserId": "65f0c0ffee0000000000f00d", + "principalSource": "librechat_jwt", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "apiKeyId": "", + "startTime": 1790000000000, + "lastActivity": 1790000000000, + "mode": "replay", + "userCode": "print(1)", + "tools": [], + "isPyPlot": false, + "timeout": 1000, + "callCount": 0, + "language": "python" + }, + "continuationSameUser": { + "ok": true + }, + "": { + "ok": false, + "status": 403, + "error": "Forbidden" + } + } + }, + "routes": { + "steps": [ + { + "name": "upload user", + "status": 200, + "body": { + "message": "success", + "storage_session_id": "", + "files": [ + { + "filename": "notes.txt", + "fileId": "" + } + ] + } + }, + { + "name": "upload/batch agent", + "status": 200, + "body": { + "message": "success", + "storage_session_id": "", + "files": [ + { + "status": "success", + "filename": "a.txt", + "fileId": "" + }, + { + "status": "success", + "filename": "dir/b.txt", + "fileId": "" + } + ], + "succeeded": 2, + "failed": 0 + } + }, + { + "name": "upload/batch skill", + "status": 200, + "body": { + "message": "success", + "storage_session_id": "", + "files": [ + { + "status": "success", + "filename": "SKILL.md", + "fileId": "" + } + ], + "succeeded": 1, + "failed": 0 + } + }, + { + "name": "metadata user", + "status": 200, + "body": { + "name": "/", + "size": 5 + } + }, + { + "name": "download agent", + "status": 200, + "body": "aa" + }, + { + "name": "files list user", + "status": 200, + "body": [ + "/" + ] + }, + { + "name": "download wrong kind", + "status": 403, + "body": { + "error": "Unauthorized" + } + }, + { + "name": "download other user", + "status": 403, + "body": { + "error": "Unauthorized" + } + }, + { + "name": "exec", + "status": 200, + "body": { + "session_id": "", + "files": [], + "stdout": "ok\n", + "stderr": "" + } + }, + { + "name": "exec foreign file", + "status": 403, + "body": { + "error": "Unauthorized file reference" + } + }, + { + "name": "delete user", + "status": 200, + "body": { + "message": "File deleted successfully", + "session_id": "", + "fileId": "" + } + } + ], + "redisWrites": [ + { + "op": "set", + "key": "session:", + "value": "tenant-parity:user:65f0c0ffee0000000000abcd", + "ttl": 86400 + }, + { + "op": "set", + "key": "upload:tenant-parity:user:65f0c0ffee0000000000abcd", + "value": "true", + "ttl": 86400 + }, + { + "op": "set", + "key": "session:", + "value": "tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "ttl": 86400 + }, + { + "op": "set", + "key": "upload:tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "value": "true", + "ttl": 86400 + }, + { + "op": "set", + "key": "upload:tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "value": "true", + "ttl": 86400 + }, + { + "op": "set", + "key": "session:", + "value": "tenant-parity:skill:65f0c0ffee0000000000beef:v:3", + "ttl": 86400 + }, + { + "op": "set", + "key": "upload:tenant-parity:skill:65f0c0ffee0000000000beef:v:3", + "value": "true", + "ttl": 86400 + }, + { + "op": "set", + "key": "session:", + "value": "tenant-parity:user:65f0c0ffee0000000000abcd", + "ttl": 86400 + }, + { + "op": "del", + "key": "upload:tenant-parity:user:65f0c0ffee0000000000abcd" + } + ], + "puts": [ + { + "url": "/sessions//objects/", + "bytes": 5, + "internalTokenPresent": true, + "headers": { + "accept": "application/json, text/plain, */*", + "content-type": "text/plain", + "x-original-filename": "notes.txt", + "content-length": "5", + "user-agent": "axios/1.13.2", + "accept-encoding": "gzip, compress, deflate, br", + "connection": "keep-alive" + } + }, + { + "url": "/sessions//objects/", + "bytes": 2, + "internalTokenPresent": true, + "headers": { + "accept": "application/json, text/plain, */*", + "content-type": "text/plain", + "x-original-filename": "a.txt", + "content-length": "2", + "user-agent": "axios/1.13.2", + "accept-encoding": "gzip, compress, deflate, br", + "connection": "keep-alive" + } + }, + { + "url": "/sessions//objects/", + "bytes": 3, + "internalTokenPresent": true, + "headers": { + "accept": "application/json, text/plain, */*", + "content-type": "text/plain", + "x-original-filename": "dir%2Fb.txt", + "content-length": "3", + "user-agent": "axios/1.13.2", + "accept-encoding": "gzip, compress, deflate, br", + "connection": "keep-alive" + } + }, + { + "url": "/sessions//objects/", + "bytes": 7, + "internalTokenPresent": true, + "headers": { + "accept": "application/json, text/plain, */*", + "content-type": "text/plain", + "x-original-filename": "SKILL.md", + "x-read-only": "true", + "content-length": "7", + "user-agent": "axios/1.13.2", + "accept-encoding": "gzip, compress, deflate, br", + "connection": "keep-alive" + } + } + ], + "jobs": [ + { + "code": "print(1)", + "userId": "65f0c0ffee0000000000abcd", + "payload": { + "run_memory_limit": 268435456, + "language": "python", + "version": "3.14.4", + "files": [ + { + "name": "main.py", + "content": "print(1)" + }, + { + "id": "", + "storage_session_id": "", + "name": "notes.txt" + }, + { + "id": "", + "storage_session_id": "", + "name": "a.txt" + } + ], + "session_id": "" + }, + "apiKeyId": "", + "isSynthetic": false, + "isPyPlot": false, + "principalSource": "librechat_jwt", + "executionId": "", + "tenantId": "tenant-parity", + "canonicalUserId": "65f0c0ffee0000000000abcd", + "executionProfile": "default", + "runtimeSessionId": "rt_4fa5a133a9e76a2a52b6c3a1b10555fc021bbc91", + "runtimeSessionMode": "affinity", + "_otel": {}, + "egressGrantClaims": { + "v": 1, + "exec_id": "", + "tenant_id": "tenant-parity", + "user_id": "65f0c0ffee0000000000abcd", + "session_key": "tenant-parity:user:65f0c0ffee0000000000abcd", + "input_files": [ + { + "id": "", + "session_id": "", + "name": "notes.txt" + }, + { + "id": "", + "session_id": "", + "name": "a.txt" + } + ], + "read_sessions": [ + "", + "" + ], + "output_session_id": "", + "max_upload_bytes": 26214400, + "max_output_files": 50, + "max_requests": 1000, + "tool_call_socket": false, + "principal_source": "librechat_jwt", + "auth_context_hash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de" + } + } + ], + "logs": [ + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "POST", + "path": "/v1/upload", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "info", + "message": "[] Upload: Session ID: | User ID: 65f0c0ffee0000000000abcd | Session key: tenant-parity:user:65f0c0ffee0000000000abcd" + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "POST", + "path": "/v1/upload/batch", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "info", + "message": "[] Batch upload: Session ID: | User ID: 65f0c0ffee0000000000abcd | Session key: tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f" + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "POST", + "path": "/v1/upload/batch", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "info", + "message": "[] Batch upload: Session ID: | User ID: 65f0c0ffee0000000000abcd | Session key: tenant-parity:skill:65f0c0ffee0000000000beef:v:3" + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "GET", + "path": "/v1/sessions//objects/?kind=user", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "GET", + "path": "/v1/download//?kind=agent&id=0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "GET", + "path": "/v1/files/?kind=user", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "GET", + "path": "/v1/download//?kind=agent&id=0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "error", + "message": "Unauthorized download: Cached session key: tenant-parity:user:65f0c0ffee0000000000abcd | Expected session key: tenant-parity:agent:0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e0f | Session ID: | File ID: " + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "GET", + "path": "/v1/download//?kind=user", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000dddd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "error", + "message": "Unauthorized download: Cached session key: tenant-parity:user:65f0c0ffee0000000000abcd | Expected session key: tenant-parity:user:65f0c0ffee0000000000dddd | Session ID: | File ID: " + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "POST", + "path": "/v1/exec", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "info", + "message": "Request received", + "meta": { + "userId": "65f0c0ffee0000000000abcd", + "apiKeyId": "", + "session_id": "", + "language": "py", + "files": { + "count": 2, + "skillCount": 0, + "agentCount": 1, + "userCount": 1 + }, + "sessionKey": "tenant-parity:user:65f0c0ffee0000000000abcd" + } + }, + { + "level": "info", + "message": "Execution completed", + "meta": { + "session_id": "" + } + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "POST", + "path": "/v1/exec", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000dddd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "warn", + "message": "File reference authorization rejected", + "meta": { + "status": 403, + "reason": "session_key_mismatch", + "message": "Unauthorized file reference", + "requestUserId": "65f0c0ffee0000000000dddd", + "requestApiKeyId": "", + "tenantId": "tenant-parity", + "file": { + "id": "", + "resource_id": "65f0c0ffee0000000000abcd", + "storage_session_id": "", + "name": "notes.txt", + "kind": "user" + }, + "resolvedSessionKey": "tenant-parity:user:65f0c0ffee0000000000dddd", + "cachedSessionKey": "tenant-parity:user:65f0c0ffee0000000000abcd" + } + }, + { + "level": "debug", + "message": "CodeAPI request authenticated", + "meta": { + "method": "DELETE", + "path": "/v1/files//?kind=user", + "ip": "127.0.0.1", + "authProvider": "librechat-jwt", + "hasBearerToken": true, + "hasApiKeyHeader": false, + "hasSyntheticToken": false, + "principalSource": "librechat_jwt", + "userId": "65f0c0ffee0000000000abcd", + "tenantId": "tenant-parity", + "authContextHash": "b1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0deb1f1c0de", + "mode": "librechat-jwt" + } + }, + { + "level": "info", + "message": "[] File deleted: Session ID: | File ID: " + } + ] + } +} diff --git a/service/src/test-support/route-harness.ts b/service/src/test-support/route-harness.ts new file mode 100644 index 00000000..5e0bbba9 --- /dev/null +++ b/service/src/test-support/route-harness.ts @@ -0,0 +1,360 @@ +/** + * In-process harness for route-level auth tests: the real `apiKeyAuth` and + * service router behind an Express app, with an in-memory stand-in for + * Redis/BullMQ and a stub file server on a loopback port. No Redis, MinIO or + * sandbox is needed, so the suites stay CI-safe. + * + * `installRouteHarness()` must run before anything imports `../queue`: it + * registers the queue mock and only then loads the routers. That ordering is + * why the router modules are imported dynamically here. + */ +import { mock, spyOn } from 'bun:test'; +import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign } from 'crypto'; +import { createServer } from 'http'; +import express from 'express'; +import type { KeyObject } from 'crypto'; +import type { IncomingMessage, Server } from 'http'; +import type { AddressInfo } from 'net'; + +type StoreEntry = { value: string; ttl?: number }; + +export type CapturedLog = { level: string; message: string; meta?: unknown }; +export type CapturedPut = { url: string; headers: Record; bytes: number }; +export type CapturedFileServerCall = { method: string; url: string; headers: Record }; +export type DeleteHandler = (req: IncomingMessage, key: string) => { status: number; body: unknown }; + +/** Fixed Ed25519 seed so token bytes are reproducible across runs and trees. */ +const SIGNING_SEED = Buffer.alloc(32, 7); +const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); + +export const TEST_KID = 'parity-kid'; +export const INTERNAL_TOKEN = 'internal-service-token-for-route-harness-0001'; +export const EGRESS_SECRET = 'egress-grant-secret-for-route-harness-000000001'; + +export function testSigningKey(seed: Buffer = SIGNING_SEED): KeyObject { + return createPrivateKey({ + key: Buffer.concat([PKCS8_ED25519_PREFIX, seed]), + format: 'der', + type: 'pkcs8', + }); +} + +export function jwksFor(entries: Array<{ kid: string; key: KeyObject; tenants?: string[] }>): string { + return JSON.stringify({ + keys: entries.map(entry => { + const jwk = createPublicKey(entry.key).export({ format: 'jwk' }); + return { + kty: jwk.kty, + crv: jwk.crv, + x: jwk.x, + kid: entry.kid, + alg: 'EdDSA', + ...(entry.tenants ? { tenants: entry.tenants } : {}), + }; + }), + }); +} + +export function signTestJwt( + claims: Record, + key: KeyObject = testSigningKey(), + kid = TEST_KID, +): string { + const header = { alg: 'EdDSA', typ: 'JWT', kid }; + const signingInput = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from( + JSON.stringify(claims), + ).toString('base64url')}`; + return `${signingInput}.${cryptoSign(null, Buffer.from(signingInput), key).toString('base64url')}`; +} + +export function configureJwtEnv(jwksJson: string): void { + process.env.CODEAPI_AUTH_PROVIDER = 'librechat-jwt'; + process.env.CODEAPI_JWT_ISSUER = 'librechat'; + process.env.CODEAPI_JWT_AUDIENCE = 'codeapi'; + process.env.CODEAPI_JWT_ALLOWED_ALGS = 'EdDSA'; + process.env.CODEAPI_JWT_CLOCK_SKEW_SECONDS = '30'; + process.env.CODEAPI_JWT_MAX_TTL_SECONDS = '300'; + process.env.CODEAPI_JWT_KEY_CACHE_TTL_SECONDS = '0'; + process.env.CODEAPI_JWT_JWKS_JSON = jwksJson; + delete process.env.CODEAPI_JWT_PUBLIC_KEYS_DIR; + delete process.env.CODEAPI_JWT_PUBLIC_KEY; + delete process.env.CODEAPI_JWT_HS256_SECRET; +} + +export class FakeRedis { + readonly entries = new Map(); + readonly writes: Array<{ op: string; key: string; value?: string; ttl?: number }> = []; + + async get(key: string): Promise { + return this.entries.get(key)?.value ?? null; + } + + async set(key: string, value: string, ...args: (string | number)[]): Promise<'OK'> { + const exIndex = args.findIndex(arg => String(arg).toUpperCase() === 'EX'); + const ttl = exIndex >= 0 ? Number(args[exIndex + 1]) : undefined; + this.entries.set(key, { value, ttl }); + this.writes.push({ op: 'set', key, value, ttl }); + return 'OK'; + } + + async exists(...keys: string[]): Promise { + return keys.filter(key => this.entries.has(key)).length; + } + + async del(...keys: string[]): Promise { + let removed = 0; + for (const key of keys) { + this.writes.push({ op: 'del', key }); + if (this.entries.delete(key)) removed++; + } + return removed; + } + + async ping(): Promise { + return 'PONG'; + } + + /** replay-state registers Lua helpers at import; the harness never runs them. */ + defineCommand(): void {} + + /** Enough of the rate-limit-redis protocol for an always-allowing limiter. */ + async call(command: string, ...args: (string | number | Buffer)[]): Promise { + switch (command.toUpperCase()) { + case 'SCRIPT': + return createHash('sha1').update(String(args[1])).digest('hex'); + case 'EVALSHA': + return args.length === 3 ? [false, -2] : [1, Number(args[4] ?? 60_000)]; + case 'DECR': + case 'DEL': + return 0; + default: + throw new Error(`Unsupported Redis command in harness: ${command}`); + } + } +} + +export type ExecResultFactory = (jobData: Record) => Record; + +export interface RouteHarness { + baseUrl: string; + redis: FakeRedis; + jobs: Array>; + logs: CapturedLog[]; + puts: CapturedPut[]; + fileServerCalls: CapturedFileServerCall[]; + objects: Map }>; + setDeleteHandler(handler: DeleteHandler | undefined): void; + close(): Promise; +} + +function headerRecord(req: IncomingMessage): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(req.headers)) { + if (value === undefined) continue; + out[name] = Array.isArray(value) ? value.join(',') : value; + } + return out; +} + +async function listen(server: Server): Promise { + const { promise, resolve } = Promise.withResolvers(); + server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)); + return promise; +} + +async function closeServer(server: Server): Promise { + const { promise, resolve } = Promise.withResolvers(); + server.close(() => resolve()); + server.closeAllConnections(); + return promise; +} + +export async function installRouteHarness(options: { + /** Absolute path of `service/src/queue.ts` in the tree under test. */ + queueModulePath: string; + /** Absolute path of `service/src` in the tree under test. */ + srcDir: string; + jwksJson: string; +}): Promise { + const redis = new FakeRedis(); + const jobs: Array> = []; + const makeQueue = (name: string) => ({ + name, + async add(_jobName: string, data: Record, opts?: { jobId?: string }) { + jobs.push(data); + const payload = data.payload as { session_id?: string } | undefined; + return { + id: opts?.jobId ?? `job-${jobs.length}`, + async remove() {}, + async waitUntilFinished() { + return { session_id: payload?.session_id ?? '', files: [], stdout: 'ok\n', stderr: '' }; + }, + }; + }, + }); + mock.module(options.queueModulePath, () => ({ + connection: redis, + pyQueue: makeQueue('python'), + otherQueue: makeQueue('other'), + pyQueueEvents: {}, + otherQueueEvents: {}, + queueNames: { python: 'python', other: 'other' }, + })); + + configureJwtEnv(options.jwksJson); + process.env.CODEAPI_INTERNAL_SERVICE_TOKEN = INTERNAL_TOKEN; + + /* Stub file server: records every forwarded call, keeps the bytes and sets + * the `upload:` marker the real file server writes. */ + const puts: CapturedPut[] = []; + const fileServerCalls: CapturedFileServerCall[] = []; + const objects = new Map }>(); + let deleteHandler: DeleteHandler | undefined; + const fileServer = createServer(async (req, res) => { + const url = req.url ?? ''; + const headers = headerRecord(req); + fileServerCalls.push({ method: req.method ?? '', url, headers }); + const send = (status: number, body: unknown): void => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + const match = url.match(/^\/sessions\/([^/?]+)\/objects(?:\/([^/?]+))?(\/metadata)?/); + if (!match) return send(404, { error: 'not found' }); + const [, sessionId, fileId, metadata] = match; + const key = `${sessionId}/${fileId ?? ''}`; + if (req.method === 'PUT' && fileId) { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const bytes = Buffer.concat(chunks); + puts.push({ url, headers, bytes: bytes.length }); + objects.set(key, { bytes, headers }); + const sessionKey = await redis.get(`session:${sessionId}`); + await redis.set(`upload:${sessionKey}${sessionId}${fileId}`, 'true', 'EX', 86400); + return send(200, { filename: decodeURIComponent(headers['x-original-filename'] ?? ''), fileId, size: bytes.length }); + } + const object = fileId ? objects.get(key) : undefined; + if (req.method === 'GET' && fileId) { + if (!object) return send(404, { error: 'File not found' }); + if (metadata) return send(200, { name: key, size: object.bytes.length }); + res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(object.bytes.length) }); + return res.end(object.bytes); + } + if (req.method === 'GET') { + return send(200, [...objects.keys()].filter(name => name.startsWith(`${sessionId}/`))); + } + if (req.method === 'DELETE' && fileId) { + if (deleteHandler) { + const outcome = deleteHandler(req, key); + return send(outcome.status, outcome.body); + } + if (!objects.delete(key)) return send(404, { error: 'File not found' }); + return send(200, { message: 'File deleted successfully', session_id: sessionId, fileId }); + } + return send(405, { error: 'unsupported' }); + }); + const fileServerPort = await listen(fileServer); + + const { env } = await import(`${options.srcDir}/config`); + env.FILE_SERVER_URL = `http://127.0.0.1:${fileServerPort}`; + env.LOCAL_MODE = false; + env.MAX_UPLOAD_CHECKS = 1; + env.MAX_UPLOAD_WAIT = 1; + env.EGRESS_GRANT_SECRET = EGRESS_SECRET; + env.EGRESS_GATEWAY_URL = 'http://egress-gateway.invalid'; + env.RUNTIME_SESSION_MODE = 'affinity'; + + const logger = (await import(`${options.srcDir}/logger`)).default; + const logs: CapturedLog[] = []; + for (const level of ['error', 'warn', 'info', 'debug'] as const) { + spyOn(logger, level).mockImplementation(((message: unknown, meta?: unknown) => { + logs.push({ level, message: String(message), ...(meta === undefined ? {} : { meta }) }); + return logger; + }) as never); + } + + const { apiKeyAuth } = await import(`${options.srcDir}/middleware/auth`); + const serviceRouter = (await import(`${options.srcDir}/service/router`)).default; + (await import(`${options.srcDir}/lifecycle`)).setStartupComplete(); + + const app = express(); + app.use(express.json({ limit: '10mb' })); + const v1 = express.Router(); + v1.use(apiKeyAuth); + v1.use(serviceRouter); + app.use('/v1', v1); + const apiServer = createServer(app); + const apiPort = await listen(apiServer); + + return { + baseUrl: `http://127.0.0.1:${apiPort}`, + redis, + jobs, + logs, + puts, + fileServerCalls, + objects, + setDeleteHandler(handler) { + deleteHandler = handler; + }, + async close() { + await Promise.all([closeServer(apiServer), closeServer(fileServer)]); + }, + }; +} + +/** Replaces every nanoid-shaped token with a stable placeholder, in order of + * first appearance, so snapshots compare across runs and source trees. + * Tokens are collected where they stand alone and then replaced everywhere, + * including inside concatenations such as `upload:`. */ +export function normalizeIds(value: unknown): unknown { + const json = JSON.stringify(value, (_key, inner) => (inner instanceof Error ? { error: inner.message } : inner)); + const tokens = new Map(); + for (const [token] of json.matchAll(/(?`); + } + let replaced = json; + for (const [token, placeholder] of tokens) replaced = replaced.split(token).join(placeholder); + return JSON.parse(replaced); +} + +export async function uploadForm( + baseUrl: string, + token: string, + fields: Record, + files: Array<{ name: string; content: string }>, + path = '/v1/upload', + extraHeaders: Record = {}, +): Promise<{ status: number; body: unknown }> { + const form = new FormData(); + for (const [name, value] of Object.entries(fields)) form.append(name, value); + for (const file of files) form.append('file', new Blob([file.content], { type: 'text/plain' }), file.name); + const response = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, ...extraHeaders }, + body: form, + }); + return { status: response.status, body: await response.json().catch(() => null) }; +} + +export async function call( + baseUrl: string, + token: string, + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: unknown }> { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + try { + return { status: response.status, body: JSON.parse(text) }; + } catch { + return { status: response.status, body: text }; + } +} From 850dbae29d451feee49d77d6d91ca57e242d2d70 Mon Sep 17 00:00:00 2001 From: Thor Haaland Date: Sun, 4 Oct 2026 21:09:58 +0000 Subject: [PATCH 2/4] feat(auth): agent_run subject for company Agent code execution Adds principal_source 'agent_run' with its own claim grammar (24-hex Agent sub, canonical UUID run_id, required colon-free tenant whatever the strict flag, role AGENT, no borrowed human claims). The verified principal alone selects the private ':agent-run::' key; kind=agent must name the signed run, kind=skill uploads must be read_only, kind=user is refused, and outputs always land in the private key. Deletion-only tokens (file_delete) are honoured only for the signed DELETE /files/:sid/:fid?kind=agent&id=R and refused everywhere else in apiKeyAuth before dispatch; personal tokens carrying file_delete are malformed. Private objects carry an owner binding (hash of tenant, Agent, run) signed by the api at upload and by the gateway from the grant for sandbox outputs; the file server stores only a binding signed for that exact object, so after the 24 h session cache expires a deletion token can still remove its own bytes and nobody else's. Every identity consumer branches on the subject with no userId fallback: principal/auth context, getPrincipalOrReject, sessionAuth, output bucket, replay/blocking continuation equality and the replay session-key fallback, per-Agent rate-limit bucket, runtime-session scope, manifest/grant/worker/ lambda identity (agent_id/run_id, no user_id), and logs (kind plus hashed ids). Personal behaviour is unchanged and pinned by the e9ad47c7 parity fixture. --- api/src/execution-manifest-request.test.ts | 22 + api/src/execution-manifest.ts | 12 +- service/src/agent-run.test.ts | 673 ++++++++++++++++++ service/src/agent-run.ts | 129 ++++ service/src/auth/librechat-jwt.test.ts | 4 +- service/src/auth/librechat-jwt.ts | 165 ++++- service/src/auth/principal.ts | 57 +- service/src/egress-gateway.test.ts | 52 ++ service/src/egress-gateway.ts | 37 + service/src/egress-grant.ts | 31 +- service/src/execution-identity.ts | 76 +- service/src/execution-manifest-claims.ts | 38 +- service/src/execution-manifest.ts | 23 +- service/src/file-server.ts | 41 +- service/src/middleware/auth.ts | 95 ++- service/src/middleware/limits.ts | 39 +- .../src/middleware/request-error-logger.ts | 15 +- service/src/personal-parity.test.ts | 9 +- service/src/runtime-session/id.ts | 44 +- service/src/runtime-session/registry.ts | 6 +- service/src/sandbox-backend/lambda-microvm.ts | 9 +- service/src/sandbox-backend/types.ts | 3 + service/src/service/file-authorization.ts | 7 +- service/src/service/programmatic-router.ts | 242 +++++-- service/src/service/programmatic-state.ts | 45 +- service/src/service/replay-state.ts | 52 +- service/src/service/router.ts | 173 ++++- service/src/session-key.ts | 64 +- service/src/test-support/route-harness.ts | 29 + service/src/types/service.ts | 14 +- service/src/workers.ts | 5 +- 31 files changed, 1983 insertions(+), 228 deletions(-) create mode 100644 service/src/agent-run.test.ts create mode 100644 service/src/agent-run.ts diff --git a/api/src/execution-manifest-request.test.ts b/api/src/execution-manifest-request.test.ts index 4e7c92b1..dc4e701e 100644 --- a/api/src/execution-manifest-request.test.ts +++ b/api/src/execution-manifest-request.test.ts @@ -366,3 +366,25 @@ describe('execute request manifest validation', () => { }), 'malformed'); }); }); + +describe('agent_run manifest subject', () => { + const { user_id: _userId, ...withoutUser } = claims({ + principal_source: 'agent_run', + session_key: 'session:opaque', + }); + + test('accepts an agent_run manifest that carries agent_id and run_id and no user_id', () => { + const token = signExecutionManifest({ ...withoutUser, agent_id: 'agent:opaque', run_id: 'run:opaque' }, SECRET); + expect(verifyExecutionManifest(token, SECRET, { nowSeconds: 150 })).toMatchObject({ + principal_source: 'agent_run', + agent_id: 'agent:opaque', + run_id: 'run:opaque', + }); + }); + + test('refuses an ambiguous or incomplete subject', () => { + expect(() => signExecutionManifest({ ...withoutUser, agent_id: 'a', run_id: 'r', user_id: 'u' }, SECRET)).toThrow(ExecutionManifestError); + expect(() => signExecutionManifest({ ...withoutUser, agent_id: 'a' }, SECRET)).toThrow(ExecutionManifestError); + expect(() => signExecutionManifest(claims({ agent_id: 'a', run_id: 'r' }), SECRET)).toThrow(ExecutionManifestError); + }); +}); diff --git a/api/src/execution-manifest.ts b/api/src/execution-manifest.ts index 35513aa1..eee94a0f 100644 --- a/api/src/execution-manifest.ts +++ b/api/src/execution-manifest.ts @@ -61,7 +61,10 @@ export interface ExecutionManifestClaims { v: typeof EXECUTION_MANIFEST_VERSION; exec_id: string; tenant_id: string; - user_id: string; + /** Personal subject. Absent for agent_run, which carries agent_id/run_id. */ + user_id?: string; + agent_id?: string; + run_id?: string; session_key: string; input_files: ExecutionManifestInputFile[]; read_sessions: string[]; @@ -174,10 +177,15 @@ function validateClaimsShape(value: unknown): asserts value is ExecutionManifest throw new ExecutionManifestError('malformed', 'Execution manifest claims must be an object'); } + /* Exactly one subject: a user, or an agent_run (agent_id + run_id). */ + const isAgentRun = claims.principal_source === 'agent_run'; + if (isAgentRun ? claims.user_id !== undefined : claims.agent_id !== undefined || claims.run_id !== undefined) { + throw new ExecutionManifestError('malformed', 'Execution manifest subject is ambiguous'); + } const stringFields: Array = [ 'exec_id', 'tenant_id', - 'user_id', + ...(isAgentRun ? (['agent_id', 'run_id'] as const) : (['user_id'] as const)), 'session_key', 'output_session_id', ]; diff --git a/service/src/agent-run.test.ts b/service/src/agent-run.test.ts new file mode 100644 index 00000000..986bb3e7 --- /dev/null +++ b/service/src/agent-run.test.ts @@ -0,0 +1,673 @@ +/** + * agent_run subject (contract v2, Security conditions C1-C5): claim grammar, + * the private run namespace, upload kinds, the deletion-only token, the + * durable owner binding, and every identity consumer. Route cases run the + * real apiKeyAuth + service router through the in-process harness. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { randomUUID } from 'crypto'; +import { join } from 'path'; +import { + call, + installRouteHarness, + jwksFor, + signTestJwt, + testSigningKey, + uploadForm, + type CapturedLog, + type RouteHarness, +} from './test-support/route-harness'; +import { CodeApiJwtAuthError, verifyLibreChatJwt } from './auth/librechat-jwt'; +import { applyPrincipal } from './auth/principal'; +import { + OWNER_BINDING_HEADER, + agentRunSessionKey, + ownerBindingFromHeader, + ownerBindingValue, + signOwnerBinding, +} from './agent-run'; +import { keyGenerator } from './middleware/limits'; +import { deriveRuntimeSessionId } from './runtime-session/id'; +import { buildExecutionManifestClaims } from './execution-manifest-claims'; +import { openEgressGrant, prepareSandboxEgress, sealEgressGrant } from './egress-grant'; +import { continuationSubjectMatches, replaySessionKey, type ExecutionState } from './service/replay-state'; +import { buildReplayExecutionState } from './service/programmatic-state'; +import { getExecutionIdentity } from './execution-identity'; +import type * as t from './types'; + +const TENANT = 'tenant-a'; +const OTHER_TENANT = 'tenant-b'; +const AGENT = '65f0c0ffee0000000000a9e1'; +const OTHER_AGENT = '65f0c0ffee0000000000a9e2'; +const RUN1 = '0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e01'; +const RUN2 = '0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e02'; +const USER = '65f0c0ffee0000000000abcd'; +const CONTEXT_HASH = 'c0ffee11'.repeat(8); +const BOUND_KID = 'bound-kid'; +const SKILL_ID = '65f0c0ffee0000000000beef'; + +let harness: RouteHarness; +const bindings = new Map(); + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function agentClaims(overrides: Record = {}): Record { + const now = nowSeconds(); + return { + iss: 'librechat', + aud: 'codeapi', + sub: AGENT, + iat: now, + nbf: now, + exp: now + 300, + jti: randomUUID(), + tenant_id: TENANT, + role: 'AGENT', + principal_source: 'agent_run', + run_id: RUN1, + auth_context_hash: CONTEXT_HASH, + ...overrides, + }; +} + +function userClaims(overrides: Record = {}): Record { + const now = nowSeconds(); + return { + iss: 'librechat', + aud: 'codeapi', + sub: USER, + iat: now, + nbf: now, + exp: now + 300, + jti: randomUUID(), + tenant_id: TENANT, + role: 'USER', + principal_source: 'librechat_jwt', + auth_context_hash: 'b1f1c0de'.repeat(8), + ...overrides, + }; +} + +const agentToken = (overrides: Record = {}): string => signTestJwt(agentClaims(overrides)); +const userToken = (overrides: Record = {}): string => signTestJwt(userClaims(overrides)); + +function jwtReason(token: string): string { + try { + verifyLibreChatJwt(token); + return 'accepted'; + } catch (error) { + if (error instanceof CodeApiJwtAuthError) return `${error.reason}: ${error.message}`; + throw error; + } +} + +/** Every log line emitted while `fn` runs must be free of raw Agent identity. */ +async function withoutRawIdentityInLogs(fn: () => Promise): Promise { + const start = harness.logs.length; + const result = await fn(); + const emitted: CapturedLog[] = harness.logs.slice(start); + const serialized = JSON.stringify(emitted); + for (const raw of [AGENT, RUN1, RUN2, CONTEXT_HASH, `${TENANT}:`]) { + expect(serialized).not.toContain(raw); + } + return result; +} + +type UploadBody = { storage_session_id: string; files: Array<{ fileId: string }> }; + +async function agentUpload( + fields: Record, + token = agentToken(), + path = '/v1/upload', + extraHeaders: Record = {}, +): Promise<{ status: number; body: unknown }> { + return uploadForm(harness.baseUrl, token, fields, [{ name: 'in.txt', content: 'agent input' }], path, extraHeaders); +} + +function uploadedRef(result: { body: unknown }): { sid: string; fid: string } { + const body = result.body as UploadBody; + return { sid: body.storage_session_id, fid: body.files[0].fileId }; +} + +beforeAll(async () => { + harness = await installRouteHarness({ + queueModulePath: join(import.meta.dir, 'queue.ts'), + srcDir: import.meta.dir, + jwksJson: jwksFor([ + { kid: 'parity-kid', key: testSigningKey() }, + { kid: BOUND_KID, key: testSigningKey(Buffer.alloc(32, 9)), tenants: [TENANT] }, + ]), + }); + /* The stub file server applies the real file server's owner-binding rules + * through the same exported helper: only a signed binding for this exact + * object is stored; owner-bound deletion requires an exact match. */ + harness.setPutHandler((headers, sid, fid) => { + const owner = ownerBindingFromHeader(headers[OWNER_BINDING_HEADER.toLowerCase()], sid, fid); + if (!owner.ok) return { status: 400, body: { error: owner.error } }; + if (owner.binding) bindings.set(`${sid}/${fid}`, owner.binding); + return undefined; + }); + harness.setDeleteHandler((req, key) => { + if (!harness.objects.has(key)) return { status: 404, body: { error: 'File not found' } }; + const expected = req.headers['x-codeapi-owner-expect']; + if (expected !== undefined) { + const stored = bindings.get(key); + if (!stored || stored !== expected) return { status: 403, body: { error: 'Owner binding does not match' } }; + } + harness.objects.delete(key); + bindings.delete(key); + return { status: 200, body: { message: 'File deleted successfully' } }; + }); +}); + +afterAll(async () => { + await harness?.close(); +}); + +beforeEach(() => { + delete process.env.CODEAPI_TENANT_ISOLATION_STRICT; +}); + +describe('agent_run claim grammar', () => { + test('a well-formed agent_run token verifies to an Agent principal with no user', () => { + const principal = verifyLibreChatJwt(agentToken()); + expect(principal).toEqual({ + tenantId: TENANT, + role: 'AGENT', + principalSource: 'agent_run', + authContextHash: CONTEXT_HASH, + agentRun: { agentId: AGENT, runId: RUN1 }, + }); + expect('userId' in principal).toBe(false); + }); + + test('tenant_id is required for agent_run whether strict isolation is on or off', () => { + for (const strict of [undefined, 'true', 'false']) { + if (strict === undefined) delete process.env.CODEAPI_TENANT_ISOLATION_STRICT; + else process.env.CODEAPI_TENANT_ISOLATION_STRICT = strict; + expect(jwtReason(agentToken({ tenant_id: undefined }))).toBe('malformed_claims: tenant_id is required for agent_run'); + expect(jwtReason(agentToken({ tenant_id: '' }))).toBe('malformed_claims: tenant_id is required for agent_run'); + } + }); + + test('malformed agent_run values are refused, never normalized', () => { + const cases: Array<[Record, string]> = [ + [{ sub: 'agent_AbCdEfGhIjK' }, 'sub must be a canonical Agent id'], + [{ sub: AGENT.toUpperCase() }, 'sub must be a canonical Agent id'], + [{ sub: `${AGENT}0` }, 'sub must be a canonical Agent id'], + [{ run_id: undefined }, 'run_id must be a canonical UUID'], + [{ run_id: RUN1.toUpperCase() }, 'run_id must be a canonical UUID'], + [{ run_id: RUN1.replace(/-/g, '') }, 'run_id must be a canonical UUID'], + [{ run_id: `${RUN1}:x` }, 'run_id must be a canonical UUID'], + [{ tenant_id: 'tenant:a' }, 'tenant_id is not canonical'], + [{ tenant_id: ' tenant-a' }, 'tenant_id is not canonical'], + [{ role: 'USER' }, 'role must be AGENT'], + [{ role: undefined }, 'role must be AGENT'], + [{ org_id: 'org_1' }, 'org_id is not accepted for agent_run'], + [{ service_id: 'svc_1' }, 'service_id is not accepted for agent_run'], + [{ external_user_id: 'ext_1' }, 'external_user_id is not accepted for agent_run'], + [{ chc_user_id: 'chc_1' }, 'chc_user_id is not accepted for agent_run'], // leak-check:allow + [{ plan_id: 'pro' }, 'plan_id is not accepted for agent_run'], + [{ auth_context_hash: undefined }, 'auth_context_hash is required'], + [{ file_delete: { storage_session_id: 'a'.repeat(21) } }, 'file_delete must hold exactly'], + [{ file_delete: { storage_session_id: 'a'.repeat(21), file_id: 'b'.repeat(21), kind: 'agent' } }, 'file_delete must hold exactly'], + [{ file_delete: { storage_session_id: 'short', file_id: 'b'.repeat(21) } }, 'file_delete target is not a storage object id'], + [{ file_delete: 'x' }, 'file_delete must hold exactly'], + ]; + for (const [overrides, message] of cases) { + expect(jwtReason(agentToken(overrides))).toContain(message); + } + }); + + test('#18: a key bound to tenants signs agent_run only for those tenants', () => { + const boundKey = testSigningKey(Buffer.alloc(32, 9)); + expect(jwtReason(signTestJwt(agentClaims(), boundKey, BOUND_KID))).toBe('accepted'); + expect(jwtReason(signTestJwt(agentClaims({ tenant_id: OTHER_TENANT }), boundKey, BOUND_KID))).toBe( + 'tenant_not_allowed: JWT tenant is not allowed for this key', + ); + }); + + test('a personal token carrying file_delete is refused, not ignored', () => { + expect(jwtReason(userToken({ file_delete: { storage_session_id: 'a'.repeat(21), file_id: 'b'.repeat(21) } }))).toBe( + 'malformed_claims: file_delete is only accepted for agent_run', + ); + }); + + test('a personal token with an Agent-looking sub stays a personal principal', () => { + const principal = verifyLibreChatJwt(userToken({ sub: AGENT, role: 'AGENT', run_id: RUN1 })); + expect(principal.agentRun).toBeUndefined(); + expect(principal.userId).toBe(AGENT); + }); +}); + +describe('agent_run identity consumers (C5)', () => { + function agentReq(runId = RUN1, agentId = AGENT): t.AuthenticatedRequest { + const req = { ip: '127.0.0.1', headers: {}, header: () => undefined } as unknown as t.AuthenticatedRequest; + applyPrincipal(req, verifyLibreChatJwt(agentToken({ run_id: runId, sub: agentId }))); + return req; + } + + test('auth context and execution identity carry the Agent subject and no user id', () => { + const req = agentReq(); + expect(req.codeApiAuthContext).toEqual({ + tenantId: TENANT, + principalSource: 'agent_run', + authContextHash: CONTEXT_HASH, + networkPolicy: undefined, + networkPolicyDigest: undefined, + agentRun: { agentId: AGENT, runId: RUN1 }, + }); + const identity = getExecutionIdentity(req); + expect(identity.agentRun).toEqual({ agentId: AGENT, runId: RUN1 }); + expect(identity.userId).toBeUndefined(); + expect(identity.canonicalUserId).toBeUndefined(); + expect(req.planId).toBeUndefined(); + }); + + test('rate-limit bucket is stable per Agent across runs and separate from a user with the same id', () => { + const first = keyGenerator(agentReq(RUN1) as never); + expect(first).toBe(`${TENANT}:agent:${AGENT}`); + expect(keyGenerator(agentReq(RUN2) as never)).toBe(first); + expect(keyGenerator(agentReq(RUN1, OTHER_AGENT) as never)).not.toBe(first); + const userReq = { ip: '127.0.0.1', headers: {}, header: () => undefined } as unknown as t.AuthenticatedRequest; + applyPrincipal(userReq, verifyLibreChatJwt(userToken({ sub: AGENT }))); + expect(keyGenerator(userReq as never)).toBe(`${TENANT}:user:${AGENT}`); + }); + + test('runtime session scope separates runs, Agents and users whatever the hint', () => { + const run1 = deriveRuntimeSessionId({ storageNamespace: TENANT, agentRun: { agentId: AGENT, runId: RUN1 }, hint: 'conv' }); + expect(run1).toMatch(/^rt_[0-9a-f]{40}$/); + expect(deriveRuntimeSessionId({ storageNamespace: TENANT, agentRun: { agentId: AGENT, runId: RUN1 }, hint: 'conv' })).toBe(run1); + expect(deriveRuntimeSessionId({ storageNamespace: TENANT, agentRun: { agentId: AGENT, runId: RUN2 }, hint: 'conv' })).not.toBe(run1); + expect(deriveRuntimeSessionId({ storageNamespace: TENANT, agentRun: { agentId: OTHER_AGENT, runId: RUN1 }, hint: 'conv' })).not.toBe(run1); + expect(deriveRuntimeSessionId({ storageNamespace: OTHER_TENANT, agentRun: { agentId: AGENT, runId: RUN1 }, hint: 'conv' })).not.toBe(run1); + expect(deriveRuntimeSessionId({ storageNamespace: TENANT, canonicalUserId: AGENT, hint: 'conv' })).not.toBe(run1); + expect(deriveRuntimeSessionId({ storageNamespace: TENANT, canonicalUserId: `${AGENT}\u0000${RUN1}`, hint: 'conv' })).not.toBe(run1); + }); + + test('manifest and grant carry principal_source, agent_id and run_id and no user_id', () => { + const req = agentReq(); + const claims = buildExecutionManifestClaims({ + req, + executionId: 'exec_agent_000000001', + agentRun: { agentId: AGENT, runId: RUN1 }, + sessionKey: agentRunSessionKey(TENANT, { agentId: AGENT, runId: RUN1 }), + outputSessionId: 'sess_output_agent_001', + payload: { files: [], session_id: 'sess_output_agent_001' } as unknown as t.PayloadBody, + nowSeconds: 1_790_000_000, + }); + expect(claims).toMatchObject({ tenant_id: TENANT, agent_id: AGENT, run_id: RUN1, principal_source: 'agent_run' }); + expect('user_id' in claims).toBe(false); + const grant = openEgressGrant( + sealEgressGrant({ ...claims, grant_id: 'grant_agent_00000001', iat: nowSeconds(), exp: nowSeconds() + 60 }, 'x'.repeat(32)), + 'x'.repeat(32), + ); + expect(grant).toMatchObject({ agent_id: AGENT, run_id: RUN1, principal_source: 'agent_run' }); + expect(grant.user_id).toBeUndefined(); + const masked = prepareSandboxEgress({ + payload: { files: [], session_id: 'sess_output_agent_001' } as unknown as t.PayloadBody, + claims, + grantId: 'grant_agent_00000001', + secret: 'x'.repeat(32), + }).executionManifestClaims; + expect(masked.agent_id).toMatch(/^agent:/); + expect(masked.run_id).toMatch(/^run:/); + expect(masked.user_id).toBeUndefined(); + }); + + test('a grant naming both a user and an Agent, or an Agent without its run, is refused', () => { + const base = { + grant_id: 'grant_agent_00000001', + exec_id: 'exec_1', + tenant_id: TENANT, + session_key: 'k', + input_files: [], + read_sessions: [], + output_session_id: 'out', + max_upload_bytes: 1, + max_output_files: 1, + max_requests: 1, + iat: nowSeconds(), + exp: nowSeconds() + 60, + principal_source: 'agent_run', + }; + const secret = 'x'.repeat(32); + expect(() => openEgressGrant(sealEgressGrant({ ...base, agent_id: AGENT, run_id: RUN1, user_id: USER }, secret), secret)).toThrow(); + expect(() => openEgressGrant(sealEgressGrant({ ...base, agent_id: AGENT }, secret), secret)).toThrow(); + expect(() => openEgressGrant(sealEgressGrant({ ...base, user_id: USER }, secret), secret)).toThrow(); + }); + + test('continuation equality compares kind, Agent, run, tenant and context hash, never a sub string', () => { + const req = agentReq(); + const state = buildReplayExecutionState({ + executionId: 'exec_1', + sessionId: 'sess_1', + sessionKey: agentRunSessionKey(TENANT, { agentId: AGENT, runId: RUN1 }), + apiKeyId: '', + identity: getExecutionIdentity(req), + code: 'x', + tools: [], + isPyPlot: false, + timeout: 1000, + language: 'python', + }); + expect(state.userId).toBeUndefined(); + const same = { agentRun: { agentId: AGENT, runId: RUN1 }, tenantId: TENANT, authContextHash: CONTEXT_HASH }; + expect(continuationSubjectMatches(state, same)).toBe(true); + expect(continuationSubjectMatches(state, { ...same, agentRun: { agentId: AGENT, runId: RUN2 } })).toBe(false); + expect(continuationSubjectMatches(state, { ...same, agentRun: { agentId: OTHER_AGENT, runId: RUN1 } })).toBe(false); + expect(continuationSubjectMatches(state, { ...same, tenantId: OTHER_TENANT })).toBe(false); + expect(continuationSubjectMatches(state, { ...same, authContextHash: 'other' })).toBe(false); + /* A user whose id equals the Agent id, or a user with no id, never matches Agent state. */ + expect(continuationSubjectMatches(state, { userId: AGENT, tenantId: TENANT, authContextHash: CONTEXT_HASH })).toBe(false); + expect(continuationSubjectMatches(state, { tenantId: TENANT })).toBe(false); + /* And an Agent never matches personal state. */ + const personal = { ...state, agentRun: undefined, userId: AGENT, principalSource: 'librechat_jwt' } as ExecutionState; + expect(continuationSubjectMatches(personal, same)).toBe(false); + }); + + test('replay output key: agent_run state requires its persisted private key, no userId fallback', () => { + const agentState = { execution_id: 'e', session_id: 's', agentRun: { agentId: AGENT, runId: RUN1 }, userId: AGENT } as ExecutionState; + expect(() => replaySessionKey(agentState)).toThrow('no session key'); + expect(replaySessionKey({ ...agentState, sessionKey: 'k' })).toBe('k'); + expect(replaySessionKey({ execution_id: 'e', session_id: 's', userId: 'legacy-key' } as ExecutionState)).toBe('legacy-key'); + }); +}); + +describe('owner binding (C4)', () => { + test('only a binding signed for this exact object is accepted', () => { + const binding = ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN1 }); + const sid = 'a'.repeat(21); + const fid = 'b'.repeat(21); + const signed = signOwnerBinding(sid, fid, binding); + expect(ownerBindingFromHeader(signed, sid, fid)).toEqual({ ok: true, binding }); + expect(ownerBindingFromHeader(undefined, sid, fid)).toEqual({ ok: true }); + expect(ownerBindingFromHeader(signed, sid, 'c'.repeat(21)).ok).toBe(false); + expect(ownerBindingFromHeader(`${ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN2 })}.${'A'.repeat(43)}`, sid, fid).ok).toBe(false); + expect(ownerBindingFromHeader(binding, sid, fid).ok).toBe(false); + expect(ownerBindingFromHeader([signed ?? ''], sid, fid).ok).toBe(false); + }); +}); + +describe('agent_run routes', () => { + test('C1: upload kind=agent id= lands in the private run key with a server-written owner binding', async () => { + const result = await withoutRawIdentityInLogs(() => agentUpload({ kind: 'agent', id: RUN1 })); + expect(result.status).toBe(200); + const { sid, fid } = uploadedRef(result); + expect(await harness.redis.get(`session:${sid}`)).toBe(`${TENANT}:agent-run:${AGENT}:${RUN1}`); + expect(bindings.get(`${sid}/${fid}`)).toBe(ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN1 })); + }); + + test('C1: upload kind=agent with an id other than the signed run is refused', async () => { + expect((await agentUpload({ kind: 'agent', id: RUN2 })).status).toBe(403); + expect((await agentUpload({ kind: 'agent', id: AGENT })).status).toBe(403); + expect((await agentUpload({ kind: 'agent', id: RUN2 }, agentToken(), '/v1/upload/batch')).status).toBe(403); + }); + + test('C1: upload kind=user is refused for agent_run', async () => { + const putsBefore = harness.puts.length; + expect((await agentUpload({ kind: 'user' })).status).not.toBe(200); + expect((await agentUpload({ kind: 'user', id: AGENT })).status).toBe(403); + expect((await agentUpload({ kind: 'user', id: AGENT }, agentToken(), '/v1/upload/batch')).status).toBe(403); + expect(harness.puts.length).toBe(putsBefore); + }); + + test('C1: upload kind=skill needs read_only=true and stays in the shared skill key without a binding', async () => { + expect((await agentUpload({ kind: 'skill', id: SKILL_ID, version: '2' })).status).toBe(403); + expect((await agentUpload({ kind: 'skill', id: SKILL_ID, version: '2', read_only: 'false' }, agentToken(), '/v1/upload/batch')).status).toBe(403); + const ok = await agentUpload({ kind: 'skill', id: SKILL_ID, version: '2', read_only: 'true' }, agentToken(), '/v1/upload/batch'); + expect(ok.status).toBe(200); + const { sid, fid } = uploadedRef(ok); + expect(await harness.redis.get(`session:${sid}`)).toBe(`${TENANT}:skill:${SKILL_ID}:v:2`); + expect(bindings.has(`${sid}/${fid}`)).toBe(false); + /* Shared skill inputs are readable; an Agent cannot delete them. */ + const read = await call(harness.baseUrl, agentToken(), 'GET', `/v1/download/${sid}/${fid}?kind=skill&id=${SKILL_ID}&version=2`); + expect(read.status).toBe(200); + const del = await call(harness.baseUrl, agentToken(), 'DELETE', `/v1/files/${sid}/${fid}?kind=skill&id=${SKILL_ID}&version=2`); + expect(del.status).toBe(403); + }); + + test('C4: a client-supplied owner header on upload is ignored; the binding comes from the verified principal', async () => { + const forged = signOwnerBinding('x'.repeat(21), 'y'.repeat(21), ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN2 })); + const result = await agentUpload({ kind: 'agent', id: RUN1 }, agentToken(), '/v1/upload', { + [OWNER_BINDING_HEADER]: forged ?? 'forged', + }); + expect(result.status).toBe(200); + const { sid, fid } = uploadedRef(result); + expect(bindings.get(`${sid}/${fid}`)).toBe(ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN1 })); + }); + + test('the Agent reads its own run objects', async () => { + const { sid, fid } = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + await withoutRawIdentityInLogs(async () => { + expect((await call(harness.baseUrl, agentToken(), 'GET', `/v1/download/${sid}/${fid}?kind=agent&id=${RUN1}`)).body).toBe('agent input'); + expect((await call(harness.baseUrl, agentToken(), 'GET', `/v1/sessions/${sid}/objects/${fid}?kind=agent&id=${RUN1}`)).status).toBe(200); + expect((await call(harness.baseUrl, agentToken(), 'GET', `/v1/files/${sid}?kind=agent&id=${RUN1}`)).status).toBe(200); + }); + }); + + test('collision: a user token with kind=agent id= cannot touch Agent objects', async () => { + const { sid, fid } = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + for (const id of [AGENT, RUN1, `agent-run:${AGENT}:${RUN1}`]) { + const query = `kind=agent&id=${encodeURIComponent(id)}`; + for (const token of [userToken(), userToken({ sub: AGENT })]) { + expect((await call(harness.baseUrl, token, 'GET', `/v1/download/${sid}/${fid}?${query}`)).status).toBe(403); + expect((await call(harness.baseUrl, token, 'GET', `/v1/files/${sid}?${query}`)).status).toBe(403); + expect((await call(harness.baseUrl, token, 'GET', `/v1/sessions/${sid}/objects/${fid}?${query}`)).status).toBe(403); + expect((await call(harness.baseUrl, token, 'DELETE', `/v1/files/${sid}/${fid}?${query}`)).status).toBe(403); + const exec = await call(harness.baseUrl, token, 'POST', '/v1/exec', { + lang: 'py', + code: 'print(1)', + files: [{ id: fid, storage_session_id: sid, name: 'in.txt', kind: 'agent', resource_id: id }], + }); + expect(exec.status).toBe(403); + } + } + /* A user upload with kind=agent id= stays in the shared agent key. */ + const userUpload = await uploadForm(harness.baseUrl, userToken(), { kind: 'agent', id: RUN1 }, [{ name: 'u.txt', content: 'u' }]); + expect(await harness.redis.get(`session:${uploadedRef(userUpload).sid}`)).toBe(`${TENANT}:agent:${RUN1}`); + /* And the Agent cannot read that shared key either. */ + const { sid: userSid, fid: userFid } = uploadedRef(userUpload); + expect((await call(harness.baseUrl, agentToken(), 'GET', `/v1/download/${userSid}/${userFid}?kind=agent&id=${RUN1}`)).status).toBe(403); + }); + + test('the Agent cannot read a user bucket', async () => { + const userUpload = await uploadForm(harness.baseUrl, userToken({ sub: AGENT }), { kind: 'user' }, [{ name: 'u.txt', content: 'u' }]); + const { sid, fid } = uploadedRef(userUpload); + expect((await call(harness.baseUrl, agentToken(), 'GET', `/v1/download/${sid}/${fid}?kind=user`)).status).toBe(400); + expect((await call(harness.baseUrl, agentToken(), 'GET', `/v1/download/${sid}/${fid}?kind=user&id=${AGENT}`)).status).toBe(403); + expect((await call(harness.baseUrl, agentToken(), 'GET', `/v1/files/${sid}?kind=user&id=${AGENT}`)).status).toBe(403); + }); + + test('cross-run replay: a token for run R2 cannot read, execute with or delete run R1 objects', async () => { + const { sid, fid } = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + const run2 = agentToken({ run_id: RUN2 }); + expect((await call(harness.baseUrl, run2, 'GET', `/v1/download/${sid}/${fid}?kind=agent&id=${RUN1}`)).status).toBe(403); + expect((await call(harness.baseUrl, run2, 'GET', `/v1/download/${sid}/${fid}?kind=agent&id=${RUN2}`)).status).toBe(403); + expect((await call(harness.baseUrl, run2, 'DELETE', `/v1/files/${sid}/${fid}?kind=agent&id=${RUN2}`)).status).toBe(403); + const exec = await call(harness.baseUrl, run2, 'POST', '/v1/exec', { + lang: 'py', + code: 'print(1)', + files: [{ id: fid, storage_session_id: sid, name: 'in.txt', kind: 'agent', resource_id: RUN1 }], + }); + expect(exec.status).toBe(403); + /* Same Agent and run but another tenant's (bound) token. */ + const otherTenant = signTestJwt(agentClaims({ tenant_id: OTHER_TENANT }), testSigningKey(Buffer.alloc(32, 9)), BOUND_KID); + expect((await call(harness.baseUrl, otherTenant, 'GET', `/v1/download/${sid}/${fid}?kind=agent&id=${RUN1}`)).status).toBe(401); + const otherTenantUnbound = agentToken({ tenant_id: OTHER_TENANT }); + expect((await call(harness.baseUrl, otherTenantUnbound, 'GET', `/v1/download/${sid}/${fid}?kind=agent&id=${RUN1}`)).status).toBe(403); + }); + + test('exec: outputs go to the private key and every downstream identity is the Agent subject', async () => { + const { sid, fid } = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + const jobsBefore = harness.jobs.length; + const result = await withoutRawIdentityInLogs(() => call(harness.baseUrl, agentToken(), 'POST', '/v1/exec', { + lang: 'py', + code: 'print(1)', + runtime_session_hint: 'conv-1', + files: [{ id: fid, storage_session_id: sid, name: 'in.txt', kind: 'agent', resource_id: RUN1 }], + })); + expect(result.status).toBe(200); + const job = harness.jobs[jobsBefore] as t.JobData & { egressGrantClaims: Record }; + expect(job.userId).toBeUndefined(); + expect(job.canonicalUserId).toBeUndefined(); + expect(job.agentRun).toEqual({ agentId: AGENT, runId: RUN1 }); + expect(job.principalSource).toBe('agent_run'); + expect(job.runtimeSessionId).toBe( + deriveRuntimeSessionId({ storageNamespace: TENANT, agentRun: { agentId: AGENT, runId: RUN1 }, hint: 'conv-1' }), + ); + const privateKey = `${TENANT}:agent-run:${AGENT}:${RUN1}`; + expect(job.egressGrantClaims).toMatchObject({ + tenant_id: TENANT, + agent_id: AGENT, + run_id: RUN1, + principal_source: 'agent_run', + session_key: privateKey, + }); + expect('user_id' in job.egressGrantClaims).toBe(false); + const outputSession = job.payload.session_id as string; + expect(await harness.redis.get(`session:${outputSession}`)).toBe(privateKey); + + /* An output the gateway wrote (binding from the grant) is readable by the run, not by users. */ + const outputFid = 'o'.repeat(21); + const put = await fetch(`${harness.fileServerUrl}/sessions/${outputSession}/objects/${outputFid}`, { + method: 'PUT', + headers: { + 'Content-Type': 'text/plain', + 'X-Original-Filename': 'out.txt', + [OWNER_BINDING_HEADER]: signOwnerBinding(outputSession, outputFid, ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN1 })) ?? '', + }, + body: 'output', + }); + expect(put.status).toBe(200); + expect((await call(harness.baseUrl, agentToken(), 'GET', `/v1/download/${outputSession}/${outputFid}?kind=agent&id=${RUN1}`)).body).toBe('output'); + expect((await call(harness.baseUrl, userToken({ sub: AGENT }), 'GET', `/v1/download/${outputSession}/${outputFid}?kind=agent&id=${AGENT}`)).status).toBe(403); + expect((await call(harness.baseUrl, userToken(), 'GET', `/v1/download/${outputSession}/${outputFid}?kind=user`)).status).toBe(403); + }); + + test('exec with a kind=user file reference is refused for agent_run', async () => { + const exec = await call(harness.baseUrl, agentToken(), 'POST', '/v1/exec', { + lang: 'py', + code: 'print(1)', + files: [{ id: 'f'.repeat(21), storage_session_id: 's'.repeat(21), name: 'x', kind: 'user', resource_id: AGENT }], + }); + expect(exec.status).toBe(403); + }); +}); + +describe('deletion-only tokens (C3) and durable owner binding (C4)', () => { + function deletionToken(sid: string, fid: string, overrides: Record = {}): string { + return agentToken({ file_delete: { storage_session_id: sid, file_id: fid }, ...overrides }); + } + + test('every route other than the signed DELETE target is refused before dispatch', async () => { + const { sid, fid } = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + const other = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + const token = deletionToken(sid, fid); + const jobsBefore = harness.jobs.length; + const putsBefore = harness.puts.length; + const refused: Array<[string, string, unknown?]> = [ + ['POST', '/v1/exec', { lang: 'py', code: 'print(1)' }], + ['POST', '/v1/exec/programmatic', { code: 'x', tools: [{ name: 't' }] }], + ['POST', '/v1/exec/programmatic', { continuation_token: 'x', tool_results: [{ call_id: 'call_001', result: 1 }] }], + ['GET', `/v1/download/${sid}/${fid}?kind=agent&id=${RUN1}`], + ['GET', `/v1/files/${sid}?kind=agent&id=${RUN1}`], + ['GET', `/v1/sessions/${sid}/objects/${fid}?kind=agent&id=${RUN1}`], + ['GET', `/v1/files/${sid}/${fid}?kind=agent&id=${RUN1}`], + ['DELETE', `/v1/files/${other.sid}/${other.fid}?kind=agent&id=${RUN1}`], + ['DELETE', `/v1/files/${sid}/${other.fid}?kind=agent&id=${RUN1}`], + ['DELETE', `/v1/files/${sid}/${fid}?kind=agent&id=${RUN2}`], + ['DELETE', `/v1/files/${sid}/${fid}?kind=user`], + ['DELETE', `/v1/files/${sid}/${fid}?kind=skill&id=${SKILL_ID}&version=1`], + ['DELETE', `/v1/files/${sid}/${fid}?kind=agent&id=${RUN1}&version=1`], + ['DELETE', `/v1/files/${sid}/${fid}`], + ['DELETE', `/v1/files/${sid}/${fid}/?kind=agent&id=${RUN1}`], + ['DELETE', `/v1/sessions/${sid}/objects/${fid}?kind=agent&id=${RUN1}`], + ]; + for (const [method, path, body] of refused) { + const result = await call(harness.baseUrl, token, method, path, body); + expect([method, path, result.status]).toEqual([method, path, 403]); + } + const uploadCases: Array> = [ + { kind: 'agent', id: RUN1 }, + { kind: 'skill', id: SKILL_ID, version: '1', read_only: 'true' }, + ]; + for (const fields of uploadCases) { + expect((await agentUpload(fields, token)).status).toBe(403); + expect((await agentUpload(fields, token, '/v1/upload/batch')).status).toBe(403); + } + expect(harness.jobs.length).toBe(jobsBefore); + expect(harness.puts.length).toBe(putsBefore); + expect(harness.objects.has(`${sid}/${fid}`)).toBe(true); + }); + + test('the signed target is deleted while the session cache is present', async () => { + const { sid, fid } = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + const result = await withoutRawIdentityInLogs(() => + call(harness.baseUrl, deletionToken(sid, fid), 'DELETE', `/v1/files/${sid}/${fid}?kind=agent&id=${RUN1}`)); + expect(result.status).toBe(200); + expect(harness.objects.has(`${sid}/${fid}`)).toBe(false); + }); + + test('after the 24 h session cache expires the binding authorizes deletion; repeat is not-found', async () => { + const { sid, fid } = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + await harness.redis.del(`session:${sid}`); + const path = `/v1/files/${sid}/${fid}?kind=agent&id=${RUN1}`; + + /* Without file_delete there is no deletion exception. */ + expect((await call(harness.baseUrl, agentToken(), 'DELETE', path)).status).toBe(403); + /* Another run's or Agent's deletion token for the same target: binding mismatch. */ + expect((await call(harness.baseUrl, deletionToken(sid, fid, { run_id: RUN2 }), 'DELETE', `/v1/files/${sid}/${fid}?kind=agent&id=${RUN2}`)).status).toBe(403); + expect((await call(harness.baseUrl, deletionToken(sid, fid, { sub: OTHER_AGENT }), 'DELETE', path)).status).toBe(403); + expect((await call(harness.baseUrl, deletionToken(sid, fid, { tenant_id: OTHER_TENANT }), 'DELETE', path)).status).toBe(403); + expect(harness.objects.has(`${sid}/${fid}`)).toBe(true); + + const deleted = await withoutRawIdentityInLogs(() => call(harness.baseUrl, deletionToken(sid, fid), 'DELETE', path)); + expect(deleted.status).toBe(200); + expect(harness.objects.has(`${sid}/${fid}`)).toBe(false); + expect(bindings.has(`${sid}/${fid}`)).toBe(false); + const expectHeaders = harness.fileServerCalls.filter(c => c.method === 'DELETE' && c.url.endsWith(`/${sid}/objects/${fid}`)).map(c => c.headers['x-codeapi-owner-expect']); + expect(expectHeaders.at(-1)).toBe(ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN1 })); + + expect((await call(harness.baseUrl, deletionToken(sid, fid), 'DELETE', path)).status).toBe(404); + }); + + test('an object with no binding is never reported deleted through the binding path', async () => { + const sid = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })).sid; + const fid = 'u'.repeat(21); + await fetch(`${harness.fileServerUrl}/sessions/${sid}/objects/${fid}`, { + method: 'PUT', + headers: { 'Content-Type': 'text/plain', 'X-Original-Filename': 'unbound.txt' }, + body: 'unbound', + }); + await harness.redis.del(`session:${sid}`); + expect((await call(harness.baseUrl, deletionToken(sid, fid), 'DELETE', `/v1/files/${sid}/${fid}?kind=agent&id=${RUN1}`)).status).toBe(403); + expect(harness.objects.has(`${sid}/${fid}`)).toBe(true); + }); + + test('C4: a PUT with a forged owner header for another run cannot change the binding; that run cannot delete', async () => { + const { sid, fid } = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + const run2Binding = ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN2 }); + for (const forged of [`${run2Binding}.${'A'.repeat(43)}`, signOwnerBinding('z'.repeat(21), fid, run2Binding) ?? '', run2Binding]) { + const put = await fetch(`${harness.fileServerUrl}/sessions/${sid}/objects/${fid}`, { + method: 'PUT', + headers: { 'Content-Type': 'text/plain', 'X-Original-Filename': 'in.txt', [OWNER_BINDING_HEADER]: forged }, + body: 'overwrite', + }); + expect(put.status).toBe(400); + } + expect(bindings.get(`${sid}/${fid}`)).toBe(ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN1 })); + await harness.redis.del(`session:${sid}`); + expect((await call(harness.baseUrl, deletionToken(sid, fid, { run_id: RUN2 }), 'DELETE', `/v1/files/${sid}/${fid}?kind=agent&id=${RUN2}`)).status).toBe(403); + expect(harness.objects.get(`${sid}/${fid}`)?.bytes.toString()).toBe('agent input'); + }); + + test('refused agent_run tokens leave no raw identity in logs', async () => { + await withoutRawIdentityInLogs(async () => { + expect((await call(harness.baseUrl, agentToken({ run_id: 'not-a-uuid' }), 'GET', `/v1/files/${'s'.repeat(21)}?kind=agent&id=${RUN1}`)).status).toBe(401); + expect((await call(harness.baseUrl, deletionToken('s'.repeat(21), 'f'.repeat(21)), 'GET', `/v1/files/${'s'.repeat(21)}?kind=agent&id=${RUN1}`)).status).toBe(403); + }); + }); +}); diff --git a/service/src/agent-run.ts b/service/src/agent-run.ts new file mode 100644 index 00000000..648f5d6f --- /dev/null +++ b/service/src/agent-run.ts @@ -0,0 +1,129 @@ +/** + * Agent-run subject: a company Agent executing code for one run. + * + * The verified JWT (`principal_source: 'agent_run'`) is the only source of + * this identity. Its private storage key is derived here from the verified + * principal and never from a caller-selectable kind/id, so no `kind=agent` + * upload by a user token can produce it: after the namespace, `agent:` and + * `agent-run:` differ at the sixth character. + */ +import { createHash, createHmac, timingSafeEqual } from 'crypto'; +import { INTERNAL_SERVICE_TOKEN_ENV } from './internal-service-auth'; + +export const AGENT_RUN_PRINCIPAL_SOURCE = 'agent_run'; + +/** Canonical lowercase Mongo ObjectId of the Agent (never the public Agent key). */ +export const AGENT_ID_PATTERN = /^[0-9a-f]{24}$/; +/** Canonical lowercase UUID: the producing run's responseMessageId. */ +export const RUN_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +export interface AgentRunSubject { + agentId: string; + runId: string; +} + +/** The one storage object a deletion-only token may remove. */ +export interface FileDeleteTarget { + storageSessionId: string; + fileId: string; +} + +export function agentRunSessionKey(storageNamespace: string, subject: AgentRunSubject): string { + return `${storageNamespace}:agent-run:${subject.agentId}:${subject.runId}`; +} + +export function hashIdentityLabel(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +/** Log fields for an agent_run subject: kind plus hashed tenant/Agent/run, never raw ids. */ +export function agentRunLogFields(tenantId: string | undefined, subject: AgentRunSubject): Record { + return { + subjectKind: AGENT_RUN_PRINCIPAL_SOURCE, + tenantHash: tenantId ? hashIdentityLabel(tenantId) : undefined, + agentHash: hashIdentityLabel(subject.agentId), + runHash: hashIdentityLabel(subject.runId), + }; +} + +/** Private agent-run session keys embed raw Agent/run ids; log a hash instead. + * Personal keys are returned unchanged. */ +export function sessionKeyForLog(sessionKey: T): T | string { + if (typeof sessionKey === 'string' && /^[^:]+:agent-run:/.test(sessionKey)) { + return `agent-run#${hashIdentityLabel(sessionKey)}`; + } + return sessionKey; +} + +// --------------------------------------------------------------------------- +// Durable owner binding for agent-run objects (C4) +// --------------------------------------------------------------------------- + +/** Internal api/gateway → file_server header carrying a signed binding. */ +export const OWNER_BINDING_HEADER = 'X-CodeAPI-Owner-Binding'; +/** Internal api → file_server header: delete only if the stored binding equals this. */ +export const OWNER_EXPECT_HEADER = 'X-CodeAPI-Owner-Expect'; +/** MinIO user-metadata name; stored with the object, so it lives exactly as long as the bytes. */ +export const OWNER_METADATA = 'X-Amz-Meta-Codeapi-Owner'; +/** How MinIO returns `OWNER_METADATA` from statObject. */ +export const OWNER_METADATA_STAT_KEY = 'codeapi-owner'; + +/** + * Opaque binding of an object to (tenant, Agent, run). Stored as a hash, so + * object metadata listings never carry raw identity ids. + */ +export function ownerBindingValue(tenantId: string, subject: AgentRunSubject): string { + const digest = createHash('sha256') + .update(JSON.stringify([AGENT_RUN_PRINCIPAL_SOURCE, tenantId, subject.agentId, subject.runId])) + .digest('hex'); + return `agent_run.${digest}`; +} + +function ownerBindingKey(): Buffer | undefined { + const token = (process.env[INTERNAL_SERVICE_TOKEN_ENV] ?? '').trim(); + if (!token) return undefined; + return createHmac('sha256', token).update('codeapi-owner-binding:v1').digest(); +} + +function ownerBindingMac(key: Buffer, sessionId: string, fileId: string, binding: string): string { + return createHmac('sha256', key).update(`${sessionId}/${fileId}\n${binding}`).digest('base64url'); +} + +/** + * Header value the api (at upload) and the gateway (for sandbox outputs) send + * so the file server stores the binding for exactly this object. Undefined + * when internal service auth is not configured: the file server then cannot + * tell an internal caller from anyone else, so no binding is written. + */ +export function signOwnerBinding(sessionId: string, fileId: string, binding: string): string | undefined { + const key = ownerBindingKey(); + if (!key) return undefined; + return `${binding}.${ownerBindingMac(key, sessionId, fileId, binding)}`; +} + +export type OwnerBindingHeaderResult = + | { ok: true; binding?: string } + | { ok: false; error: string }; + +/** + * File-server side. Only a binding signed by an internal caller for this exact + * session/object is stored; anything else in the header is a forgery and the + * PUT is refused, so a forged header can neither set nor change a binding. + */ +export function ownerBindingFromHeader( + header: string | string[] | undefined, + sessionId: string, + fileId: string, +): OwnerBindingHeaderResult { + if (header === undefined) return { ok: true }; + const value = Array.isArray(header) ? undefined : header; + const key = ownerBindingKey(); + const match = value?.match(/^(agent_run\.[0-9a-f]{64})\.([A-Za-z0-9_-]{43})$/); + if (!key || !match) return { ok: false, error: 'Owner binding is not accepted' }; + const expected = Buffer.from(ownerBindingMac(key, sessionId, fileId, match[1])); + const actual = Buffer.from(match[2]); + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + return { ok: false, error: 'Owner binding is not accepted' }; + } + return { ok: true, binding: match[1] }; +} diff --git a/service/src/auth/librechat-jwt.test.ts b/service/src/auth/librechat-jwt.test.ts index 89747dfc..9e20a0c2 100644 --- a/service/src/auth/librechat-jwt.test.ts +++ b/service/src/auth/librechat-jwt.test.ts @@ -203,7 +203,7 @@ describe('LibreChat JWT auth provider', () => { }), ), ); - expect(principal.externalUserId).toBe('chc_legacy_456'); + expect(principal).toMatchObject({ externalUserId: 'chc_legacy_456' }); }); test('prefers external_user_id over the legacy claim when both are present', () => { @@ -215,7 +215,7 @@ describe('LibreChat JWT auth provider', () => { }), ), ); - expect(principal.externalUserId).toBe('ext_789'); + expect(principal).toMatchObject({ externalUserId: 'ext_789' }); }); test('rejects expired tokens', () => { diff --git a/service/src/auth/librechat-jwt.ts b/service/src/auth/librechat-jwt.ts index 488e8d8a..50f636ac 100644 --- a/service/src/auth/librechat-jwt.ts +++ b/service/src/auth/librechat-jwt.ts @@ -9,7 +9,14 @@ import { join, parse } from 'path'; import type { JsonWebKey, KeyObject } from 'crypto'; import type { Request } from 'express'; import type { AuthProvider } from './provider'; -import type { CodeApiPrincipal } from './principal'; +import type { AgentRunPrincipal, CodeApiPrincipal } from './principal'; +import { + AGENT_ID_PATTERN, + AGENT_RUN_PRINCIPAL_SOURCE, + RUN_ID_PATTERN, + type FileDeleteTarget, +} from '../agent-run'; +import { isValidId } from '../utils'; import { externalFetchPolicyDigest, parseExternalFetchPolicy, @@ -46,6 +53,10 @@ interface LibreChatJwtClaims { plan_id?: string; network_policy?: ExternalFetchPolicySnapshot; network_policy_digest?: string; + /** agent_run only: the producing run's UUID. */ + run_id?: unknown; + /** agent_run deletion-only tokens: the one object they may delete. */ + file_delete?: unknown; } interface PublicKeyEntry { @@ -556,26 +567,15 @@ function assertPrincipalSource(value: unknown): LibreChatPrincipalSource { ); } -function validateClaims( +/** Issuer, audience and token-window checks shared by every subject kind, + * in the order the personal path has always applied them. */ +function assertTokenWindow( claims: LibreChatJwtClaims, config: VerificationConfig, -): CodeApiPrincipal { + window: { issuer: string; jti: string; iat: number; nbf: number; exp: number }, +): void { const now = Math.floor(Date.now() / 1000); - const issuer = assertString(claims.iss, 'iss'); - const userId = assertString(claims.sub, 'sub'); - const tenantId = resolveTenantIdClaim(claims.tenant_id); - const jti = assertString(claims.jti, 'jti'); - const iat = assertNumericDate(claims.iat, 'iat'); - const nbf = assertNumericDate(claims.nbf, 'nbf'); - const exp = assertNumericDate(claims.exp, 'exp'); - const planId = optionalString(claims.plan_id, 'plan_id'); - const principalSource = assertPrincipalSource(claims.principal_source); - const authContextHash = assertString( - claims.auth_context_hash, - 'auth_context_hash', - ); - const networkPolicyBinding = validatedNetworkPolicyBinding(claims); - + const { issuer, jti, iat, nbf, exp } = window; if (jti.length > 256) { throw new CodeApiJwtAuthError('malformed_claims', 'jti is too long'); } @@ -607,6 +607,40 @@ function validateClaims( 'JWT lifetime exceeds CodeAPI maximum', ); } +} + +function validateClaims( + claims: LibreChatJwtClaims, + config: VerificationConfig, +): CodeApiPrincipal { + if (claims.principal_source === AGENT_RUN_PRINCIPAL_SOURCE) { + return validateAgentRunClaims(claims, config); + } + const issuer = assertString(claims.iss, 'iss'); + const userId = assertString(claims.sub, 'sub'); + const tenantId = resolveTenantIdClaim(claims.tenant_id); + const jti = assertString(claims.jti, 'jti'); + const iat = assertNumericDate(claims.iat, 'iat'); + const nbf = assertNumericDate(claims.nbf, 'nbf'); + const exp = assertNumericDate(claims.exp, 'exp'); + const planId = optionalString(claims.plan_id, 'plan_id'); + const principalSource = assertPrincipalSource(claims.principal_source); + const authContextHash = assertString( + claims.auth_context_hash, + 'auth_context_hash', + ); + const networkPolicyBinding = validatedNetworkPolicyBinding(claims); + + assertTokenWindow(claims, config, { issuer, jti, iat, nbf, exp }); + /* A deletion-only claim belongs to agent_run tokens alone (C3). A personal + * token carrying one is malformed, not a personal token with an ignored + * extra field. */ + if (claims.file_delete !== undefined) { + throw new CodeApiJwtAuthError( + 'malformed_claims', + 'file_delete is only accepted for agent_run', + ); + } return { userId, @@ -631,6 +665,101 @@ function validateClaims( }; } +/** Claims a human principal carries that an Agent must never borrow. */ +const HUMAN_ONLY_CLAIMS = [ + 'org_id', + 'service_id', + 'external_user_id', + 'chc_user_id', // leak-check:allow + 'plan_id', +] as const; + +function agentRunMalformed(message: string): CodeApiJwtAuthError { + return new CodeApiJwtAuthError('malformed_claims', message); +} + +/** + * agent_run grammar. Values are refused, never normalized into another + * principal: sub is the Agent's canonical lowercase 24-hex Mongo id, run_id + * the run's canonical lowercase UUID, tenant_id is required whatever + * CODEAPI_TENANT_ISOLATION_STRICT says (no single-tenant default) and has no + * colon, so `:agent-run::` stays unambiguous. + */ +function validateAgentRunClaims( + claims: LibreChatJwtClaims, + config: VerificationConfig, +): AgentRunPrincipal { + const issuer = assertString(claims.iss, 'iss'); + const agentId = assertString(claims.sub, 'sub'); + if (!AGENT_ID_PATTERN.test(agentId)) { + throw agentRunMalformed('sub must be a canonical Agent id for agent_run'); + } + if (typeof claims.tenant_id !== 'string' || claims.tenant_id === '') { + throw agentRunMalformed('tenant_id is required for agent_run'); + } + const tenantId = claims.tenant_id; + if (tenantId.trim() !== tenantId || tenantId.includes(':')) { + throw agentRunMalformed('tenant_id is not canonical for agent_run'); + } + const jti = assertString(claims.jti, 'jti'); + const iat = assertNumericDate(claims.iat, 'iat'); + const nbf = assertNumericDate(claims.nbf, 'nbf'); + const exp = assertNumericDate(claims.exp, 'exp'); + if (typeof claims.run_id !== 'string' || !RUN_ID_PATTERN.test(claims.run_id)) { + throw agentRunMalformed('run_id must be a canonical UUID for agent_run'); + } + const runId = claims.run_id; + if (claims.role !== 'AGENT') { + throw agentRunMalformed('role must be AGENT for agent_run'); + } + for (const field of HUMAN_ONLY_CLAIMS) { + if (claims[field] !== undefined) { + throw agentRunMalformed(`${field} is not accepted for agent_run`); + } + } + const authContextHash = assertString(claims.auth_context_hash, 'auth_context_hash'); + const networkPolicyBinding = validatedNetworkPolicyBinding(claims); + const fileDelete = parseFileDeleteClaim(claims.file_delete); + + assertTokenWindow(claims, config, { issuer, jti, iat, nbf, exp }); + + return { + tenantId, + role: 'AGENT', + principalSource: AGENT_RUN_PRINCIPAL_SOURCE, + authContextHash, + agentRun: { + agentId, + runId, + ...(fileDelete ? { fileDelete } : {}), + }, + ...networkPolicyBinding, + }; +} + +/** `{ storage_session_id, file_id }`, both storage ids, nothing else. */ +function parseFileDeleteClaim(value: unknown): FileDeleteTarget | undefined { + if (value === undefined) return undefined; + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) || + Object.keys(value).sort().join(',') !== 'file_id,storage_session_id' + ) { + throw agentRunMalformed('file_delete must hold exactly storage_session_id and file_id'); + } + const target = value as { storage_session_id: unknown; file_id: unknown }; + if ( + typeof target.storage_session_id !== 'string' || + typeof target.file_id !== 'string' || + !isValidId(target.storage_session_id) || + !isValidId(target.file_id) + ) { + throw agentRunMalformed('file_delete target is not a storage object id'); + } + return { storageSessionId: target.storage_session_id, fileId: target.file_id }; +} + export function validateLibreChatJwtVerifierConfig(): void { getConfig(); } diff --git a/service/src/auth/principal.ts b/service/src/auth/principal.ts index e26b9864..af0d4d5a 100644 --- a/service/src/auth/principal.ts +++ b/service/src/auth/principal.ts @@ -5,8 +5,9 @@ import { executionIdentityFromPrincipal, } from '../execution-identity'; import type { ExternalFetchPolicySnapshot } from '../external-fetch-policy'; +import type { AgentRunSubject, FileDeleteTarget } from '../agent-run'; -export type CodeApiPrincipal = { +export type UserPrincipal = { userId: string; tenantId: string; role?: string; @@ -19,14 +20,52 @@ export type CodeApiPrincipal = { planId?: string; networkPolicy?: ExternalFetchPolicySnapshot; networkPolicyDigest?: string; + agentRun?: undefined; }; +/** + * A company Agent executing one run. There is no user here: `userId` is + * absent by type, so no consumer can read the Agent as a human. + */ +export type AgentRunPrincipal = { + tenantId: string; + role: 'AGENT'; + principalSource: 'agent_run'; + authContextHash: string; + credentialId?: string; + networkPolicy?: ExternalFetchPolicySnapshot; + networkPolicyDigest?: string; + agentRun: AgentRunSubject & { + /** Present only on deletion-only tokens (C3). */ + fileDelete?: FileDeleteTarget; + }; + userId?: undefined; + planId?: undefined; +}; + +export type CodeApiPrincipal = UserPrincipal | AgentRunPrincipal; + +export function isAgentRunPrincipal(principal: CodeApiPrincipal | undefined): principal is AgentRunPrincipal { + return principal?.agentRun !== undefined; +} + export function applyPrincipal( req: t.AuthenticatedRequest, principal: CodeApiPrincipal, ): void { req.codeApiPrincipal = principal; applyExecutionIdentity(req, executionIdentityFromPrincipal(principal)); + if (principal.agentRun) { + req.codeApiAuthContext = { + tenantId: principal.tenantId, + principalSource: principal.principalSource, + authContextHash: principal.authContextHash, + networkPolicy: principal.networkPolicy, + networkPolicyDigest: principal.networkPolicyDigest, + agentRun: { agentId: principal.agentRun.agentId, runId: principal.agentRun.runId }, + }; + return; + } if (principal.planId) { req.planId = principal.planId; } @@ -50,7 +89,9 @@ export function getPrincipal( return req.codeApiPrincipal; } const ctx = req.codeApiAuthContext; - if (!ctx?.userId) { + /* Rebuilding from the auth context is a personal-only legacy path; an + * agent_run principal always arrives through applyPrincipal. */ + if (!ctx?.userId || ctx.agentRun) { return undefined; } return { @@ -66,11 +107,23 @@ export function getPrincipal( }; } +/** + * Accepts a verified agent_run principal only when every Agent field is + * present (a missing field is a refusal, never a personal default), and a + * personal principal only with a userId. + */ export function getPrincipalOrReject( req: t.AuthenticatedRequest, res: Response, ): CodeApiPrincipal | undefined { const principal = getPrincipal(req); + if (principal?.agentRun) { + if (principal.tenantId && principal.agentRun.agentId && principal.agentRun.runId) { + return principal; + } + res.status(401).json({ error: 'Agent run principal is incomplete' }); + return undefined; + } if (!principal?.userId) { res.status(401).json({ error: 'User not found' }); return undefined; diff --git a/service/src/egress-gateway.test.ts b/service/src/egress-gateway.test.ts index 51f63c86..bf06df4f 100644 --- a/service/src/egress-gateway.test.ts +++ b/service/src/egress-gateway.test.ts @@ -26,6 +26,7 @@ import { type EgressGrantClaims, } from './egress-grant'; import { INTERNAL_SERVICE_TOKEN_HEADER } from './internal-service-auth'; +import { OWNER_BINDING_HEADER, ownerBindingFromHeader, ownerBindingValue, signOwnerBinding } from './agent-run'; import { externalFetchPolicyDigest, parseExternalFetchPolicy, @@ -1270,6 +1271,57 @@ describe('egress gateway routes', () => { ); }); + test('agent_run output PUT carries the grant owner binding and drops a sandbox-supplied one', async () => { + upstreamResponse = Response.json({ id: 'abcdefghijklmnopqrstu' }, { status: 201 }); + const agentRun = { agentId: '65f0c0ffee0000000000aaaa', runId: '0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e01' }; + const otherRun = { agentId: agentRun.agentId, runId: '0b7f3c2e-9d4a-4c1b-8e2f-5a6b7c8d9e02' }; + const { user_id: _userId, ...agentClaims } = claims({ + principal_source: 'agent_run', + session_key: `tenant_abc:agent-run:${agentRun.agentId}:${agentRun.runId}`, + }); + const grant = { ...agentClaims, agent_id: agentRun.agentId, run_id: agentRun.runId } as EgressGrantClaims; + const writeSession = sessionHandle({ dir: 'write', sessionId: 'sess_output' }); + const forged = signOwnerBinding('sess_output', 'abcdefghijklmnopqrstu', ownerBindingValue('tenant_abc', otherRun)); + + const response = await gatewayFetch(`/sessions/${writeSession}/objects/abcdefghijklmnopqrstu`, { + method: 'PUT', + headers: { + ...grantHeader(grant), + 'Content-Type': 'text/plain', + 'Content-Length': '3', + 'X-Original-Filename': 'out.txt', + [OWNER_BINDING_HEADER]: forged ?? 'forged', + }, + body: 'abc', + }); + + expect(response.status).toBe(201); + const forwarded = header(upstreamCalls[0].init, OWNER_BINDING_HEADER); + expect(ownerBindingFromHeader(forwarded ?? undefined, 'sess_output', 'abcdefghijklmnopqrstu')).toEqual({ + ok: true, + binding: ownerBindingValue('tenant_abc', agentRun), + }); + expect(forwarded).not.toBe(forged); + }); + + test('personal output PUT carries no owner binding', async () => { + upstreamResponse = Response.json({ id: 'abcdefghijklmnopqrstu' }, { status: 201 }); + const writeSession = sessionHandle({ dir: 'write', sessionId: 'sess_output' }); + const response = await gatewayFetch(`/sessions/${writeSession}/objects/abcdefghijklmnopqrstu`, { + method: 'PUT', + headers: { + ...grantHeader(), + 'Content-Type': 'text/plain', + 'Content-Length': '3', + 'X-Original-Filename': 'out.txt', + [OWNER_BINDING_HEADER]: 'agent_run.forged', + }, + body: 'abc', + }); + expect(response.status).toBe(201); + expect(header(upstreamCalls[0].init, OWNER_BINDING_HEADER)).toBeFalsy(); + }); + test('rolls back upload reservations when upstream PUT throws', async () => { const redis = new RedisMock(); env.EGRESS_LEDGER_REQUIRED = true; diff --git a/service/src/egress-gateway.ts b/service/src/egress-gateway.ts index cf0fcf13..bb06487e 100644 --- a/service/src/egress-gateway.ts +++ b/service/src/egress-gateway.ts @@ -44,6 +44,12 @@ import { sealPtcCallbackToken, type EgressGrantClaims, } from './egress-grant'; +import { + AGENT_RUN_PRINCIPAL_SOURCE, + OWNER_BINDING_HEADER, + ownerBindingValue, + signOwnerBinding, +} from './agent-run'; import type { ExecutionManifestClaims } from './execution-manifest'; import { openEgressRouteHandle } from './egress-route-params'; import { @@ -260,6 +266,8 @@ type EgressAuditFields = { requestExecHash?: string; tenantHash?: string; userHash?: string; + agentHash?: string; + runHash?: string; authContextHash?: string; principalSource?: string; grantHash?: string; @@ -277,6 +285,8 @@ type ExternalFetchAuditFields = Pick< | 'execHash' | 'tenantHash' | 'userHash' + | 'agentHash' + | 'runHash' | 'grantHash' | 'destinationHost' | 'destinationHostHash' @@ -334,6 +344,23 @@ function hashLabel(value: string | undefined): string | undefined { .slice(0, 16); } +function agentRunOutputOwnerHeaders( + grant: EgressGrantClaims, + sessionId: string, + fileId: string, +): Record { + if (grant.principal_source !== AGENT_RUN_PRINCIPAL_SOURCE) return {}; + if (!grant.agent_id || !grant.run_id) { + throw new EgressGrantError('malformed', 'agent_run grant has no Agent subject'); + } + const signed = signOwnerBinding( + sessionId, + fileId, + ownerBindingValue(grant.tenant_id, { agentId: grant.agent_id, runId: grant.run_id }), + ); + return signed ? { [OWNER_BINDING_HEADER]: signed } : {}; +} + function auditFields(res: Response): EgressAuditFields { return ( (res.locals.egressAuditFields as EgressAuditFields | undefined) ?? {} @@ -367,6 +394,7 @@ function externalFetchAuditFields(res: Response): ExternalFetchAuditFields { execHash: fields.execHash, tenantHash: fields.tenantHash, userHash: fields.userHash, + ...(fields.agentHash ? { agentHash: fields.agentHash, runHash: fields.runHash } : {}), grantHash: fields.grantHash, destinationHost: fields.destinationHost, // Only for requests that never reached a validated destination: once the host passed @@ -394,6 +422,9 @@ function setGrantAudit(res: Response, grant: EgressGrantClaims): void { grantHash: hashLabel(grant.grant_id), tenantHash: hashLabel(grant.tenant_id), userHash: hashLabel(grant.user_id), + ...(grant.agent_id + ? { agentHash: hashLabel(grant.agent_id), runHash: hashLabel(grant.run_id) } + : {}), authContextHash: hashLabel(grant.auth_context_hash), ...(grant.principal_source ? { principalSource: grant.principal_source } @@ -1240,6 +1271,9 @@ app.post( execHash: hashLabel(grant.exec_id), tenantHash: hashLabel(grant.tenant_id), userHash: hashLabel(grant.user_id), + ...(grant.agent_id + ? { agentHash: hashLabel(grant.agent_id), runHash: hashLabel(grant.run_id) } + : {}), }); } return res.status(201).json({ grant_id: grantId, ...prepared }); @@ -1641,6 +1675,9 @@ app.put('/sessions/:sessionHandle/objects/:fileId', async (req, res) => { req.header('content-type') ?? 'application/octet-stream', 'Content-Length': String(contentLength), 'X-Original-Filename': originalFilename, + /* C4: an agent_run output's owner binding comes from the verified + * grant, never from a sandbox-supplied header. */ + ...agentRunOutputOwnerHeaders(grant, sessionId, fileId), }), ); const upstream = await fetch( diff --git a/service/src/egress-grant.ts b/service/src/egress-grant.ts index f5117438..d1fe1c76 100644 --- a/service/src/egress-grant.ts +++ b/service/src/egress-grant.ts @@ -46,7 +46,10 @@ export interface EgressGrantClaims { legacy_grant?: true; exec_id: string; tenant_id: string; - user_id: string; + /** Personal subject. Absent for agent_run, which carries agent_id/run_id. */ + user_id?: string; + agent_id?: string; + run_id?: string; session_key: string; input_files: ExecutionManifestInputFile[]; read_sessions: string[]; @@ -288,10 +291,22 @@ function validateGrant(value: unknown, token: string): EgressGrantClaims { assertString(claims.grant_id, 'grant_id'); } + /* Exactly one subject: a user, or an agent_run (agent_id + run_id). */ + const isAgentRun = claims.principal_source === 'agent_run'; + if ( + isAgentRun + ? claims.user_id !== undefined + : claims.agent_id !== undefined || claims.run_id !== undefined + ) { + throw new EgressGrantError( + 'malformed', + 'Egress grant subject is ambiguous', + ); + } for (const field of [ 'exec_id', 'tenant_id', - 'user_id', + ...(isAgentRun ? (['agent_id', 'run_id'] as const) : (['user_id'] as const)), 'session_key', 'output_session_id', ] as const) { @@ -434,7 +449,9 @@ export function sealEgressGrant( grant_id: claims.grant_id, exec_id: claims.exec_id, tenant_id: claims.tenant_id, - user_id: claims.user_id, + ...(claims.user_id !== undefined ? { user_id: claims.user_id } : {}), + ...(claims.agent_id !== undefined ? { agent_id: claims.agent_id } : {}), + ...(claims.run_id !== undefined ? { run_id: claims.run_id } : {}), session_key: claims.session_key, input_files: claims.input_files, read_sessions: claims.read_sessions, @@ -500,7 +517,9 @@ export function egressGrantFromExecutionClaims( grant_id: grantId, exec_id: claims.exec_id, tenant_id: claims.tenant_id, - user_id: claims.user_id, + ...(claims.user_id !== undefined ? { user_id: claims.user_id } : {}), + ...(claims.agent_id !== undefined ? { agent_id: claims.agent_id } : {}), + ...(claims.run_id !== undefined ? { run_id: claims.run_id } : {}), session_key: claims.session_key, input_files: claims.input_files, read_sessions: claims.read_sessions, @@ -652,7 +671,9 @@ export function prepareSandboxEgress(args: { const executionManifestClaims: ExecutionManifestClaims = { ...sandboxVisibleClaims, tenant_id: opaqueLabel('tenant', claims.tenant_id), - user_id: opaqueLabel('user', claims.user_id), + ...(claims.user_id !== undefined ? { user_id: opaqueLabel('user', claims.user_id) } : {}), + ...(claims.agent_id !== undefined ? { agent_id: opaqueLabel('agent', claims.agent_id) } : {}), + ...(claims.run_id !== undefined ? { run_id: opaqueLabel('run', claims.run_id) } : {}), session_key: opaqueLabel('session', claims.session_key), input_files: maskedInputFiles, read_sessions: Array.from( diff --git a/service/src/execution-identity.ts b/service/src/execution-identity.ts index 062ec028..95fb0244 100644 --- a/service/src/execution-identity.ts +++ b/service/src/execution-identity.ts @@ -1,14 +1,11 @@ -import type { CodeApiPrincipal } from './auth/principal'; +import type { CodeApiPrincipal, UserPrincipal } from './auth/principal'; +import type { AgentRunSubject } from './agent-run'; import type { AuthenticatedRequest, CodeApiAuthContext } from './types'; const DEFAULT_SINGLE_TENANT_NAMESPACE = 'legacy'; const DEFAULT_PRINCIPAL_SOURCE = 'librechat_jwt'; -export interface ExecutionIdentity { - /** Requesting user from the authenticated principal. */ - userId: string; - /** User identity to persist across replay and sandbox capability scopes. */ - canonicalUserId: string; +interface ExecutionIdentityShared { /** Core storage/rate-limit namespace. Enterprise adapters map this from tenant identity. */ storageNamespace: string; /** Back-compat alias for wire/persisted fields that still use tenant naming. */ @@ -22,10 +19,27 @@ export interface ExecutionIdentity { planId?: string; } +export interface UserExecutionIdentity extends ExecutionIdentityShared { + /** Requesting user from the authenticated principal. */ + userId: string; + /** User identity to persist across replay and sandbox capability scopes. */ + canonicalUserId: string; + agentRun?: undefined; +} + +/** A verified agent_run subject. It has no user: every consumer branches on `agentRun`. */ +export interface AgentRunExecutionIdentity extends ExecutionIdentityShared { + agentRun: AgentRunSubject; + userId?: undefined; + canonicalUserId?: undefined; +} + +export type ExecutionIdentity = UserExecutionIdentity | AgentRunExecutionIdentity; + export interface BuildExecutionIdentityArgs { userId: string; authContext?: CodeApiAuthContext; - principal?: CodeApiPrincipal; + principal?: UserPrincipal; canonicalUserId?: string; storageNamespace?: string; orgId?: string; @@ -65,7 +79,9 @@ export function resolveStorageNamespace( return options.singleTenantNamespace ?? resolveSingleTenantNamespace(); } -export function buildExecutionIdentity(args: BuildExecutionIdentityArgs): ExecutionIdentity { +/** Personal execution identity. agent_run identities come only from + * `agentRunExecutionIdentity`, never from a user id argument. */ +export function buildExecutionIdentity(args: BuildExecutionIdentityArgs): UserExecutionIdentity { const principal = args.principal; const authContext = args.authContext; const storageNamespace = args.storageNamespace @@ -94,7 +110,33 @@ export function buildExecutionIdentity(args: BuildExecutionIdentityArgs): Execut }; } +export function agentRunExecutionIdentity(args: { + tenantId: string; + agentRun: AgentRunSubject; + principalSource: string; + authContextHash?: string; + credentialId?: string; +}): AgentRunExecutionIdentity { + return { + storageNamespace: args.tenantId, + tenantId: args.tenantId, + principalSource: args.principalSource, + authContextHash: args.authContextHash, + credentialId: args.credentialId, + agentRun: { agentId: args.agentRun.agentId, runId: args.agentRun.runId }, + }; +} + export function executionIdentityFromPrincipal(principal: CodeApiPrincipal): ExecutionIdentity { + if (principal.agentRun) { + return agentRunExecutionIdentity({ + tenantId: principal.tenantId, + agentRun: principal.agentRun, + principalSource: principal.principalSource, + authContextHash: principal.authContextHash, + credentialId: principal.credentialId, + }); + } return buildExecutionIdentity({ userId: principal.userId, canonicalUserId: principal.userId, @@ -111,9 +153,25 @@ export function getExecutionIdentity( return req.executionIdentity; } const principal = req.codeApiPrincipal; + if (principal?.agentRun) { + return executionIdentityFromPrincipal(principal); + } + const authContext = req.codeApiAuthContext; + if (authContext?.agentRun) { + /* No user fallback for an Agent: a context without its tenant is refused. */ + if (!authContext.tenantId) { + throw new Error('agent_run auth context has no tenant'); + } + return agentRunExecutionIdentity({ + tenantId: authContext.tenantId, + agentRun: authContext.agentRun, + principalSource: authContext.principalSource ?? '', + authContextHash: authContext.authContextHash, + }); + } return buildExecutionIdentity({ userId: fallbackUserId, - authContext: req.codeApiAuthContext, + authContext, principal, }); } diff --git a/service/src/execution-manifest-claims.ts b/service/src/execution-manifest-claims.ts index 24536b61..17f981c5 100644 --- a/service/src/execution-manifest-claims.ts +++ b/service/src/execution-manifest-claims.ts @@ -1,6 +1,7 @@ import { env } from './config'; import type * as t from './types'; import { buildExecutionIdentity } from './execution-identity'; +import { AGENT_RUN_PRINCIPAL_SOURCE, type AgentRunSubject } from './agent-run'; import { EXECUTION_MANIFEST_VERSION, type ExecutionManifestClaims, @@ -52,7 +53,10 @@ export function collectManifestInputFiles( export function buildExecutionManifestClaims(args: { req: t.AuthenticatedRequest; executionId: string; - userId: string; + /** Personal subject. Exactly one of `userId` / `agentRun` is set. */ + userId?: string; + /** agent_run subject: the manifest carries agent_id/run_id and no user_id. */ + agentRun?: AgentRunSubject; sessionKey: string; outputSessionId: string; payload: t.PayloadBody; @@ -71,6 +75,38 @@ export function buildExecutionManifestClaims(args: { new Set(inputFiles.map(file => file.session_id)), ).sort(); const ctx = args.req.codeApiAuthContext; + if (args.agentRun) { + const tenantId = args.tenantId ?? ctx?.tenantId; + if (!tenantId) { + throw new Error('agent_run execution manifest requires a tenant'); + } + return { + v: EXECUTION_MANIFEST_VERSION, + exec_id: args.executionId, + tenant_id: tenantId, + agent_id: args.agentRun.agentId, + run_id: args.agentRun.runId, + session_key: args.sessionKey, + input_files: inputFiles, + read_sessions: readSessions, + output_session_id: args.outputSessionId, + max_upload_bytes: env.EXECUTION_MANIFEST_MAX_UPLOAD_BYTES, + max_output_files: env.EXECUTION_MANIFEST_MAX_OUTPUT_FILES, + max_requests: env.EXECUTION_MANIFEST_MAX_REQUESTS, + iat: now, + exp: now + env.EXECUTION_MANIFEST_TTL_SECONDS, + tool_call_socket: args.payload.tool_call_socket === true, + principal_source: AGENT_RUN_PRINCIPAL_SOURCE, + ...(args.authContextHash ?? ctx?.authContextHash + ? { auth_context_hash: args.authContextHash ?? ctx?.authContextHash } + : {}), + ...(ctx?.networkPolicy ? { network_policy: ctx.networkPolicy } : {}), + ...(ctx?.networkPolicyDigest ? { network_policy_digest: ctx.networkPolicyDigest } : {}), + }; + } + if (args.userId === undefined) { + throw new Error('execution manifest requires a user or an agent_run subject'); + } const identity = buildExecutionIdentity({ userId: args.userId, authContext: ctx, diff --git a/service/src/execution-manifest.ts b/service/src/execution-manifest.ts index 0bfbee06..713bf100 100644 --- a/service/src/execution-manifest.ts +++ b/service/src/execution-manifest.ts @@ -66,7 +66,11 @@ export interface ExecutionManifestClaims { v: typeof EXECUTION_MANIFEST_VERSION; exec_id: string; tenant_id: string; - user_id: string; + /** Personal subject. Absent for agent_run, which carries agent_id/run_id. */ + user_id?: string; + /** agent_run only: the Agent's Mongo id and the producing run id. */ + agent_id?: string; + run_id?: string; session_key: string; input_files: ExecutionManifestInputFile[]; read_sessions: string[]; @@ -218,10 +222,25 @@ function validateClaimsShape( ); } + /* The subject is either a user or an agent_run, never both: an agent_run + * manifest must not carry a user_id, a personal one must. */ + const isAgentRun = claims.principal_source === 'agent_run'; + if (isAgentRun && claims.user_id !== undefined) { + throw new ExecutionManifestError( + 'malformed', + 'Execution manifest user_id is not accepted for agent_run', + ); + } + if (!isAgentRun && (claims.agent_id !== undefined || claims.run_id !== undefined)) { + throw new ExecutionManifestError( + 'malformed', + 'Execution manifest agent_id/run_id require agent_run', + ); + } const stringFields: Array = [ 'exec_id', 'tenant_id', - 'user_id', + ...(isAgentRun ? (['agent_id', 'run_id'] as const) : (['user_id'] as const)), 'session_key', 'output_session_id', ]; diff --git a/service/src/file-server.ts b/service/src/file-server.ts index 4e0604de..fff6bfa7 100644 --- a/service/src/file-server.ts +++ b/service/src/file-server.ts @@ -18,6 +18,14 @@ import logger from './fileServerLogger'; import { env } from './config'; import { streamObjectToResponse } from './file-server-download'; import { redisKeepAliveOptions } from './redis-options'; +import { + OWNER_EXPECT_HEADER, + OWNER_METADATA, + OWNER_METADATA_STAT_KEY, + OWNER_BINDING_HEADER, + ownerBindingFromHeader, + sessionKeyForLog, +} from './agent-run'; const { INSTANCE_ID } = env; @@ -232,6 +240,7 @@ async function uploadFile( mimetype: string, existingFileId?: string, readOnly = false, + ownerBinding?: string, ): Promise { const fileId = existingFileId ?? nanoid(); const fileExtension = path.extname(filename); @@ -252,6 +261,12 @@ async function uploadFile( if (readOnly) { metaData['X-Amz-Meta-Read-Only'] = 'true'; } + /* Durable agent-run owner binding (C4). Only a binding an internal caller + * signed for this exact object reaches here; it is stored with the bytes + * so it outlives the session cache and disappears with the object. */ + if (ownerBinding) { + metaData[OWNER_METADATA] = ownerBinding; + } const sessionKey = await redisClient.get(`session:${session_id}`); const peeked = await peekStreamForEmpty(fileStream); @@ -279,7 +294,7 @@ async function uploadFile( await minioClient.removeObject(bucketName, objectName); throw new Error('Stored object is incomplete'); } - logger.info(`[${INSTANCE_ID}] File ID: ${fileId} | Filename: ${filename} | Session key: ${sessionKey} | Bytes: ${size}`); + logger.info(`[${INSTANCE_ID}] File ID: ${fileId} | Filename: ${filename} | Session key: ${sessionKeyForLog(sessionKey)} | Bytes: ${size}`); await redisClient.set(`upload:${sessionKey}${session_id}${fileId}`, 'true', 'EX', env.SESSION_CACHE_TTL); fileUploads.inc(); @@ -431,9 +446,18 @@ app.put('/sessions/:session_id/objects/:fileId', async (req: express.Request, re if (!decodedFilename || !mimeType) { return res.status(400).json({ error: 'Missing required headers' }); } + /* The owner binding is accepted only as an internal caller's signature for + * this session/object; a forged or replayed value refuses the write, so it + * can neither create nor change a stored binding. */ + const owner = ownerBindingFromHeader(req.headers[OWNER_BINDING_HEADER.toLowerCase()], session_id, fileId); + if (!owner.ok) { + logger.warn('Refusing object write with an invalid owner binding', { session_id, fileId }); + req.resume(); + return res.status(400).json({ error: owner.error }); + } try { - const result = await uploadFile(session_id, req, decodedFilename, mimeType, fileId, readOnly); + const result = await uploadFile(session_id, req, decodedFilename, mimeType, fileId, readOnly, owner.binding); logger.info(`[${INSTANCE_ID}] File uploaded successfully: ${result.filename}`); return res.status(200).json(result); } catch (err) { @@ -709,6 +733,19 @@ app.delete('/sessions/:session_id/objects/:fileId', async (req, res) => { }); } + /* Deletion against a durable agent-run owner binding: the stored binding + * must name the same tenant, Agent and run. An object without a binding + * has uncertain ownership and is refused, never reported as deleted. */ + const expectedOwner = req.headers[OWNER_EXPECT_HEADER.toLowerCase()]; + if (expectedOwner !== undefined) { + const stat = await minioClient.statObject(bucketName, objectName); + const storedOwner = stat.metaData?.[OWNER_METADATA_STAT_KEY]; + if (typeof expectedOwner !== 'string' || !storedOwner || storedOwner !== expectedOwner) { + logger.warn('Refusing owner-bound deletion', { session_id, fileId, bound: Boolean(storedOwner) }); + return res.status(403).json({ error: 'Owner binding does not match' }); + } + } + await minioClient.removeObject(bucketName, objectName); logger.info(`[${INSTANCE_ID}] File deleted successfully: ${objectName}`); return res.status(200).json({ diff --git a/service/src/middleware/auth.ts b/service/src/middleware/auth.ts index b402e346..5e02e4e5 100644 --- a/service/src/middleware/auth.ts +++ b/service/src/middleware/auth.ts @@ -5,8 +5,9 @@ import { isValidId } from '../utils'; import { env } from '../config'; import { resolveSessionKey, parseUploadSessionKeyInput, SessionKeyResolutionError } from '../session-key'; import { LibreChatJwtAuthProvider, CodeApiJwtAuthError } from '../auth/librechat-jwt'; -import { applyPrincipal, type CodeApiPrincipal } from '../auth/principal'; +import { applyPrincipal, type AgentRunPrincipal, type CodeApiPrincipal } from '../auth/principal'; import { applyLocalPrincipal } from '../auth/local'; +import { agentRunLogFields, ownerBindingValue, sessionKeyForLog } from '../agent-run'; import { AuthProviderConfigError, getAuthProviderMode } from '../auth/provider'; import { authenticateSyntheticRequest, @@ -30,14 +31,19 @@ const logSessionKeyResolutionError = ( context: string, ): boolean => { if (err instanceof SessionKeyResolutionError) { + const agentRun = req.codeApiAuthContext?.agentRun; logger.error(`sessionKey resolution failed (${context})`, { status: err.status, message: err.message, method: req.method, path: req.path, - requestUserId: req.codeApiAuthContext?.userId, - authContextUserId: req.codeApiAuthContext?.userId, - tenantId: req.codeApiAuthContext?.tenantId, + ...(agentRun + ? agentRunLogFields(req.codeApiAuthContext?.tenantId, agentRun) + : { + requestUserId: req.codeApiAuthContext?.userId, + authContextUserId: req.codeApiAuthContext?.userId, + tenantId: req.codeApiAuthContext?.tenantId, + }), }); res.status(err.status).json({ error: err.message }); return true; @@ -47,23 +53,65 @@ const logSessionKeyResolutionError = ( const jwtProvider = new LibreChatJwtAuthProvider(); +/** + * True when an unverified bearer token declares agent_run. Used only to drop + * the query string (which carries the run id) from refusal logs; it grants + * nothing. + */ +function bearerDeclaresAgentRun(req: AuthenticatedRequest): boolean { + const payload = req.header('Authorization')?.match(/^Bearer\s+[^.\s]+\.([^.\s]+)\./i)?.[1]; + if (!payload) return false; + try { + return (JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { principal_source?: unknown }) + .principal_source === 'agent_run'; + } catch { + return false; + } +} + function authLogMeta(req: AuthenticatedRequest, extra: Record = {}): Record { + const agentRun = req.codeApiAuthContext?.agentRun; + const redactQuery = agentRun !== undefined || (!req.codeApiPrincipal && bearerDeclaresAgentRun(req)); + const path = req.originalUrl || req.path; return { method: req.method, - path: req.originalUrl || req.path, + path: redactQuery ? path.split('?')[0] : path, ip: req.ip, authProvider: process.env.CODEAPI_AUTH_PROVIDER || 'librechat-jwt', hasBearerToken: Boolean(req.header('Authorization')?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim()), hasApiKeyHeader: Boolean(req.header('X-API-Key')), hasSyntheticToken: hasSyntheticAccessToken(req), principalSource: req.codeApiPrincipal?.principalSource, - userId: req.codeApiAuthContext?.userId, - tenantId: req.codeApiAuthContext?.tenantId, - authContextHash: req.codeApiAuthContext?.authContextHash, + /* agent_run: subject kind and hashed ids only; no raw ids or context hash. */ + ...(agentRun + ? agentRunLogFields(req.codeApiAuthContext?.tenantId, agentRun) + : { + userId: req.codeApiAuthContext?.userId, + tenantId: req.codeApiAuthContext?.tenantId, + authContextHash: req.codeApiAuthContext?.authContextHash, + }), ...extra, }; } +/** + * C3: a deletion-only token is honoured for exactly one request shape, + * `DELETE /files//?kind=agent&id=` with the signed target, and + * refused for every other method, route and query before any handler runs. + */ +function fileDeleteRequestMatches(req: AuthenticatedRequest, principal: AgentRunPrincipal): boolean { + const target = principal.agentRun.fileDelete; + if (!target) return false; + const queryKeys = Object.keys(req.query).sort().join(','); + return ( + req.method === 'DELETE' && + req.path === `/files/${target.storageSessionId}/${target.fileId}` && + queryKeys === 'id,kind' && + req.query.kind === 'agent' && + req.query.id === principal.agentRun.runId + ); +} + export const apiKeyAuth = async ( req: AuthenticatedRequest, res: Response, @@ -142,6 +190,13 @@ export const apiKeyAuth = async ( return res.status(401).json({ error: 'Authentication is required' }); } applyPrincipal(req, principal); + if (principal.agentRun?.fileDelete && !fileDeleteRequestMatches(req, principal)) { + logger.warn( + 'Refusing deletion-only agent_run token outside its signed target', + authLogMeta(req, { mode }), + ); + return res.status(403).json({ error: 'Token is limited to one file deletion' }); + } logger.debug('CodeAPI request authenticated', authLogMeta(req, { mode })); next(); } catch (error) { @@ -194,8 +249,10 @@ export const sessionAuth = async (req: AuthenticatedRequest, res: Response, next return res.status(400).json({ error: 'Bad request' }); } + const principal = req.codeApiPrincipal; + const agentRunPrincipal = principal?.agentRun ? principal : undefined; const userId = req.codeApiAuthContext?.userId ?? ''; - if (!userId) { + if (!agentRunPrincipal && !userId) { logger.warn('Rejecting session auth without authContext.userId', authLogMeta(req)); return res.status(401).json({ error: 'User not found' }); } @@ -238,9 +295,27 @@ export const sessionAuth = async (req: AuthenticatedRequest, res: Response, next } throw err; } + /* C1: shared skill objects are read-only for an Agent; it deletes only its + * own run's private objects. */ + if (agentRunPrincipal && req.method === 'DELETE' && sessionKeyInput.kind !== 'agent') { + logger.warn('Refusing agent_run deletion outside its private run key', authLogMeta(req)); + return res.status(403).json({ error: 'Unauthorized' }); + } const cachedSessionKey = await connection.get(`session:${session_id}`); if (cachedSessionKey !== sessionKey) { - logger.error(`Unauthorized download: Cached session key: ${cachedSessionKey} | Expected session key: ${sessionKey} | Session ID: ${session_id} | File ID: ${fileId}`); + /* The session cache expires after SESSION_CACHE_TTL. A deletion-only + * agent_run token may then still delete, but only against the durable + * owner binding stored with the bytes: the file server removes the object + * only if that binding names this tenant, Agent and run. A present but + * different cache entry is a refusal. */ + if (cachedSessionKey === null && agentRunPrincipal?.agentRun.fileDelete && req.method === 'DELETE') { + req.sessionKey = sessionKey; + req.ownerBindingExpectation = ownerBindingValue(agentRunPrincipal.tenantId, agentRunPrincipal.agentRun); + logger.info('Session cache absent; deleting against the durable owner binding', authLogMeta(req)); + next(); + return; + } + logger.error(`Unauthorized download: Cached session key: ${sessionKeyForLog(cachedSessionKey)} | Expected session key: ${sessionKeyForLog(sessionKey)} | Session ID: ${session_id} | File ID: ${fileId}`); return res.status(403).json({ error: 'Unauthorized' }); } diff --git a/service/src/middleware/limits.ts b/service/src/middleware/limits.ts index b16ed196..5a236b09 100644 --- a/service/src/middleware/limits.ts +++ b/service/src/middleware/limits.ts @@ -8,6 +8,7 @@ import type { NextFunction, Request, Response } from 'express'; import type { AuthenticatedRequest } from '../types'; import { env } from '../config'; import { getExecutionIdentity } from '../execution-identity'; +import { agentRunLogFields } from '../agent-run'; import logger from '../logger'; type RedisCommandTarget = { @@ -92,6 +93,11 @@ export function rateLimitResponseBody(message: string, retryAfter: number): { export const keyGenerator = (req: Request): string => { const authReq = req as AuthenticatedRequest; const identity = getExecutionIdentity(authReq); + /* One stable bucket per Agent, not per run: starting runs must not reset + * the allowance. `:agent:` here is a rate-limit key space only. */ + if (identity.agentRun) { + return `${keySegment(identity.storageNamespace, 'legacy')}:agent:${keySegment(identity.agentRun.agentId)}`; + } if (identity.canonicalUserId) { return `${keySegment(identity.storageNamespace, 'legacy')}:user:${keySegment(identity.canonicalUserId)}`; } @@ -127,17 +133,28 @@ const buildRateLimiter = ( const principal = authReq.codeApiPrincipal; const identity = getExecutionIdentity(authReq); const hasIdentity = Boolean(identity.canonicalUserId); - logger.warn('CodeAPI rate limit rejected', { - limiter: prefix, - path: req.originalUrl || req.path, - retryAfterSeconds: retryAfter, - limit: rateLimit?.limit ?? max, - windowMs, - principalSource: hasIdentity ? identity.principalSource : undefined, - tenantHash: hasIdentity ? hashLabel(identity.storageNamespace) : undefined, - userHash: hasIdentity ? hashLabel(identity.canonicalUserId) : undefined, - credentialHash: hashLabel(principal?.credentialId), - }); + logger.warn('CodeAPI rate limit rejected', identity.agentRun + ? { + limiter: prefix, + path: (req.originalUrl || req.path).split('?')[0], + retryAfterSeconds: retryAfter, + limit: rateLimit?.limit ?? max, + windowMs, + principalSource: identity.principalSource, + ...agentRunLogFields(identity.storageNamespace, identity.agentRun), + credentialHash: hashLabel(principal?.credentialId), + } + : { + limiter: prefix, + path: req.originalUrl || req.path, + retryAfterSeconds: retryAfter, + limit: rateLimit?.limit ?? max, + windowMs, + principalSource: hasIdentity ? identity.principalSource : undefined, + tenantHash: hasIdentity ? hashLabel(identity.storageNamespace) : undefined, + userHash: hasIdentity ? hashLabel(identity.canonicalUserId) : undefined, + credentialHash: hashLabel(principal?.credentialId), + }); } if (options.structuredBody) { diff --git a/service/src/middleware/request-error-logger.ts b/service/src/middleware/request-error-logger.ts index 304e6062..95250517 100644 --- a/service/src/middleware/request-error-logger.ts +++ b/service/src/middleware/request-error-logger.ts @@ -1,4 +1,5 @@ import type { ErrorRequestHandler, Request, RequestHandler } from 'express'; +import { agentRunLogFields } from '../agent-run'; import type { AuthenticatedRequest } from '../types'; import { SessionKeyResolutionError } from '../session-key'; import { CodeApiJwtAuthError } from '../auth/librechat-jwt'; @@ -45,18 +46,24 @@ function requestPath(req: Request): string { export function buildRequestErrorLogMeta(error: unknown, req: Request): Record { const authReq = req as AuthenticatedRequest; + const agentRun = authReq.codeApiAuthContext?.agentRun; return { status: statusFromError(error), method: req.method, - path: requestPath(req), + /* agent_run queries carry the run id; log the path alone. */ + path: agentRun ? requestPath(req).split('?')[0] : requestPath(req), requestId: req.header('x-request-id') || req.header('x-correlation-id'), userAgent: req.header('user-agent'), ip: req.ip, authProvider: process.env.CODEAPI_AUTH_PROVIDER || 'librechat-jwt', principalSource: authReq.codeApiPrincipal?.principalSource, - userId: authReq.codeApiAuthContext?.userId, - tenantId: authReq.codeApiAuthContext?.tenantId, - authContextHash: authReq.codeApiAuthContext?.authContextHash, + ...(agentRun + ? agentRunLogFields(authReq.codeApiAuthContext?.tenantId, agentRun) + : { + userId: authReq.codeApiAuthContext?.userId, + tenantId: authReq.codeApiAuthContext?.tenantId, + authContextHash: authReq.codeApiAuthContext?.authContextHash, + }), error: serializeError(error), }; } diff --git a/service/src/personal-parity.test.ts b/service/src/personal-parity.test.ts index 7428cc15..07714dee 100644 --- a/service/src/personal-parity.test.ts +++ b/service/src/personal-parity.test.ts @@ -61,12 +61,12 @@ const TOKEN_VARIANTS: Record> = { }; beforeAll(async () => { - process.env.CODEAPI_TENANT_ISOLATION_STRICT = 'true'; harness = await installRouteHarness({ queueModulePath: join(import.meta.dir, 'queue.ts'), srcDir: import.meta.dir, jwksJson: jwksFor([{ kid: 'parity-kid', key: testSigningKey() }]), }); + process.env.CODEAPI_TENANT_ISOLATION_STRICT = 'true'; }); afterAll(async () => { @@ -93,9 +93,11 @@ async function unitSnapshot(): Promise> { for (const [name, overrides] of Object.entries(TOKEN_VARIANTS)) { const token = signTestJwt(personalClaims(FIXED_NOW_SECONDS, overrides)); const principal = verifyLibreChatJwt(token); + if (principal.agentRun) throw new Error('personal fixture verified as agent_run'); const req = { ip: '127.0.0.1', headers: {}, header: () => undefined } as unknown as Req; applyPrincipal(req, principal); const identity = getExecutionIdentity(req); + if (identity.agentRun) throw new Error('personal fixture resolved an agent_run identity'); const sessionKeys = { user: resolveSessionKey(req, parseUploadSessionKeyInput({ kind: 'user', id: undefined, version: undefined, authContextUserId: principal.userId })), agentRun: resolveSessionKey(req, { kind: 'agent', id: RUN_UUID }), @@ -265,5 +267,8 @@ test('personal identity consumers, routes and log lines are byte-identical to th writeFileSync(GOLDEN_PATH, `${JSON.stringify(snapshot, null, 2)}\n`); } expect(existsSync(GOLDEN_PATH)).toBe(true); - expect(snapshot).toEqual(JSON.parse(readFileSync(GOLDEN_PATH, 'utf8'))); + const golden = JSON.parse(readFileSync(GOLDEN_PATH, 'utf8')); + expect(snapshot).toEqual(golden); + /* toEqual ignores key order; serialized log lines, claims and state do not. */ + expect(JSON.stringify(snapshot)).toBe(JSON.stringify(golden)); }); diff --git a/service/src/runtime-session/id.ts b/service/src/runtime-session/id.ts index 92129443..09f380c2 100644 --- a/service/src/runtime-session/id.ts +++ b/service/src/runtime-session/id.ts @@ -1,4 +1,5 @@ import { createHash } from 'crypto'; +import type { AgentRunSubject } from '../agent-run'; export const RUNTIME_SESSION_HINT_MAX_LENGTH = 128; const RUNTIME_SESSION_HINT_PATTERN = /^[A-Za-z0-9._:-]+$/; @@ -32,17 +33,32 @@ export function validateRuntimeSessionHint(hint: unknown): string | undefined { return hint; } +/** Whose sessions these are: a user, or one Agent run. Server-derived only. */ +export type RuntimeSessionScope = + | { storageNamespace: string; canonicalUserId: string; agentRun?: undefined } + | { storageNamespace: string; agentRun: AgentRunSubject; canonicalUserId?: undefined }; + /** * Server-derived runtime session identity. The namespace and user come from * `getExecutionIdentity(req)` — never the client — so a hint can never * collide across tenants or users. The hint only partitions sessions within * one (tenant, user) scope. + * + * An agent_run scope is (namespace, kind, Agent, run): one run's sessions + * never merge with another run's, another Agent's or a user's, whatever hint + * the caller sends. Its material is JSON with a kind prefix and no raw NUL, + * so it cannot equal personal legacy material (two raw NULs) or `v2:` material. */ -export function deriveRuntimeSessionId(args: { - storageNamespace: string; - canonicalUserId: string; - hint?: string; -}): string { +export function deriveRuntimeSessionId(args: RuntimeSessionScope & { hint?: string }): string { + if (args.agentRun) { + const material = `agent_run:${JSON.stringify([ + args.storageNamespace, + args.agentRun.agentId, + args.agentRun.runId, + args.hint ?? DEFAULT_HINT, + ])}`; + return `rt_${createHash('sha256').update(material, 'utf8').digest('hex').slice(0, 40)}`; + } const fields = [ args.storageNamespace, args.canonicalUserId, @@ -68,10 +84,8 @@ export function deriveRuntimeSessionId(args: { * strict mode rejects it, since the caller asked for guaranteed session * semantics it failed to identify. */ -export function resolveRuntimeSessionIdForRequest(args: { +export function resolveRuntimeSessionIdForRequest(args: RuntimeSessionScope & { mode: 'stateless' | 'affinity' | 'strict'; - storageNamespace: string; - canonicalUserId: string; hint?: string; }): string | undefined { if (args.mode === 'stateless') return undefined; @@ -90,18 +104,14 @@ export function resolveRuntimeSessionIdForRequest(args: { * validation of a hint that will never be consumed. Stateless mode has the * same ignore-don't-validate contract. */ -export function resolveRuntimeSessionIdForExecRequest(args: { +export function resolveRuntimeSessionIdForExecRequest(args: RuntimeSessionScope & { mode: 'stateless' | 'affinity' | 'strict'; - storageNamespace: string; - canonicalUserId: string; runtimeSessionHint: unknown; isSynthetic: boolean; }): string | undefined { if (args.isSynthetic || args.mode === 'stateless') return undefined; - return resolveRuntimeSessionIdForRequest({ - mode: args.mode, - storageNamespace: args.storageNamespace, - canonicalUserId: args.canonicalUserId, - hint: validateRuntimeSessionHint(args.runtimeSessionHint), - }); + const hint = validateRuntimeSessionHint(args.runtimeSessionHint); + return resolveRuntimeSessionIdForRequest(args.agentRun + ? { mode: args.mode, storageNamespace: args.storageNamespace, agentRun: args.agentRun, hint } + : { mode: args.mode, storageNamespace: args.storageNamespace, canonicalUserId: args.canonicalUserId, hint }); } diff --git a/service/src/runtime-session/registry.ts b/service/src/runtime-session/registry.ts index f8d66507..ea4c902b 100644 --- a/service/src/runtime-session/registry.ts +++ b/service/src/runtime-session/registry.ts @@ -29,7 +29,11 @@ export type RuntimeSessionState = 'PENDING' | 'RUNNING' | 'SUSPENDED' | 'TERMINA export interface RuntimeSessionRecord { runtime_session_id: string; tenant_id: string; - canonical_user_id: string; + /** Personal sessions. agent_run sessions carry principal_source/agent_id/run_id instead. */ + canonical_user_id?: string; + principal_source?: string; + agent_id?: string; + run_id?: string; microvm_id?: string; endpoint?: string; port?: number; diff --git a/service/src/sandbox-backend/lambda-microvm.ts b/service/src/sandbox-backend/lambda-microvm.ts index 1cb8a4dd..c660df76 100644 --- a/service/src/sandbox-backend/lambda-microvm.ts +++ b/service/src/sandbox-backend/lambda-microvm.ts @@ -40,6 +40,7 @@ import { SandboxBackendError } from './types'; import { Jobs } from '../enum'; import { checkpointPipelineBudgetMs } from '../config'; import logger from '../logger'; +import { AGENT_RUN_PRINCIPAL_SOURCE } from '../agent-run'; /** Header that opts a proxied /execute into the runner's persistent session * workspace (see api/src/session-workspace.ts). Session mode is delivered @@ -821,7 +822,13 @@ export class LambdaMicrovmSandboxBackend implements SandboxBackend { let launchIntent: RuntimeSessionRecord = { runtime_session_id: runtimeSessionId, tenant_id: ctx.tenantId ?? '', - canonical_user_id: ctx.canonicalUserId ?? '', + ...(ctx.agentRun + ? { + principal_source: AGENT_RUN_PRINCIPAL_SOURCE, + agent_id: ctx.agentRun.agentId, + run_id: ctx.agentRun.runId, + } + : { canonical_user_id: ctx.canonicalUserId ?? '' }), port: this.config.port, image_arn: this.config.imageArn, image_version: this.config.imageVersion, diff --git a/service/src/sandbox-backend/types.ts b/service/src/sandbox-backend/types.ts index 75d7f1af..f178b667 100644 --- a/service/src/sandbox-backend/types.ts +++ b/service/src/sandbox-backend/types.ts @@ -1,4 +1,5 @@ import type * as t from '../types'; +import type { AgentRunSubject } from '../agent-run'; import { getAxiosErrorDetails } from '../utils'; /** @@ -37,6 +38,8 @@ export interface SandboxExecuteContext { deadlineAtMs?: number; tenantId?: string; canonicalUserId?: string; + /** agent_run subject; set instead of canonicalUserId, never alongside it. */ + agentRun?: AgentRunSubject; /** Absent ⇒ stateless execution (no runtime session affinity). */ runtimeSessionId?: string; runtimeSessionMode: t.RuntimeSessionMode; diff --git a/service/src/service/file-authorization.ts b/service/src/service/file-authorization.ts index 4afa2c85..ac5d8d64 100644 --- a/service/src/service/file-authorization.ts +++ b/service/src/service/file-authorization.ts @@ -24,7 +24,9 @@ type FileRefStore = { export type FileRefAuthDenyReason = | 'session_key_mismatch' | 'upload_missing' - | 'invalid_input'; + | 'invalid_input' + /** agent_run: a kind/id the verified Agent subject may not resolve. */ + | 'subject_refused'; export class FileRefAuthorizationError extends Error { readonly status: 400 | 403; @@ -215,6 +217,9 @@ export async function authorizeRequestedFiles(args: { if (err.status === 400) { throw new FileRefAuthorizationError(400, err.message, 'invalid_input'); } + if (err.status === 403) { + throw new FileRefAuthorizationError(403, 'Unauthorized file reference', 'subject_refused'); + } throw err; } throw err; diff --git a/service/src/service/programmatic-router.ts b/service/src/service/programmatic-router.ts index eade27fb..f2be460a 100644 --- a/service/src/service/programmatic-router.ts +++ b/service/src/service/programmatic-router.ts @@ -15,6 +15,7 @@ import { internalServiceHeaders } from '../internal-service-auth'; import { resolveOutputBucketSessionKey, SessionKeyResolutionError } from '../session-key'; import { getCredentialId, getPrincipalOrReject } from '../auth/principal'; import { getExecutionIdentity } from '../execution-identity'; +import { agentRunLogFields, sessionKeyForLog } from '../agent-run'; import { PROGRAMMATIC_RUNTIME_SESSION_EXEMPTION } from '../runtime-session/job-policy'; import { jobsSubmitted, @@ -45,6 +46,8 @@ import { acquireExecutionLock, releaseExecutionLock, checkContinuationPreconditions, + continuationSubjectMatches, + replaySessionKey, cleanupExecution, cleanupStaleExecutions, commitToolHistoryAndState, @@ -81,6 +84,18 @@ function sendFileRefAuthorizationError( req?: t.AuthenticatedRequest, ): boolean { if (error instanceof FileRefAuthorizationError) { + const agentRun = req?.codeApiAuthContext?.agentRun; + if (agentRun) { + /* The rejection context embeds the raw run id; log hashes only. */ + logger.warn('File reference authorization rejected', { + status: error.status, + reason: error.reason, + message: error.message, + ...agentRunLogFields(req?.codeApiAuthContext?.tenantId, agentRun), + }); + res.status(error.status).json({ error: error.message }); + return true; + } logger.warn('File reference authorization rejected', { status: error.status, reason: error.reason, @@ -108,14 +123,19 @@ function sendSessionKeyResolutionError( context: string, ): boolean { if (error instanceof SessionKeyResolutionError) { + const agentRun = req.codeApiAuthContext?.agentRun; logger.error(`sessionKey resolution failed (${context})`, { status: error.status, message: error.message, method: req.method, path: req.path, - requestUserId: req.codeApiAuthContext?.userId, - authContextUserId: req.codeApiAuthContext?.userId, - tenantId: req.codeApiAuthContext?.tenantId, + ...(agentRun + ? agentRunLogFields(req.codeApiAuthContext?.tenantId, agentRun) + : { + requestUserId: req.codeApiAuthContext?.userId, + authContextUserId: req.codeApiAuthContext?.userId, + tenantId: req.codeApiAuthContext?.tenantId, + }), }); res.status(error.status).json({ error: error.message }); return true; @@ -360,19 +380,32 @@ function buildReplayPayload( }); } +/** Continuation refusal involving an agent_run: kind and hashed ids only. */ +function continuationRejectionLogFields( + state: ExecutionState, + requestAgentRun: { agentId: string; runId: string } | undefined, + requestTenantId: string, +): Record { + return { + execution_id: state.execution_id, + request: requestAgentRun ? agentRunLogFields(requestTenantId, requestAgentRun) : { subjectKind: 'user' }, + execution: state.agentRun ? agentRunLogFields(state.tenantId, state.agentRun) : { subjectKind: 'user' }, + }; +} + async function runReplayIteration( req: t.AuthenticatedRequest, state: ExecutionState, apiKeyId: string, - userId: string, + userId: string | undefined, ): Promise { const history = await loadToolHistory(state.execution_id); const rawPayload = buildReplayPayload(req, state, history); - const sessionKey = state.sessionKey ?? state.userId; + const sessionKey = replaySessionKey(state); const sandboxSecurity = prepareSandboxJobSecurity({ req, executionId: state.execution_id, - userId, + ...(state.agentRun ? { agentRun: state.agentRun } : { userId }), sessionKey, outputSessionId: state.session_id, payload: rawPayload, @@ -399,14 +432,14 @@ async function runReplayIteration( const { queue, events, language } = pickQueue(state.language ?? 'python'); const job = await queue.add(Jobs.execute, { code: state.userCode ?? '', - userId, + ...(state.agentRun ? {} : { userId }), payload: sandboxSecurity.payload, apiKeyId, isPyPlot: state.isPyPlot ?? false, principalSource: state.principalSource, executionId: state.execution_id, tenantId: state.tenantId, - canonicalUserId: state.canonicalUserId, + ...(state.agentRun ? { agentRun: state.agentRun } : { canonicalUserId: state.canonicalUserId }), executionProfile: env.EXECUTION_PROFILE, runtimeSessionMode: 'stateless', runtimeSessionExemption: PROGRAMMATIC_RUNTIME_SESSION_EXEMPTION, @@ -438,7 +471,7 @@ async function handleReplayInitial( res: Response, params: { apiKeyId: string; - userId: string; + userId: string | undefined; }, ): Promise { const { apiKeyId, userId } = params; @@ -573,7 +606,7 @@ async function handleReplayInitial( if (err instanceof ExecutionStateTooLargeError) { logger.warn('Rejecting replay request: ExecutionState exceeds Redis cap', { execution_id, - userId, + ...(identity.agentRun ? agentRunLogFields(identity.storageNamespace, identity.agentRun) : { userId }), apiKeyId, bytes: err.bytes, cap: err.cap, @@ -588,19 +621,32 @@ async function handleReplayInitial( throw err; } - logger.info('Programmatic execution request received (replay)', { - userId, - apiKeyId, - user: user_id, - session_id, - execution_id, - language, - toolCount: tools.length, - codeLength: code.length, - files: summarizeRequestedFiles(authorizedFiles), - sessionKey, - timeout, - }); + logger.info('Programmatic execution request received (replay)', identity.agentRun + ? { + ...agentRunLogFields(identity.storageNamespace, identity.agentRun), + apiKeyId, + session_id, + execution_id, + language, + toolCount: tools.length, + codeLength: code.length, + files: summarizeRequestedFiles(authorizedFiles), + sessionKey: sessionKeyForLog(sessionKey), + timeout, + } + : { + userId, + apiKeyId, + user: user_id, + session_id, + execution_id, + language, + toolCount: tools.length, + codeLength: code.length, + files: summarizeRequestedFiles(authorizedFiles), + sessionKey, + timeout, + }); await runAndRespond(req, res, state, apiKeyId, userId); } @@ -610,7 +656,7 @@ async function handleReplayContinuation( res: Response, params: { apiKeyId: string; - userId: string; + userId: string | undefined; decoded: { execution_id: string }; tool_results: NonNullable; }, @@ -700,6 +746,7 @@ async function handleReplayContinuation( state, results: enrichedResults, userId, + agentRun: identity.agentRun, apiKeyId, tenantId: identity.storageNamespace, authContextHash: req.codeApiAuthContext?.authContextHash, @@ -707,15 +754,18 @@ async function handleReplayContinuation( }); if (!pre.ok) { if (pre.status === 403) { - logger.warn('Unauthorized replay continuation request rejected', { - execution_id: state.execution_id, - requestUserId: userId, - requestApiKeyId: apiKeyId, - requestTenantId: identity.storageNamespace, - executionUserId: state.userId, - executionApiKeyId: state.apiKeyId, - executionTenantId: state.tenantId, - }); + logger.warn('Unauthorized replay continuation request rejected', + identity.agentRun || state.agentRun + ? continuationRejectionLogFields(state, identity.agentRun, identity.storageNamespace) + : { + execution_id: state.execution_id, + requestUserId: userId, + requestApiKeyId: apiKeyId, + requestTenantId: identity.storageNamespace, + executionUserId: state.userId, + executionApiKeyId: state.apiKeyId, + executionTenantId: state.tenantId, + }); } if (pre.cleanupOnReject === true) { await cleanupExecution(state.execution_id, 'replay'); @@ -811,7 +861,7 @@ async function runAndRespond( res: Response, state: ExecutionState, apiKeyId: string, - userId: string, + userId: string | undefined, ): Promise { /** Read disconnect state through `isDisconnected()` rather than a * direct boolean. The `req.on('close', ...)` handler flips the flag @@ -1099,7 +1149,7 @@ router.post('/exec/programmatic', executionLimiter, async (req: t.AuthenticatedR async function handleBlocking( req: t.AuthenticatedRequest, res: Response, - params: { apiKeyId: string; userId: string }, + params: { apiKeyId: string; userId: string | undefined }, ): Promise> { const { apiKeyId, userId } = params; const { @@ -1132,7 +1182,12 @@ async function handleBlocking( const identity = getExecutionIdentity(req, userId); if ( - execution.userId !== userId || + !continuationSubjectMatches(execution, { + userId, + agentRun: identity.agentRun, + tenantId: identity.storageNamespace, + authContextHash: req.codeApiAuthContext?.authContextHash, + }) || (execution.apiKeyId != null && execution.apiKeyId !== apiKeyId) || ( execution.tenantId != null && @@ -1143,15 +1198,18 @@ async function handleBlocking( execution.authContextHash !== req.codeApiAuthContext?.authContextHash ) ) { - logger.warn('Unauthorized blocking continuation request rejected', { - execution_id, - requestUserId: userId, - requestApiKeyId: apiKeyId, - requestTenantId: identity.storageNamespace, - executionUserId: execution.userId, - executionApiKeyId: execution.apiKeyId, - executionTenantId: execution.tenantId, - }); + logger.warn('Unauthorized blocking continuation request rejected', + identity.agentRun || execution.agentRun + ? continuationRejectionLogFields(execution, identity.agentRun, identity.storageNamespace) + : { + execution_id, + requestUserId: userId, + requestApiKeyId: apiKeyId, + requestTenantId: identity.storageNamespace, + executionUserId: execution.userId, + executionApiKeyId: execution.apiKeyId, + executionTenantId: execution.tenantId, + }); return res.status(403).json({ error: 'Forbidden' }); } @@ -1221,7 +1279,9 @@ async function handleBlocking( if (tools.length > MAX_TOOLS_PER_REQUEST) { logger.warn(`Too many tools provided: ${tools.length}, limit is ${MAX_TOOLS_PER_REQUEST}`, { execution_id: 'pre-creation', - userId, + ...(req.codeApiAuthContext?.agentRun + ? agentRunLogFields(req.codeApiAuthContext.tenantId, req.codeApiAuthContext.agentRun) + : { userId }), toolCount: tools.length, }); return res.status(400).json({ @@ -1269,38 +1329,64 @@ async function handleBlocking( connection.set(`session:${session_id}`, sessionKey, 'EX', env.SESSION_CACHE_TTL); - const executionState: ExecutionState = { - execution_id, - session_id, - sessionKey, - userId, - tenantId: identity.storageNamespace, - canonicalUserId: identity.canonicalUserId, - orgId: identity.orgId, - serviceId: identity.serviceId, - externalUserId: identity.externalUserId, - principalSource: identity.principalSource, - authContextHash: identity.authContextHash, - apiKeyId, - startTime: Date.now(), - lastActivity: Date.now(), - mode: 'blocking', - }; - await setExecutionState(executionState); - - try { - logger.info('Programmatic execution request received', { - userId, - apiKeyId, - user: user_id, + const executionState: ExecutionState = identity.agentRun + ? { + execution_id, session_id, + sessionKey, + agentRun: identity.agentRun, + tenantId: identity.storageNamespace, + principalSource: identity.principalSource, + authContextHash: identity.authContextHash, + apiKeyId, + startTime: Date.now(), + lastActivity: Date.now(), + mode: 'blocking', + } + : { execution_id, - toolCount: tools.length, - codeLength: code.length, - files: summarizeRequestedFiles(authorizedFiles), + session_id, sessionKey, - timeout, - }); + userId, + tenantId: identity.storageNamespace, + canonicalUserId: identity.canonicalUserId, + orgId: identity.orgId, + serviceId: identity.serviceId, + externalUserId: identity.externalUserId, + principalSource: identity.principalSource, + authContextHash: identity.authContextHash, + apiKeyId, + startTime: Date.now(), + lastActivity: Date.now(), + mode: 'blocking', + }; + await setExecutionState(executionState); + + try { + logger.info('Programmatic execution request received', identity.agentRun + ? { + ...agentRunLogFields(identity.storageNamespace, identity.agentRun), + apiKeyId, + session_id, + execution_id, + toolCount: tools.length, + codeLength: code.length, + files: summarizeRequestedFiles(authorizedFiles), + sessionKey: sessionKeyForLog(sessionKey), + timeout, + } + : { + userId, + apiKeyId, + user: user_id, + session_id, + execution_id, + toolCount: tools.length, + codeLength: code.length, + files: summarizeRequestedFiles(authorizedFiles), + sessionKey, + timeout, + }); let callbackUrl: string; try { @@ -1361,7 +1447,7 @@ async function handleBlocking( const sandboxSecurity = prepareSandboxJobSecurity({ req, executionId: execution_id, - userId, + ...(identity.agentRun ? { agentRun: identity.agentRun } : { userId }), sessionKey, outputSessionId: session_id, payload: rawPayload, @@ -1369,14 +1455,14 @@ async function handleBlocking( const job = await pyQueue.add(Jobs.execute, { code, - userId, + ...(identity.agentRun ? {} : { userId }), payload: sandboxSecurity.payload, apiKeyId, isPyPlot: false, principalSource: identity.principalSource, executionId: execution_id, tenantId: identity.storageNamespace, - canonicalUserId: identity.canonicalUserId, + ...(identity.agentRun ? { agentRun: identity.agentRun } : { canonicalUserId: identity.canonicalUserId }), executionProfile: env.EXECUTION_PROFILE, runtimeSessionMode: 'stateless', runtimeSessionExemption: PROGRAMMATIC_RUNTIME_SESSION_EXEMPTION, diff --git a/service/src/service/programmatic-state.ts b/service/src/service/programmatic-state.ts index f469606f..708da649 100644 --- a/service/src/service/programmatic-state.ts +++ b/service/src/service/programmatic-state.ts @@ -7,7 +7,8 @@ export interface BuildReplayExecutionStateParams { executionId: string; sessionId: string; sessionKey: string; - userId: string; + /** Personal subject; unused for an agent_run identity. */ + userId?: string; apiKeyId: string; authContext?: t.CodeApiAuthContext; identity?: ExecutionIdentity; @@ -24,6 +25,36 @@ export function buildReplayExecutionState( params: BuildReplayExecutionStateParams, ): ExecutionState { const now = params.now ?? Date.now(); + const replay = { + apiKeyId: params.apiKeyId, + startTime: now, + lastActivity: now, + mode: 'replay' as const, + userCode: params.code, + tools: params.tools, + files: params.files, + isPyPlot: params.isPyPlot, + timeout: params.timeout, + callCount: 0, + language: params.language, + }; + /* agent_run state persists the Agent subject and its private sessionKey; + * it never gains a userId. */ + if (params.identity?.agentRun) { + return { + execution_id: params.executionId, + session_id: params.sessionId, + sessionKey: params.sessionKey, + agentRun: params.identity.agentRun, + tenantId: params.identity.storageNamespace, + principalSource: params.identity.principalSource, + authContextHash: params.identity.authContextHash, + ...replay, + }; + } + if (params.userId === undefined) { + throw new Error('replay execution state requires a user or an agent_run subject'); + } const identity = params.identity ?? buildExecutionIdentity({ userId: params.userId, authContext: params.authContext, @@ -40,16 +71,6 @@ export function buildReplayExecutionState( externalUserId: identity.externalUserId, principalSource: identity.principalSource, authContextHash: identity.authContextHash, - apiKeyId: params.apiKeyId, - startTime: now, - lastActivity: now, - mode: 'replay', - userCode: params.code, - tools: params.tools, - files: params.files, - isPyPlot: params.isPyPlot, - timeout: params.timeout, - callCount: 0, - language: params.language, + ...replay, }; } diff --git a/service/src/service/replay-state.ts b/service/src/service/replay-state.ts index 562e06dd..24c3b7ca 100644 --- a/service/src/service/replay-state.ts +++ b/service/src/service/replay-state.ts @@ -28,6 +28,7 @@ import { connection } from '../queue'; import { env } from '../config'; import { internalServiceHeaders } from '../internal-service-auth'; import logger from '../logger'; +import { AGENT_RUN_PRINCIPAL_SOURCE, type AgentRunSubject } from '../agent-run'; import { ptcReplayHistorySize, ptcReplayHistoryEntries, @@ -91,7 +92,10 @@ export interface ExecutionState { execution_id: string; session_id: string; sessionKey?: string; - userId: string; + /** Personal subject. Absent on agent_run state, which carries `agentRun` + * and always a persisted private `sessionKey`. */ + userId?: string; + agentRun?: AgentRunSubject; tenantId?: string; canonicalUserId?: string; orgId?: string; @@ -943,6 +947,45 @@ export function validateContinuationBatch(tool_results: unknown[]): return { ok: true, results }; } +/** + * Whose continuation this is. A personal request matches only personal state + * of the same user. An agent_run request matches only agent_run state of the + * same Agent and run in the same tenant with the same context hash; none of + * those is optional for an Agent, and a sub string alone never matches. + */ +export function continuationSubjectMatches( + state: ExecutionState, + request: { userId?: string; agentRun?: AgentRunSubject; tenantId?: string; authContextHash?: string }, +): boolean { + if (request.agentRun) { + return ( + state.agentRun !== undefined && + state.userId === undefined && + state.principalSource === AGENT_RUN_PRINCIPAL_SOURCE && + state.agentRun.agentId === request.agentRun.agentId && + state.agentRun.runId === request.agentRun.runId && + state.tenantId !== undefined && + state.tenantId === request.tenantId && + state.authContextHash !== undefined && + state.authContextHash === request.authContextHash + ); + } + return state.agentRun === undefined && request.userId !== undefined && state.userId === request.userId; +} + +/** + * Output session key a replay iteration writes to. agent_run state always + * persisted its private key; it has no userId or legacy fallback. The legacy + * `sessionKey ?? userId` fallback stays personal-only. + */ +export function replaySessionKey(state: ExecutionState): string { + const sessionKey = state.agentRun ? state.sessionKey : (state.sessionKey ?? state.userId); + if (!sessionKey) { + throw new Error('Execution state has no session key'); + } + return sessionKey; +} + /** Apply the post-state-load pre-checks to a continuation request: * mode, ownership/auth, that every incoming call_id was actually emitted * by the sandbox, and the projected aggregate caps after applying @@ -954,7 +997,8 @@ export function validateContinuationBatch(tool_results: unknown[]): export function checkContinuationPreconditions(params: { state: ExecutionState; results: ValidatedContinuationResult[]; - userId: string; + userId?: string; + agentRun?: AgentRunSubject; apiKeyId?: string; tenantId?: string; authContextHash?: string; @@ -962,7 +1006,7 @@ export function checkContinuationPreconditions(params: { }): | { ok: true } | { ok: false; status: number; error: string; cleanupOnReject?: boolean } { - const { state, results, userId, apiKeyId, tenantId, authContextHash, delta } = params; + const { state, results, userId, agentRun, apiKeyId, tenantId, authContextHash, delta } = params; if (state.mode !== 'replay') { return { ok: false, @@ -971,7 +1015,7 @@ export function checkContinuationPreconditions(params: { }; } if ( - state.userId !== userId || + !continuationSubjectMatches(state, { userId, agentRun, tenantId, authContextHash }) || (state.apiKeyId != null && state.apiKeyId !== apiKeyId) || (state.tenantId != null && state.tenantId !== tenantId) || (state.authContextHash != null && state.authContextHash !== authContextHash) diff --git a/service/src/service/router.ts b/service/src/service/router.ts index 2728e0a0..8719825b 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -10,13 +10,21 @@ import { checkServiceStartUp, checkServiceShutDown } from '../lifecycle'; import { sessionAuth } from '../middleware/auth'; import { executionLimiter, uploadLimiter, downloadLimiter, fetchLimiter } from '../middleware/limits'; import { internalServiceHeaders } from '../internal-service-auth'; -import { resolveSessionKey, resolveOutputBucketSessionKey, SessionKeyResolutionError, parseUploadSessionKeyInput, type SessionKeyInput } from '../session-key'; +import { resolveSessionKey, resolveUploadSessionKey, resolveOutputBucketSessionKey, SessionKeyResolutionError, parseUploadSessionKeyInput, type SessionKeyInput } from '../session-key'; import { pyQueue, otherQueue, pyQueueEvents, otherQueueEvents, queueNames, connection } from '../queue'; import { sleep, getAxiosErrorDetails, publicExecutionFailure } from '../utils'; import { env, jobCompletionWaitTimeoutMs, planLimits, resolveLanguage } from '../config'; import { createPayload } from '../payload'; import { summarizeRequestedFiles } from '../execution-log'; -import { getCredentialId, getPrincipalOrReject } from '../auth/principal'; +import { getCredentialId, getPrincipalOrReject, type CodeApiPrincipal } from '../auth/principal'; +import { + OWNER_BINDING_HEADER, + OWNER_EXPECT_HEADER, + agentRunLogFields, + ownerBindingValue, + sessionKeyForLog, + signOwnerBinding, +} from '../agent-run'; import { isSyntheticPrincipalSource } from '../auth/synthetic'; import { getExecutionIdentity } from '../execution-identity'; import { resolveRuntimeSessionIdForExecRequest, RuntimeSessionHintError } from '../runtime-session/id'; @@ -44,7 +52,7 @@ const UPLOAD_TIMEOUT_MS = 30_000; * caller. */ const MAX_BATCH_FILES = 200; -function validateUploadRequest(req: t.AuthenticatedRequest, res: Response): string | null { +function validateUploadRequest(req: t.AuthenticatedRequest, res: Response): CodeApiPrincipal | null { const principal = getPrincipalOrReject(req, res); if (!principal) return null; if (req.headers['content-type']?.includes('multipart/form-data') !== true) { @@ -59,7 +67,30 @@ function validateUploadRequest(req: t.AuthenticatedRequest, res: Response): stri res.status(503).json({ error: 'Service is starting up' }); return null; } - return principal.userId; + return principal; +} + +/** `User ID: ` for people; kind plus hashed ids for an agent_run. */ +function uploadSubjectLabel(principal: CodeApiPrincipal): string { + if (!principal.agentRun) return `User ID: ${principal.userId}`; + const fields = agentRunLogFields(principal.tenantId, principal.agentRun); + return `Subject: agent_run | Tenant: ${fields.tenantHash} | Agent: ${fields.agentHash} | Run: ${fields.runHash}`; +} + +/** + * C4 at upload: objects in an agent_run's private key carry a binding to the + * verified (tenant, Agent, run), written by the api for the file server. + * Nothing from the request body or headers feeds it. + */ +function ownerBindingHeaders( + principal: CodeApiPrincipal, + input: SessionKeyInput, + sessionId: string, + fileId: string, +): Record { + if (!principal.agentRun || input.kind !== 'agent') return {}; + const signed = signOwnerBinding(sessionId, fileId, ownerBindingValue(principal.tenantId, principal.agentRun)); + return signed ? { [OWNER_BINDING_HEADER]: signed } : {}; } function sendFileRefAuthorizationError( @@ -68,6 +99,19 @@ function sendFileRefAuthorizationError( req?: t.AuthenticatedRequest, ): boolean { if (error instanceof FileRefAuthorizationError) { + const agentRun = req?.codeApiAuthContext?.agentRun; + if (agentRun) { + /* The rejection context carries resource ids and session keys that + * embed the raw run id; an agent_run rejection logs hashes only. */ + logger.warn('File reference authorization rejected', { + status: error.status, + reason: error.reason, + message: error.message, + ...agentRunLogFields(req?.codeApiAuthContext?.tenantId, agentRun), + }); + res.status(error.status).json({ error: error.message }); + return true; + } const queryEntityId = typeof req?.query?.entity_id === 'string' ? req.query.entity_id : undefined; logger.warn('File reference authorization rejected', { status: error.status, @@ -102,14 +146,19 @@ function sendSessionKeyResolutionError( context: string, ): boolean { if (error instanceof SessionKeyResolutionError) { + const agentRun = req.codeApiAuthContext?.agentRun; logger.error(`[${INSTANCE_ID}] sessionKey resolution failed (${context})`, { status: error.status, message: error.message, method: req.method, path: req.path, - requestUserId: req.codeApiAuthContext?.userId, - authContextUserId: req.codeApiAuthContext?.userId, - tenantId: req.codeApiAuthContext?.tenantId, + ...(agentRun + ? agentRunLogFields(req.codeApiAuthContext?.tenantId, agentRun) + : { + requestUserId: req.codeApiAuthContext?.userId, + authContextUserId: req.codeApiAuthContext?.userId, + tenantId: req.codeApiAuthContext?.tenantId, + }), }); res.status(error.status).json({ error: error.message }); return true; @@ -125,6 +174,7 @@ router.post('/exec', executionLimiter, async (req: t.AuthenticatedRequest, res) const apiKeyId = getCredentialId(req); const userId = principal.userId; const identity = getExecutionIdentity(req, userId); + const agentRun = identity.agentRun; const isSyntheticRequest = isSyntheticPrincipalSource(identity.principalSource); if (checkServiceShutDown()) { @@ -144,13 +194,21 @@ router.post('/exec', executionLimiter, async (req: t.AuthenticatedRequest, res) let runtimeSessionId: string | undefined; try { - runtimeSessionId = resolveRuntimeSessionIdForExecRequest({ - mode: env.RUNTIME_SESSION_MODE, - storageNamespace: identity.storageNamespace, - canonicalUserId: identity.canonicalUserId, - runtimeSessionHint: body.runtime_session_hint, - isSynthetic: isSyntheticRequest, - }); + runtimeSessionId = resolveRuntimeSessionIdForExecRequest(agentRun + ? { + mode: env.RUNTIME_SESSION_MODE, + storageNamespace: identity.storageNamespace, + agentRun, + runtimeSessionHint: body.runtime_session_hint, + isSynthetic: isSyntheticRequest, + } + : { + mode: env.RUNTIME_SESSION_MODE, + storageNamespace: identity.storageNamespace, + canonicalUserId: identity.canonicalUserId, + runtimeSessionHint: body.runtime_session_hint, + isSynthetic: isSyntheticRequest, + }); } catch (error) { if (error instanceof RuntimeSessionHintError) { return res.status(error.status).json({ error: error.message }); @@ -200,15 +258,24 @@ router.post('/exec', executionLimiter, async (req: t.AuthenticatedRequest, res) try { if (!isSyntheticRequest) { - logger.info('Request received', { - userId, - apiKeyId, - user: user_id, - session_id, - language, - files: summarizeRequestedFiles(authorizedFiles), - sessionKey, - }); + logger.info('Request received', agentRun + ? { + ...agentRunLogFields(identity.storageNamespace, agentRun), + apiKeyId, + session_id, + language, + files: summarizeRequestedFiles(authorizedFiles), + sessionKey: sessionKeyForLog(sessionKey), + } + : { + userId, + apiKeyId, + user: user_id, + session_id, + language, + files: summarizeRequestedFiles(authorizedFiles), + sessionKey, + }); } const isPyPlot = language === Languages.py && (code.includes('import matplotlib') || code.includes('import seaborn')); @@ -220,7 +287,7 @@ router.post('/exec', executionLimiter, async (req: t.AuthenticatedRequest, res) const sandboxSecurity = prepareSandboxJobSecurity({ req, executionId: execution_id, - userId, + ...(agentRun ? { agentRun } : { userId }), sessionKey, outputSessionId: session_id, payload: rawPayload, @@ -239,7 +306,7 @@ router.post('/exec', executionLimiter, async (req: t.AuthenticatedRequest, res) const traceCarrier = captureTraceCarrier(); return queue.add(Jobs.execute, { code, - userId, + ...(agentRun ? {} : { userId }), payload: sandboxSecurity.payload, apiKeyId, isSynthetic: isSyntheticRequest, @@ -247,7 +314,7 @@ router.post('/exec', executionLimiter, async (req: t.AuthenticatedRequest, res) principalSource: identity.principalSource, executionId: execution_id, tenantId: identity.storageNamespace, - canonicalUserId: identity.canonicalUserId, + ...(agentRun ? { agentRun } : { canonicalUserId: identity.canonicalUserId }), executionProfile: env.EXECUTION_PROFILE, ...(runtimeSessionId != null ? { runtimeSessionId } : {}), runtimeSessionMode, @@ -351,8 +418,8 @@ router.get('/download/:session_id/:fileId', downloadLimiter, sessionAuth, async router.post('/upload', uploadLimiter, async (req: t.AuthenticatedRequest, res: Response) => { try { - const userId = validateUploadRequest(req, res); - if (userId == null) return; + const principal = validateUploadRequest(req, res); + if (principal == null) return; const session_id = nanoid(); /* `kind`/`id`/`version?` form fields drive the upload-bucket @@ -428,7 +495,7 @@ router.post('/upload', uploadLimiter, async (req: t.AuthenticatedRequest, res: R kind: uploadKind, id: uploadId, version: uploadVersionRaw, - authContextUserId: req.codeApiAuthContext?.userId ?? userId, + authContextUserId: req.codeApiAuthContext?.userId ?? principal.userId ?? '', }); } catch (err) { clearTimeout(uploadTimeout); @@ -439,7 +506,7 @@ router.post('/upload', uploadLimiter, async (req: t.AuthenticatedRequest, res: R let sessionKey: string; try { - sessionKey = resolveSessionKey(req, sessionKeyInput); + sessionKey = resolveUploadSessionKey(req, sessionKeyInput, readOnly); } catch (err) { clearTimeout(uploadTimeout); file.resume(); @@ -452,13 +519,14 @@ router.post('/upload', uploadLimiter, async (req: t.AuthenticatedRequest, res: R * Encoding here preserves `/` as `%2F` in transit and keeps * non-ASCII filenames legal as HTTP header values. */ 'X-Original-Filename': encodeURIComponent(filename), + ...ownerBindingHeaders(principal, sessionKeyInput, session_id, fileId), }; if (readOnly) { putHeaders['X-Read-Only'] = 'true'; } connection.set(`session:${session_id}`, sessionKey, 'EX', env.SESSION_CACHE_TTL) .then(() => { - logger.info(`[${INSTANCE_ID}] Upload: Session ID: ${session_id} | User ID: ${userId} | Session key: ${sessionKey}`); + logger.info(`[${INSTANCE_ID}] Upload: Session ID: ${session_id} | ${uploadSubjectLabel(principal)} | Session key: ${sessionKeyForLog(sessionKey)}`); return enqueueForward(() => forwardUploadToFileServer({ file, url: `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}`, @@ -513,7 +581,10 @@ router.post('/upload', uploadLimiter, async (req: t.AuthenticatedRequest, res: R } catch (error) { logger.error(`[${INSTANCE_ID}] Error uploading files for session ${session_id}:`, error); if (!res.headersSent) { - if (error instanceof Error) { + if (error instanceof SessionKeyResolutionError && error.status === 403) { + /* An agent_run upload outside its permitted kinds (C1). */ + res.status(403).json({ error: error.message }); + } else if (error instanceof Error) { if (error.message === 'Upload timeout') { res.status(504).json({ error: 'Upload timeout' }); } else { @@ -548,8 +619,8 @@ router.post('/upload', uploadLimiter, async (req: t.AuthenticatedRequest, res: R router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, res: Response) => { try { - const userId = validateUploadRequest(req, res); - if (userId == null) return; + const principal = validateUploadRequest(req, res); + if (principal == null) return; const session_id = nanoid(); /* `kind`/`id`/`version?` form fields drive the batch's sessionKey @@ -572,9 +643,11 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, /* Redis registration is also a batch-level dependency fault. Keep file * promises fulfilled while Busboy drains, then surface one 500. */ let sessionRegistrationError: Error | undefined; + /* agent_run uploads outside the permitted kinds (C1) refuse the batch. */ + let forbiddenError: SessionKeyResolutionError | undefined; const ensureSessionRegistered = createUploadSessionRegistrar((sessionKey) => { - logger.info(`[${INSTANCE_ID}] Batch upload: Session ID: ${session_id} | User ID: ${userId} | Session key: ${sessionKey}`); + logger.info(`[${INSTANCE_ID}] Batch upload: Session ID: ${session_id} | ${uploadSubjectLabel(principal)} | Session key: ${sessionKeyForLog(sessionKey)}`); return connection.set(`session:${session_id}`, sessionKey, 'EX', env.SESSION_CACHE_TTL); }); @@ -642,7 +715,7 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, kind: uploadKind, id: uploadId, version: uploadVersionRaw, - authContextUserId: req.codeApiAuthContext?.userId ?? userId, + authContextUserId: req.codeApiAuthContext?.userId ?? principal.userId ?? '', }); } catch (err) { clearTimeout(uploadTimeout); @@ -654,7 +727,7 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, let sessionKey: string; try { - sessionKey = resolveSessionKey(req, sessionKeyInput); + sessionKey = resolveUploadSessionKey(req, sessionKeyInput, readOnly); } catch (err) { clearTimeout(uploadTimeout); file.resume(); @@ -665,6 +738,9 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, if (err instanceof SessionKeyResolutionError && err.status === 500 && !serverError) { serverError = err; } + if (err instanceof SessionKeyResolutionError && err.status === 403) { + forbiddenError ??= err; + } const message = err instanceof Error ? err.message : 'Failed to resolve sessionKey'; resolve({ status: 'error', filename, error: message }); return; @@ -675,6 +751,7 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, * Encoding here preserves `/` as `%2F` in transit and keeps * non-ASCII filenames legal as HTTP header values. */ 'X-Original-Filename': encodeURIComponent(filename), + ...ownerBindingHeaders(principal, sessionKeyInput, session_id, fileId), }; if (readOnly) { putHeaders['X-Read-Only'] = 'true'; @@ -761,6 +838,11 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, return; } + if (forbiddenError) { + res.status(403).json({ error: forbiddenError.message }); + return; + } + if (results.length === 0) { res.status(400).json({ error: 'No files provided' }); return; @@ -878,11 +960,16 @@ router.get('/sessions/:session_id/objects/:fileId', fetchLimiter, sessionAuth, a router.delete('/files/:session_id/:fileId', fetchLimiter, sessionAuth, async (req: t.AuthenticatedRequest, res: Response) => { const { session_id, fileId } = req.params; + const agentRun = req.codeApiAuthContext?.agentRun; try { const response = await axios.delete( `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}`, - { headers: internalServiceHeaders() } + { + headers: internalServiceHeaders( + req.ownerBindingExpectation ? { [OWNER_EXPECT_HEADER]: req.ownerBindingExpectation } : {}, + ), + }, ); await connection.del(`upload:${req.sessionKey}${session_id}${fileId}`); @@ -891,6 +978,16 @@ router.delete('/files/:session_id/:fileId', fetchLimiter, sessionAuth, async (re } catch (error) { const errorDetails = getAxiosErrorDetails(error); logger.error(`[${INSTANCE_ID}] Error deleting file - Session ID: ${session_id} | File ID: ${fileId}:`, errorDetails); + /* agent_run deletion outcomes are explicit: not-found only when the + * scoped bytes are actually absent, a refused owner binding stays a + * refusal, anything else is a failure, never success. */ + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + if (agentRun && status === 404) { + return res.status(404).json({ error: 'File not found' }); + } + if (agentRun && status === 403) { + return res.status(403).json({ error: 'Unauthorized' }); + } return res.status(500).json({ error: 'Error deleting file', }); diff --git a/service/src/session-key.ts b/service/src/session-key.ts index 9abc5c1c..e050601f 100644 --- a/service/src/session-key.ts +++ b/service/src/session-key.ts @@ -1,6 +1,7 @@ import { CODE_ENV_KINDS } from './types'; import type { AuthenticatedRequest, CodeEnvKind } from './types'; import { getExecutionIdentity, resolveStorageNamespace } from './execution-identity'; +import { agentRunSessionKey, type AgentRunSubject } from './agent-run'; /* Read directly from `process.env` (not the snapshotted `env` object) * so test suites can flip the flag between cases without module-cache @@ -31,8 +32,8 @@ export interface SessionKeyInput { } export class SessionKeyResolutionError extends Error { - readonly status: 400 | 500; - constructor(status: 400 | 500, message: string) { + readonly status: 400 | 403 | 500; + constructor(status: 400 | 403 | 500, message: string) { super(message); this.name = 'SessionKeyResolutionError'; this.status = status; @@ -68,6 +69,10 @@ export function resolveSessionKey( input: SessionKeyInput, ): string { const storageNamespace = resolveSessionStorageNamespace(req); + const agentRun = getExecutionIdentity(req).agentRun; + if (agentRun) { + return resolveAgentRunSessionKey(storageNamespace, agentRun, input); + } switch (input.kind) { case 'skill': { @@ -113,7 +118,13 @@ export function resolveSessionKey( */ export function resolveOutputBucketSessionKey(req: AuthenticatedRequest): string { const storageNamespace = resolveSessionStorageNamespace(req); - const userId = getExecutionIdentity(req).canonicalUserId; + const identity = getExecutionIdentity(req); + /* agent_run outputs always land in the run's private key, never in a + * shared (skill/agent) or user namespace. */ + if (identity.agentRun) { + return agentRunSessionKey(storageNamespace, identity.agentRun); + } + const userId = identity.canonicalUserId; if (!userId) { throw new SessionKeyResolutionError( 500, @@ -123,6 +134,53 @@ export function resolveOutputBucketSessionKey(req: AuthenticatedRequest): string return `${storageNamespace}:user:${userId}`; } +/** + * The keys an agent_run principal may resolve. `kind: 'agent'` must name the + * signed run and maps to the private `:agent-run::` key, never the + * shared `:agent:` key user tokens resolve. `kind: 'skill'` keeps the + * shared tenant-wide skill key (reads and read-only priming). `kind: 'user'` + * has no meaning for an Agent and is refused. + */ +function resolveAgentRunSessionKey( + storageNamespace: string, + agentRun: AgentRunSubject, + input: SessionKeyInput, +): string { + switch (input.kind) { + case 'agent': + if (input.id !== agentRun.runId) { + throw new SessionKeyResolutionError(403, "agent_run: kind 'agent' id must be the signed run_id"); + } + return agentRunSessionKey(storageNamespace, agentRun); + case 'skill': + if (input.version == null) { + throw new SessionKeyResolutionError(400, "resolveSessionKey: kind 'skill' requires version"); + } + return `${storageNamespace}:skill:${input.id}:v:${input.version}`; + case 'user': + throw new SessionKeyResolutionError(403, "agent_run: kind 'user' is not available"); + default: { + const _exhaustive: never = input.kind; + throw new SessionKeyResolutionError(400, `unknown kind: ${_exhaustive as string}`); + } + } +} + +/** + * Upload-bucket key (C1). Same as `resolveSessionKey`, plus: an agent_run may + * write the shared skill namespace only as read-only input priming. + */ +export function resolveUploadSessionKey( + req: AuthenticatedRequest, + input: SessionKeyInput, + readOnly: boolean, +): string { + if (getExecutionIdentity(req).agentRun && input.kind === 'skill' && !readOnly) { + throw new SessionKeyResolutionError(403, "agent_run: kind 'skill' uploads require read_only=true"); + } + return resolveSessionKey(req, input); +} + /** * Parse `kind`/`id`/`version` upload form fields (or the equivalent * URL query params on download routes) into a validated diff --git a/service/src/test-support/route-harness.ts b/service/src/test-support/route-harness.ts index 5e0bbba9..38652d9d 100644 --- a/service/src/test-support/route-harness.ts +++ b/service/src/test-support/route-harness.ts @@ -22,6 +22,8 @@ export type CapturedLog = { level: string; message: string; meta?: unknown }; export type CapturedPut = { url: string; headers: Record; bytes: number }; export type CapturedFileServerCall = { method: string; url: string; headers: Record }; export type DeleteHandler = (req: IncomingMessage, key: string) => { status: number; body: unknown }; +/** Runs before the stub stores a PUT; a returned outcome refuses the write. */ +export type PutHandler = (headers: Record, sessionId: string, fileId: string) => { status: number; body: unknown } | undefined; /** Fixed Ed25519 seed so token bytes are reproducible across runs and trees. */ const SIGNING_SEED = Buffer.alloc(32, 7); @@ -137,6 +139,7 @@ export type ExecResultFactory = (jobData: Record) => Record>; logs: CapturedLog[]; @@ -144,6 +147,7 @@ export interface RouteHarness { fileServerCalls: CapturedFileServerCall[]; objects: Map }>; setDeleteHandler(handler: DeleteHandler | undefined): void; + setPutHandler(handler: PutHandler | undefined): void; close(): Promise; } @@ -201,6 +205,15 @@ export async function installRouteHarness(options: { queueNames: { python: 'python', other: 'other' }, })); + /* Everything this harness changes in process.env / env is restored on close + * so sibling test files in the same bun process see their own settings. */ + const savedProcessEnv = new Map(); + for (const key of Object.keys(process.env).filter(name => name.startsWith('CODEAPI_'))) { + savedProcessEnv.set(key, process.env[key]); + } + for (const key of ['CODEAPI_AUTH_PROVIDER', 'CODEAPI_INTERNAL_SERVICE_TOKEN', 'CODEAPI_TENANT_ISOLATION_STRICT', 'CODEAPI_JWT_ISSUER', 'CODEAPI_JWT_AUDIENCE', 'CODEAPI_JWT_ALLOWED_ALGS', 'CODEAPI_JWT_CLOCK_SKEW_SECONDS', 'CODEAPI_JWT_MAX_TTL_SECONDS', 'CODEAPI_JWT_KEY_CACHE_TTL_SECONDS', 'CODEAPI_JWT_JWKS_JSON']) { + if (!savedProcessEnv.has(key)) savedProcessEnv.set(key, process.env[key]); + } configureJwtEnv(options.jwksJson); process.env.CODEAPI_INTERNAL_SERVICE_TOKEN = INTERNAL_TOKEN; @@ -210,6 +223,7 @@ export async function installRouteHarness(options: { const fileServerCalls: CapturedFileServerCall[] = []; const objects = new Map }>(); let deleteHandler: DeleteHandler | undefined; + let putHandler: PutHandler | undefined; const fileServer = createServer(async (req, res) => { const url = req.url ?? ''; const headers = headerRecord(req); @@ -227,6 +241,8 @@ export async function installRouteHarness(options: { for await (const chunk of req) chunks.push(chunk as Buffer); const bytes = Buffer.concat(chunks); puts.push({ url, headers, bytes: bytes.length }); + const refused = putHandler?.(headers, sessionId, fileId); + if (refused) return send(refused.status, refused.body); objects.set(key, { bytes, headers }); const sessionKey = await redis.get(`session:${sessionId}`); await redis.set(`upload:${sessionKey}${sessionId}${fileId}`, 'true', 'EX', 86400); @@ -255,6 +271,10 @@ export async function installRouteHarness(options: { const fileServerPort = await listen(fileServer); const { env } = await import(`${options.srcDir}/config`); + const savedEnv = Object.fromEntries( + ['FILE_SERVER_URL', 'LOCAL_MODE', 'MAX_UPLOAD_CHECKS', 'MAX_UPLOAD_WAIT', 'EGRESS_GRANT_SECRET', 'EGRESS_GATEWAY_URL', 'RUNTIME_SESSION_MODE'] + .map(key => [key, env[key]]), + ); env.FILE_SERVER_URL = `http://127.0.0.1:${fileServerPort}`; env.LOCAL_MODE = false; env.MAX_UPLOAD_CHECKS = 1; @@ -287,6 +307,7 @@ export async function installRouteHarness(options: { return { baseUrl: `http://127.0.0.1:${apiPort}`, + fileServerUrl: `http://127.0.0.1:${fileServerPort}`, redis, jobs, logs, @@ -296,8 +317,16 @@ export async function installRouteHarness(options: { setDeleteHandler(handler) { deleteHandler = handler; }, + setPutHandler(handler) { + putHandler = handler; + }, async close() { await Promise.all([closeServer(apiServer), closeServer(fileServer)]); + Object.assign(env, savedEnv); + for (const [key, value] of savedProcessEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } }, }; } diff --git a/service/src/types/service.ts b/service/src/types/service.ts index 2abcbd56..9d5ef9fc 100644 --- a/service/src/types/service.ts +++ b/service/src/types/service.ts @@ -7,6 +7,7 @@ import type { ExecutionProfile } from '../execution-profile'; import type { ExternalFetchPolicySnapshot } from '../external-fetch-policy'; import type { PackageSetupSummary } from '../../../shared/package-transport'; import { Jobs } from '@/enum/service'; +import type { AgentRunSubject } from '../agent-run'; /** * Per-file vs. top-level session distinction @@ -253,7 +254,8 @@ export type RuntimeSessionExemption = 'programmatic'; export type JobData = { code: string; - userId: string; + /** Personal subject. Absent for agent_run jobs, which carry `agentRun`. */ + userId?: string; apiKeyId: string; principalSource?: string; isSynthetic?: boolean; @@ -262,6 +264,8 @@ export type JobData = { executionId?: string; tenantId?: string; canonicalUserId?: string; + /** agent_run subject (Agent Mongo id + run id); never mirrored into userId. */ + agentRun?: AgentRunSubject; /** Producer deployment identity. Optional only for pre-profile queued jobs. */ executionProfile?: ExecutionProfile; /** @@ -295,7 +299,9 @@ export type JobResult = ExecuteResult; export type ExecuteJob = Job; export interface CodeApiAuthContext { - userId: string; + /** Personal subject. Absent for agent_run, whose subject is `agentRun`. */ + userId?: string; + agentRun?: AgentRunSubject; /** Multi-tenant prefix used by `resolveSessionKey`. Optional because * single-tenant deploys may not populate it; `TENANT_ISOLATION_STRICT` * rejects requests missing this field, otherwise `'legacy'` is used. */ @@ -314,6 +320,10 @@ export interface AuthenticatedRequest extends Request { planId?: string; executionIdentity?: ExecutionIdentity; codeApiAuthContext?: CodeApiAuthContext; + /** Set by sessionAuth when a deletion-only agent_run token authorizes a + * DELETE against the durable owner binding because the session cache entry + * has expired. The file server deletes only if the stored binding matches. */ + ownerBindingExpectation?: string; codeApiPrincipal?: CodeApiPrincipal; } diff --git a/service/src/workers.ts b/service/src/workers.ts index ebc412c5..c8d46b04 100644 --- a/service/src/workers.ts +++ b/service/src/workers.ts @@ -182,7 +182,10 @@ async function processJobInner(job: t.ExecuteJob): Promise { signal: controller.signal, deadlineAtMs, tenantId: job.data.tenantId, - canonicalUserId: job.data.canonicalUserId, + /* agent_run jobs carry their Agent subject; they have no user id. */ + ...(job.data.agentRun + ? { agentRun: job.data.agentRun } + : { canonicalUserId: job.data.canonicalUserId }), runtimeSessionId: runtimeSession.runtimeSessionId, runtimeSessionMode: runtimeSession.runtimeSessionMode, /* Stateful backends run this as a commit barrier after user code but From 3a0b16b11c66ba3c46cb0e02ebdfd486203d23d5 Mon Sep 17 00:00:00 2001 From: Thor Haaland Date: Sun, 4 Oct 2026 21:31:55 +0000 Subject: [PATCH 3/4] fix(auth): agent_run only on the hardened sandbox path; refuse single-upload kind=user with 403 Security review of 850dbae (P1): agent_run tokens are refused with 401 (reason agent_run_unavailable) unless CODEAPI_HARDENED_SANDBOX_MODE and the internal service token are both configured, so output owner bindings are always written by the gateway from the sealed grant. Personal tokens are unaffected. P3: /upload and /upload/batch refuse agent_run kind=user with 403 even without an id. --- service/src/agent-run.test.ts | 31 ++++++++++++++++++++++++++++++- service/src/middleware/auth.ts | 8 ++++++++ service/src/service/router.ts | 9 +++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/service/src/agent-run.test.ts b/service/src/agent-run.test.ts index 986bb3e7..a9f4ba24 100644 --- a/service/src/agent-run.test.ts +++ b/service/src/agent-run.test.ts @@ -34,6 +34,7 @@ import { continuationSubjectMatches, replaySessionKey, type ExecutionState } fro import { buildReplayExecutionState } from './service/programmatic-state'; import { getExecutionIdentity } from './execution-identity'; import type * as t from './types'; +import { env } from './config'; const TENANT = 'tenant-a'; const OTHER_TENANT = 'tenant-b'; @@ -47,6 +48,7 @@ const BOUND_KID = 'bound-kid'; const SKILL_ID = '65f0c0ffee0000000000beef'; let harness: RouteHarness; +let savedHardened = false; const bindings = new Map(); function nowSeconds(): number { @@ -143,6 +145,8 @@ beforeAll(async () => { /* The stub file server applies the real file server's owner-binding rules * through the same exported helper: only a signed binding for this exact * object is stored; owner-bound deletion requires an exact match. */ + savedHardened = env.HARDENED_SANDBOX_MODE; + env.HARDENED_SANDBOX_MODE = true; harness.setPutHandler((headers, sid, fid) => { const owner = ownerBindingFromHeader(headers[OWNER_BINDING_HEADER.toLowerCase()], sid, fid); if (!owner.ok) return { status: 400, body: { error: owner.error } }; @@ -163,6 +167,7 @@ beforeAll(async () => { }); afterAll(async () => { + env.HARDENED_SANDBOX_MODE = savedHardened; await harness?.close(); }); @@ -408,7 +413,8 @@ describe('agent_run routes', () => { test('C1: upload kind=user is refused for agent_run', async () => { const putsBefore = harness.puts.length; - expect((await agentUpload({ kind: 'user' })).status).not.toBe(200); + expect((await agentUpload({ kind: 'user' })).status).toBe(403); + expect((await agentUpload({ kind: 'user' }, agentToken(), '/v1/upload/batch')).status).toBe(403); expect((await agentUpload({ kind: 'user', id: AGENT })).status).toBe(403); expect((await agentUpload({ kind: 'user', id: AGENT }, agentToken(), '/v1/upload/batch')).status).toBe(403); expect(harness.puts.length).toBe(putsBefore); @@ -671,3 +677,26 @@ describe('deletion-only tokens (C3) and durable owner binding (C4)', () => { }); }); }); + +describe('agent_run availability (hardened sandbox + internal service auth)', () => { + test('agent_run is refused with 401 unless hardened mode and internal service auth are both on; personal is unaffected', async () => { + const path = `/v1/files/${'s'.repeat(21)}?kind=agent&id=${RUN1}`; + const savedToken = process.env.CODEAPI_INTERNAL_SERVICE_TOKEN; + try { + env.HARDENED_SANDBOX_MODE = false; + expect((await call(harness.baseUrl, agentToken(), 'GET', path)).status).toBe(401); + expect((await call(harness.baseUrl, userToken(), 'GET', `/v1/files/${'s'.repeat(21)}?kind=user`)).status).toBe(403); + env.HARDENED_SANDBOX_MODE = true; + delete process.env.CODEAPI_INTERNAL_SERVICE_TOKEN; + expect((await call(harness.baseUrl, agentToken(), 'GET', path)).status).toBe(401); + expect((await call(harness.baseUrl, userToken(), 'GET', `/v1/files/${'s'.repeat(21)}?kind=user`)).status).toBe(403); + process.env.CODEAPI_INTERNAL_SERVICE_TOKEN = savedToken; + /* Both on: the token is accepted (403 here is the session-key refusal past auth). */ + expect((await call(harness.baseUrl, agentToken(), 'GET', path)).status).toBe(403); + expect(harness.logs.some(l => l.message.includes('agent_run_unavailable'))).toBe(true); + } finally { + env.HARDENED_SANDBOX_MODE = true; + process.env.CODEAPI_INTERNAL_SERVICE_TOKEN = savedToken; + } + }); +}); diff --git a/service/src/middleware/auth.ts b/service/src/middleware/auth.ts index 5e02e4e5..c087d1fb 100644 --- a/service/src/middleware/auth.ts +++ b/service/src/middleware/auth.ts @@ -8,6 +8,7 @@ import { LibreChatJwtAuthProvider, CodeApiJwtAuthError } from '../auth/librechat import { applyPrincipal, type AgentRunPrincipal, type CodeApiPrincipal } from '../auth/principal'; import { applyLocalPrincipal } from '../auth/local'; import { agentRunLogFields, ownerBindingValue, sessionKeyForLog } from '../agent-run'; +import { internalServiceAuthEnabled } from '../internal-service-auth'; import { AuthProviderConfigError, getAuthProviderMode } from '../auth/provider'; import { authenticateSyntheticRequest, @@ -189,6 +190,13 @@ export const apiKeyAuth = async ( logger.warn('CodeAPI auth provider returned no principal', authLogMeta(req, { mode })); return res.status(401).json({ error: 'Authentication is required' }); } + /* agent_run relies on the hardened sandbox path: the egress gateway writes + * output owner bindings from the sealed grant, and internal service auth + * keys those bindings. Without both, agent_run is unavailable. */ + if (principal.agentRun && !(env.HARDENED_SANDBOX_MODE && internalServiceAuthEnabled())) { + logger.warn('JWT auth failure request: agent_run_unavailable', authLogMeta(req, { mode, reason: 'agent_run_unavailable' })); + return res.status(401).json({ error: 'Invalid bearer token' }); + } applyPrincipal(req, principal); if (principal.agentRun?.fileDelete && !fileDeleteRequestMatches(req, principal)) { logger.warn( diff --git a/service/src/service/router.ts b/service/src/service/router.ts index 8719825b..751596fd 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -491,6 +491,9 @@ router.post('/upload', uploadLimiter, async (req: t.AuthenticatedRequest, res: R let sessionKeyInput: SessionKeyInput; try { + if (principal.agentRun && uploadKind === 'user') { + throw new SessionKeyResolutionError(403, "agent_run: kind 'user' is not available"); + } sessionKeyInput = parseUploadSessionKeyInput({ kind: uploadKind, id: uploadId, @@ -711,6 +714,9 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, let sessionKeyInput: SessionKeyInput; try { + if (principal.agentRun && uploadKind === 'user') { + throw new SessionKeyResolutionError(403, "agent_run: kind 'user' is not available"); + } sessionKeyInput = parseUploadSessionKeyInput({ kind: uploadKind, id: uploadId, @@ -720,6 +726,9 @@ router.post('/upload/batch', uploadLimiter, async (req: t.AuthenticatedRequest, } catch (err) { clearTimeout(uploadTimeout); file.resume(); + if (err instanceof SessionKeyResolutionError && err.status === 403) { + forbiddenError ??= err; + } const message = err instanceof Error ? err.message : 'Invalid upload identity'; resolve({ status: 'error', filename, error: message }); return; From e5f925fa5054b8943f83145fb5b25015edd2ce4f Mon Sep 17 00:00:00 2001 From: Thor Haaland Date: Sun, 4 Oct 2026 21:53:58 +0000 Subject: [PATCH 4/4] fix(file-server): owner-checked delete is its own operation; parity header maps; typed test assertions Foundation review of 3a0b16b (P1-1): an owner-bound deletion forwarded as a plain DELETE with X-CodeAPI-Owner-Expect would be executed by a file server that predates owner bindings, so a token for another tenant could delete after the session cache expired. Owner-bound deletes now go only to the new POST /sessions/:sid/objects/:fid/owner-delete operation, which refuses a request without an expected owner and an object without a matching stored binding. The api treats every outcome other than an explicit 2xx deleted/absent as a refusal, so an old file server's 404/405 leaves the bytes in place; there is no fallback to the plain delete. Personal and cache-present agent_run deletes keep the plain route, which is back to its e9ad47c7 form. P1-2: the parity comparison sorts HTTP header maps (the only unordered input) on both sides; the golden is unchanged. P2: agent_run tests assert typed reasons, statuses and side effects instead of error wording. --- service/src/agent-run.test.ts | 132 ++++++++++++++-------- service/src/agent-run.ts | 9 ++ service/src/file-server.ts | 57 +++++++--- service/src/personal-parity.test.ts | 17 ++- service/src/service/router.ts | 58 +++++++--- service/src/test-support/route-harness.ts | 21 ++-- 6 files changed, 209 insertions(+), 85 deletions(-) diff --git a/service/src/agent-run.test.ts b/service/src/agent-run.test.ts index a9f4ba24..e2c84cf7 100644 --- a/service/src/agent-run.test.ts +++ b/service/src/agent-run.test.ts @@ -100,7 +100,7 @@ function jwtReason(token: string): string { verifyLibreChatJwt(token); return 'accepted'; } catch (error) { - if (error instanceof CodeApiJwtAuthError) return `${error.reason}: ${error.message}`; + if (error instanceof CodeApiJwtAuthError) return error.reason; throw error; } } @@ -133,6 +133,18 @@ function uploadedRef(result: { body: unknown }): { sid: string; fid: string } { return { sid: body.storage_session_id, fid: body.files[0].fileId }; } +/** The new file server's owner-checked delete, applied to the stub's objects. */ +const newFileServerOwnerDelete = (req: { headers: Record }, key: string) => { + const expected = req.headers['x-codeapi-owner-expect']; + if (typeof expected !== 'string' || !/^agent_run\.[0-9a-f]{64}$/.test(expected)) return { status: 400, body: { error: 'Expected owner is required' } }; + if (!harness.objects.has(key)) return { status: 200, body: { outcome: 'absent' } }; + const stored = bindings.get(key); + if (!stored || stored !== expected) return { status: 403, body: { error: 'Owner binding does not match' } }; + harness.objects.delete(key); + bindings.delete(key); + return { status: 200, body: { outcome: 'deleted' } }; +}; + beforeAll(async () => { harness = await installRouteHarness({ queueModulePath: join(import.meta.dir, 'queue.ts'), @@ -153,17 +165,7 @@ beforeAll(async () => { if (owner.binding) bindings.set(`${sid}/${fid}`, owner.binding); return undefined; }); - harness.setDeleteHandler((req, key) => { - if (!harness.objects.has(key)) return { status: 404, body: { error: 'File not found' } }; - const expected = req.headers['x-codeapi-owner-expect']; - if (expected !== undefined) { - const stored = bindings.get(key); - if (!stored || stored !== expected) return { status: 403, body: { error: 'Owner binding does not match' } }; - } - harness.objects.delete(key); - bindings.delete(key); - return { status: 200, body: { message: 'File deleted successfully' } }; - }); + harness.setOwnerDeleteHandler(newFileServerOwnerDelete); }); afterAll(async () => { @@ -192,52 +194,52 @@ describe('agent_run claim grammar', () => { for (const strict of [undefined, 'true', 'false']) { if (strict === undefined) delete process.env.CODEAPI_TENANT_ISOLATION_STRICT; else process.env.CODEAPI_TENANT_ISOLATION_STRICT = strict; - expect(jwtReason(agentToken({ tenant_id: undefined }))).toBe('malformed_claims: tenant_id is required for agent_run'); - expect(jwtReason(agentToken({ tenant_id: '' }))).toBe('malformed_claims: tenant_id is required for agent_run'); + expect(jwtReason(agentToken({ tenant_id: undefined }))).toBe('malformed_claims'); + expect(jwtReason(agentToken({ tenant_id: '' }))).toBe('malformed_claims'); } }); test('malformed agent_run values are refused, never normalized', () => { - const cases: Array<[Record, string]> = [ - [{ sub: 'agent_AbCdEfGhIjK' }, 'sub must be a canonical Agent id'], - [{ sub: AGENT.toUpperCase() }, 'sub must be a canonical Agent id'], - [{ sub: `${AGENT}0` }, 'sub must be a canonical Agent id'], - [{ run_id: undefined }, 'run_id must be a canonical UUID'], - [{ run_id: RUN1.toUpperCase() }, 'run_id must be a canonical UUID'], - [{ run_id: RUN1.replace(/-/g, '') }, 'run_id must be a canonical UUID'], - [{ run_id: `${RUN1}:x` }, 'run_id must be a canonical UUID'], - [{ tenant_id: 'tenant:a' }, 'tenant_id is not canonical'], - [{ tenant_id: ' tenant-a' }, 'tenant_id is not canonical'], - [{ role: 'USER' }, 'role must be AGENT'], - [{ role: undefined }, 'role must be AGENT'], - [{ org_id: 'org_1' }, 'org_id is not accepted for agent_run'], - [{ service_id: 'svc_1' }, 'service_id is not accepted for agent_run'], - [{ external_user_id: 'ext_1' }, 'external_user_id is not accepted for agent_run'], - [{ chc_user_id: 'chc_1' }, 'chc_user_id is not accepted for agent_run'], // leak-check:allow - [{ plan_id: 'pro' }, 'plan_id is not accepted for agent_run'], - [{ auth_context_hash: undefined }, 'auth_context_hash is required'], - [{ file_delete: { storage_session_id: 'a'.repeat(21) } }, 'file_delete must hold exactly'], - [{ file_delete: { storage_session_id: 'a'.repeat(21), file_id: 'b'.repeat(21), kind: 'agent' } }, 'file_delete must hold exactly'], - [{ file_delete: { storage_session_id: 'short', file_id: 'b'.repeat(21) } }, 'file_delete target is not a storage object id'], - [{ file_delete: 'x' }, 'file_delete must hold exactly'], + /* Each case changes one field of a token that verifies (first test), so the + * refusal is attributable to that field. */ + const cases: Array> = [ + { sub: 'agent_AbCdEfGhIjK' }, + { sub: AGENT.toUpperCase() }, + { sub: `${AGENT}0` }, + { run_id: undefined }, + { run_id: RUN1.toUpperCase() }, + { run_id: RUN1.replace(/-/g, '') }, + { run_id: `${RUN1}:x` }, + { tenant_id: 'tenant:a' }, + { tenant_id: ' tenant-a' }, + { role: 'USER' }, + { role: undefined }, + { org_id: 'org_1' }, + { service_id: 'svc_1' }, + { external_user_id: 'ext_1' }, + { chc_user_id: 'chc_1' }, // leak-check:allow + { plan_id: 'pro' }, + { auth_context_hash: undefined }, + { file_delete: { storage_session_id: 'a'.repeat(21) } }, + { file_delete: { storage_session_id: 'a'.repeat(21), file_id: 'b'.repeat(21), kind: 'agent' } }, + { file_delete: { storage_session_id: 'short', file_id: 'b'.repeat(21) } }, + { file_delete: 'x' }, ]; - for (const [overrides, message] of cases) { - expect(jwtReason(agentToken(overrides))).toContain(message); + expect(jwtReason(agentToken())).toBe('accepted'); + for (const overrides of cases) { + expect([overrides, jwtReason(agentToken(overrides))]).toEqual([overrides, 'malformed_claims']); } }); test('#18: a key bound to tenants signs agent_run only for those tenants', () => { const boundKey = testSigningKey(Buffer.alloc(32, 9)); expect(jwtReason(signTestJwt(agentClaims(), boundKey, BOUND_KID))).toBe('accepted'); - expect(jwtReason(signTestJwt(agentClaims({ tenant_id: OTHER_TENANT }), boundKey, BOUND_KID))).toBe( - 'tenant_not_allowed: JWT tenant is not allowed for this key', - ); + expect(jwtReason(signTestJwt(agentClaims({ tenant_id: OTHER_TENANT }), boundKey, BOUND_KID))).toBe('tenant_not_allowed'); }); test('a personal token carrying file_delete is refused, not ignored', () => { - expect(jwtReason(userToken({ file_delete: { storage_session_id: 'a'.repeat(21), file_id: 'b'.repeat(21) } }))).toBe( - 'malformed_claims: file_delete is only accepted for agent_run', - ); + expect(jwtReason(userToken())).toBe('accepted'); + expect(jwtReason(userToken({ file_delete: { storage_session_id: 'a'.repeat(21), file_id: 'b'.repeat(21) } }))).toBe('malformed_claims'); }); test('a personal token with an Agent-looking sub stays a personal principal', () => { @@ -375,7 +377,7 @@ describe('agent_run identity consumers (C5)', () => { test('replay output key: agent_run state requires its persisted private key, no userId fallback', () => { const agentState = { execution_id: 'e', session_id: 's', agentRun: { agentId: AGENT, runId: RUN1 }, userId: AGENT } as ExecutionState; - expect(() => replaySessionKey(agentState)).toThrow('no session key'); + expect(() => replaySessionKey(agentState)).toThrow(Error); expect(replaySessionKey({ ...agentState, sessionKey: 'k' })).toBe('k'); expect(replaySessionKey({ execution_id: 'e', session_id: 's', userId: 'legacy-key' } as ExecutionState)).toBe('legacy-key'); }); @@ -634,12 +636,48 @@ describe('deletion-only tokens (C3) and durable owner binding (C4)', () => { expect(deleted.status).toBe(200); expect(harness.objects.has(`${sid}/${fid}`)).toBe(false); expect(bindings.has(`${sid}/${fid}`)).toBe(false); - const expectHeaders = harness.fileServerCalls.filter(c => c.method === 'DELETE' && c.url.endsWith(`/${sid}/objects/${fid}`)).map(c => c.headers['x-codeapi-owner-expect']); - expect(expectHeaders.at(-1)).toBe(ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN1 })); + const ownerDeletes = harness.fileServerCalls.filter(c => c.url.endsWith(`/${sid}/objects/${fid}/owner-delete`)); + expect(ownerDeletes.map(c => [c.method, c.headers['x-codeapi-owner-expect']]).at(-1)).toEqual(['POST', ownerBindingValue(TENANT, { agentId: AGENT, runId: RUN1 })]); + /* The binding path never used the plain delete. */ + expect(harness.fileServerCalls.some(c => c.method === 'DELETE' && c.url.endsWith(`/${sid}/objects/${fid}`))).toBe(false); expect((await call(harness.baseUrl, deletionToken(sid, fid), 'DELETE', path)).status).toBe(404); }); + test('mixed version: against a file server without the owner-checked operation, an expired-cache deletion is refused and deletes nothing', async () => { + const { sid, fid } = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })); + await harness.redis.del(`session:${sid}`); + const path = `/v1/files/${sid}/${fid}?kind=agent&id=${RUN1}`; + const callsBefore = harness.fileServerCalls.length; + harness.setOwnerDeleteHandler(undefined); + try { + /* Cross-tenant: a token for another tenant (unbound key) naming the same Agent, run and object. */ + const otherTenant = deletionToken(sid, fid, { tenant_id: OTHER_TENANT }); + expect((await call(harness.baseUrl, otherTenant, 'DELETE', path)).status).toBe(403); + /* Even the owner's own token cannot delete through an old file server. */ + expect((await call(harness.baseUrl, deletionToken(sid, fid), 'DELETE', path)).status).toBe(403); + } finally { + harness.setOwnerDeleteHandler(newFileServerOwnerDelete); + } + expect(harness.objects.has(`${sid}/${fid}`)).toBe(true); + expect(harness.fileServerCalls.slice(callsBefore).some(c => c.method === 'DELETE')).toBe(false); + /* Same case on the new file server: the other tenant is refused, the owner deletes. */ + expect((await call(harness.baseUrl, deletionToken(sid, fid, { tenant_id: OTHER_TENANT }), 'DELETE', path)).status).toBe(403); + expect(harness.objects.has(`${sid}/${fid}`)).toBe(true); + expect((await call(harness.baseUrl, deletionToken(sid, fid), 'DELETE', path)).status).toBe(200); + expect(harness.objects.has(`${sid}/${fid}`)).toBe(false); + }); + + test('reverse gap: an object stored without a binding (old file server or old gateway) cannot be deleted after expiry', async () => { + const sid = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })).sid; + const fid = 'v'.repeat(21); + /* An old file server ignores the binding header; an old gateway never sends it. Either way the object is unbound. */ + harness.objects.set(`${sid}/${fid}`, { bytes: Buffer.from('unbound output'), headers: {} }); + await harness.redis.del(`session:${sid}`); + expect((await call(harness.baseUrl, deletionToken(sid, fid), 'DELETE', `/v1/files/${sid}/${fid}?kind=agent&id=${RUN1}`)).status).toBe(403); + expect(harness.objects.has(`${sid}/${fid}`)).toBe(true); + }); + test('an object with no binding is never reported deleted through the binding path', async () => { const sid = uploadedRef(await agentUpload({ kind: 'agent', id: RUN1 })).sid; const fid = 'u'.repeat(21); diff --git a/service/src/agent-run.ts b/service/src/agent-run.ts index 648f5d6f..f3e6e88e 100644 --- a/service/src/agent-run.ts +++ b/service/src/agent-run.ts @@ -63,6 +63,15 @@ export function sessionKeyForLog(sessionKey export const OWNER_BINDING_HEADER = 'X-CodeAPI-Owner-Binding'; /** Internal api → file_server header: delete only if the stored binding equals this. */ export const OWNER_EXPECT_HEADER = 'X-CodeAPI-Owner-Expect'; +/** Shape of a stored binding (see `ownerBindingValue`). */ +export const OWNER_BINDING_PATTERN = /^agent_run\.[0-9a-f]{64}$/; +/** + * Last path segment of the file server's owner-checked delete operation, + * `POST /sessions/:sid/objects/:fid/owner-delete`. File servers that predate + * owner bindings have no such route and answer 404, so the api never falls + * back to a plain delete. + */ +export const OWNER_DELETE_OPERATION = 'owner-delete'; /** MinIO user-metadata name; stored with the object, so it lives exactly as long as the bytes. */ export const OWNER_METADATA = 'X-Amz-Meta-Codeapi-Owner'; /** How MinIO returns `OWNER_METADATA` from statObject. */ diff --git a/service/src/file-server.ts b/service/src/file-server.ts index fff6bfa7..46f404be 100644 --- a/service/src/file-server.ts +++ b/service/src/file-server.ts @@ -19,6 +19,8 @@ import { env } from './config'; import { streamObjectToResponse } from './file-server-download'; import { redisKeepAliveOptions } from './redis-options'; import { + OWNER_BINDING_PATTERN, + OWNER_DELETE_OPERATION, OWNER_EXPECT_HEADER, OWNER_METADATA, OWNER_METADATA_STAT_KEY, @@ -733,19 +735,6 @@ app.delete('/sessions/:session_id/objects/:fileId', async (req, res) => { }); } - /* Deletion against a durable agent-run owner binding: the stored binding - * must name the same tenant, Agent and run. An object without a binding - * has uncertain ownership and is refused, never reported as deleted. */ - const expectedOwner = req.headers[OWNER_EXPECT_HEADER.toLowerCase()]; - if (expectedOwner !== undefined) { - const stat = await minioClient.statObject(bucketName, objectName); - const storedOwner = stat.metaData?.[OWNER_METADATA_STAT_KEY]; - if (typeof expectedOwner !== 'string' || !storedOwner || storedOwner !== expectedOwner) { - logger.warn('Refusing owner-bound deletion', { session_id, fileId, bound: Boolean(storedOwner) }); - return res.status(403).json({ error: 'Owner binding does not match' }); - } - } - await minioClient.removeObject(bucketName, objectName); logger.info(`[${INSTANCE_ID}] File deleted successfully: ${objectName}`); return res.status(200).json({ @@ -762,6 +751,48 @@ app.delete('/sessions/:session_id/objects/:fileId', async (req, res) => { } }); +/** + * Owner-checked deletion for agent_run objects whose session cache has + * expired. A separate operation (not the plain DELETE) so a file server that + * predates owner bindings answers 404 instead of deleting with the internal + * credential. It never deletes without an expected owner, and never deletes an + * object that has no stored binding or a different one. Outcomes: + * 200 {outcome:'deleted'} | 200 {outcome:'absent'} (no such object) | + * 400 missing/malformed expectation | 403 binding mismatch or unbound | 500. + */ +app.post(`/sessions/:session_id/objects/:fileId/${OWNER_DELETE_OPERATION}`, async (req, res) => { + const { session_id, fileId } = req.params; + const expectedOwner = req.headers[OWNER_EXPECT_HEADER.toLowerCase()]; + if (typeof expectedOwner !== 'string' || !OWNER_BINDING_PATTERN.test(expectedOwner)) { + logger.warn('Refusing owner-checked deletion without an expected owner', { session_id, fileId }); + return res.status(400).json({ error: 'Expected owner is required' }); + } + try { + let objectName = ''; + for await (const obj of minioClient.listObjects(bucketName, `${session_id}/${fileId}`, true)) { + if (obj.name.startsWith(`${session_id}/${fileId}`) === true) { + objectName = obj.name; + break; + } + } + if (!objectName) { + return res.status(200).json({ outcome: 'absent', session_id, fileId }); + } + const stat = await minioClient.statObject(bucketName, objectName); + const storedOwner = stat.metaData?.[OWNER_METADATA_STAT_KEY]; + if (!storedOwner || storedOwner !== expectedOwner) { + logger.warn('Refusing owner-checked deletion', { session_id, fileId, bound: Boolean(storedOwner) }); + return res.status(403).json({ error: 'Owner binding does not match' }); + } + await minioClient.removeObject(bucketName, objectName); + logger.info(`[${INSTANCE_ID}] Owner-checked deletion: ${objectName}`); + return res.status(200).json({ outcome: 'deleted', session_id, fileId }); + } catch (err) { + logger.error('Error in owner-checked deletion:', { error: err, session_id, fileId }); + return res.status(500).json({ error: 'Error deleting file' }); + } +}); + const port = Number(process.env.FILE_SERVER_PORT ?? 3000); /** Optional bind address. Deployments where only the co-located service-api * should reach the file server (push-model sandbox backends fetch input diff --git a/service/src/personal-parity.test.ts b/service/src/personal-parity.test.ts index 07714dee..55222ee8 100644 --- a/service/src/personal-parity.test.ts +++ b/service/src/personal-parity.test.ts @@ -269,6 +269,19 @@ test('personal identity consumers, routes and log lines are byte-identical to th expect(existsSync(GOLDEN_PATH)).toBe(true); const golden = JSON.parse(readFileSync(GOLDEN_PATH, 'utf8')); expect(snapshot).toEqual(golden); - /* toEqual ignores key order; serialized log lines, claims and state do not. */ - expect(JSON.stringify(snapshot)).toBe(JSON.stringify(golden)); + /* toEqual ignores key order; serialized log lines, claims and state do not. + * HTTP header maps are the one unordered input: their enumeration order + * depends on the Bun/axios build, so both sides sort them before the + * order-sensitive comparison. Nothing else is canonicalized. */ + const sortHeaderMaps = (value: { routes: { puts: Array<{ headers: Record }> } }): unknown => ({ + ...value, + routes: { + ...value.routes, + puts: value.routes.puts.map(put => ({ + ...put, + headers: Object.fromEntries(Object.entries(put.headers).sort(([a], [b]) => a.localeCompare(b))), + })), + }, + }); + expect(JSON.stringify(sortHeaderMaps(snapshot as never))).toBe(JSON.stringify(sortHeaderMaps(golden))); }); diff --git a/service/src/service/router.ts b/service/src/service/router.ts index 751596fd..395d78d2 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -19,6 +19,7 @@ import { summarizeRequestedFiles } from '../execution-log'; import { getCredentialId, getPrincipalOrReject, type CodeApiPrincipal } from '../auth/principal'; import { OWNER_BINDING_HEADER, + OWNER_DELETE_OPERATION, OWNER_EXPECT_HEADER, agentRunLogFields, ownerBindingValue, @@ -969,16 +970,49 @@ router.get('/sessions/:session_id/objects/:fileId', fetchLimiter, sessionAuth, a router.delete('/files/:session_id/:fileId', fetchLimiter, sessionAuth, async (req: t.AuthenticatedRequest, res: Response) => { const { session_id, fileId } = req.params; - const agentRun = req.codeApiAuthContext?.agentRun; + + /* agent_run deletion after the session cache expired: only the file + * server's owner-checked operation may delete, and anything but its explicit + * 2xx outcome is a refusal. A file server without that operation answers + * 404/405 here, which stays a refusal with the bytes in place; there is no + * fallback to the plain delete. */ + if (req.ownerBindingExpectation) { + let outcome: unknown; + let status = 0; + try { + const response = await axios.post( + `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}/${OWNER_DELETE_OPERATION}`, + undefined, + { + headers: internalServiceHeaders({ [OWNER_EXPECT_HEADER]: req.ownerBindingExpectation }), + validateStatus: () => true, + }, + ); + status = response.status; + outcome = (response.data as { outcome?: unknown } | undefined)?.outcome; + } catch (error) { + logger.error(`[${INSTANCE_ID}] Owner-checked deletion failed - Session ID: ${session_id} | File ID: ${fileId}:`, getAxiosErrorDetails(error)); + return res.status(500).json({ error: 'Error deleting file' }); + } + if (status >= 200 && status < 300 && outcome === 'deleted') { + await connection.del(`upload:${req.sessionKey}${session_id}${fileId}`); + logger.info(`[${INSTANCE_ID}] File deleted: Session ID: ${session_id} | File ID: ${fileId}`); + return res.status(200).json({ message: 'File deleted successfully', session_id, fileId }); + } + if (status >= 200 && status < 300 && outcome === 'absent') { + return res.status(404).json({ error: 'File not found' }); + } + logger.warn(`[${INSTANCE_ID}] Owner-checked deletion refused - Session ID: ${session_id} | File ID: ${fileId}`, { fileServerStatus: status }); + if (status === 500) { + return res.status(500).json({ error: 'Error deleting file' }); + } + return res.status(403).json({ error: 'Unauthorized' }); + } try { const response = await axios.delete( `${env.FILE_SERVER_URL}/sessions/${session_id}/objects/${fileId}`, - { - headers: internalServiceHeaders( - req.ownerBindingExpectation ? { [OWNER_EXPECT_HEADER]: req.ownerBindingExpectation } : {}, - ), - }, + { headers: internalServiceHeaders() } ); await connection.del(`upload:${req.sessionKey}${session_id}${fileId}`); @@ -987,16 +1021,12 @@ router.delete('/files/:session_id/:fileId', fetchLimiter, sessionAuth, async (re } catch (error) { const errorDetails = getAxiosErrorDetails(error); logger.error(`[${INSTANCE_ID}] Error deleting file - Session ID: ${session_id} | File ID: ${fileId}:`, errorDetails); - /* agent_run deletion outcomes are explicit: not-found only when the - * scoped bytes are actually absent, a refused owner binding stays a - * refusal, anything else is a failure, never success. */ - const status = axios.isAxiosError(error) ? error.response?.status : undefined; - if (agentRun && status === 404) { + /* Cache-present agent_run deletes keep the plain route; their outcomes are + * explicit: not-found only when the scoped bytes are absent, anything + * else is a failure, never success. */ + if (req.codeApiAuthContext?.agentRun && axios.isAxiosError(error) && error.response?.status === 404) { return res.status(404).json({ error: 'File not found' }); } - if (agentRun && status === 403) { - return res.status(403).json({ error: 'Unauthorized' }); - } return res.status(500).json({ error: 'Error deleting file', }); diff --git a/service/src/test-support/route-harness.ts b/service/src/test-support/route-harness.ts index 38652d9d..b565f1c5 100644 --- a/service/src/test-support/route-harness.ts +++ b/service/src/test-support/route-harness.ts @@ -21,7 +21,8 @@ type StoreEntry = { value: string; ttl?: number }; export type CapturedLog = { level: string; message: string; meta?: unknown }; export type CapturedPut = { url: string; headers: Record; bytes: number }; export type CapturedFileServerCall = { method: string; url: string; headers: Record }; -export type DeleteHandler = (req: IncomingMessage, key: string) => { status: number; body: unknown }; +/** Handles `POST .../objects/:fid/owner-delete`. Unset = a file server that predates the operation (404). */ +export type OwnerDeleteHandler = (req: IncomingMessage, key: string) => { status: number; body: unknown }; /** Runs before the stub stores a PUT; a returned outcome refuses the write. */ export type PutHandler = (headers: Record, sessionId: string, fileId: string) => { status: number; body: unknown } | undefined; @@ -146,7 +147,7 @@ export interface RouteHarness { puts: CapturedPut[]; fileServerCalls: CapturedFileServerCall[]; objects: Map }>; - setDeleteHandler(handler: DeleteHandler | undefined): void; + setOwnerDeleteHandler(handler: OwnerDeleteHandler | undefined): void; setPutHandler(handler: PutHandler | undefined): void; close(): Promise; } @@ -222,7 +223,7 @@ export async function installRouteHarness(options: { const puts: CapturedPut[] = []; const fileServerCalls: CapturedFileServerCall[] = []; const objects = new Map }>(); - let deleteHandler: DeleteHandler | undefined; + let ownerDeleteHandler: OwnerDeleteHandler | undefined; let putHandler: PutHandler | undefined; const fileServer = createServer(async (req, res) => { const url = req.url ?? ''; @@ -232,6 +233,12 @@ export async function installRouteHarness(options: { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); }; + const ownerDelete = url.match(/^\/sessions\/([^/?]+)\/objects\/([^/?]+)\/owner-delete$/); + if (ownerDelete) { + if (req.method !== 'POST' || !ownerDeleteHandler) return send(404, { error: 'not found' }); + const outcome = ownerDeleteHandler(req, `${ownerDelete[1]}/${ownerDelete[2]}`); + return send(outcome.status, outcome.body); + } const match = url.match(/^\/sessions\/([^/?]+)\/objects(?:\/([^/?]+))?(\/metadata)?/); if (!match) return send(404, { error: 'not found' }); const [, sessionId, fileId, metadata] = match; @@ -259,10 +266,6 @@ export async function installRouteHarness(options: { return send(200, [...objects.keys()].filter(name => name.startsWith(`${sessionId}/`))); } if (req.method === 'DELETE' && fileId) { - if (deleteHandler) { - const outcome = deleteHandler(req, key); - return send(outcome.status, outcome.body); - } if (!objects.delete(key)) return send(404, { error: 'File not found' }); return send(200, { message: 'File deleted successfully', session_id: sessionId, fileId }); } @@ -314,8 +317,8 @@ export async function installRouteHarness(options: { puts, fileServerCalls, objects, - setDeleteHandler(handler) { - deleteHandler = handler; + setOwnerDeleteHandler(handler) { + ownerDeleteHandler = handler; }, setPutHandler(handler) { putHandler = handler;