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..e2c84cf7 --- /dev/null +++ b/service/src/agent-run.test.ts @@ -0,0 +1,740 @@ +/** + * 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'; +import { env } from './config'; + +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; +let savedHardened = false; +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; + 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 }; +} + +/** 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'), + 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. */ + 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 } }; + if (owner.binding) bindings.set(`${sid}/${fid}`, owner.binding); + return undefined; + }); + harness.setOwnerDeleteHandler(newFileServerOwnerDelete); +}); + +afterAll(async () => { + env.HARDENED_SANDBOX_MODE = savedHardened; + 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'); + expect(jwtReason(agentToken({ tenant_id: '' }))).toBe('malformed_claims'); + } + }); + + test('malformed agent_run values are refused, never normalized', () => { + /* 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' }, + ]; + 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'); + }); + + test('a personal token carrying file_delete is refused, not ignored', () => { + 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', () => { + 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(Error); + 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).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); + }); + + 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 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); + 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); + }); + }); +}); + +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/agent-run.ts b/service/src/agent-run.ts new file mode 100644 index 00000000..f3e6e88e --- /dev/null +++ b/service/src/agent-run.ts @@ -0,0 +1,138 @@ +/** + * 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'; +/** 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. */ +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..46f404be 100644 --- a/service/src/file-server.ts +++ b/service/src/file-server.ts @@ -18,6 +18,16 @@ import logger from './fileServerLogger'; 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, + OWNER_BINDING_HEADER, + ownerBindingFromHeader, + sessionKeyForLog, +} from './agent-run'; const { INSTANCE_ID } = env; @@ -232,6 +242,7 @@ async function uploadFile( mimetype: string, existingFileId?: string, readOnly = false, + ownerBinding?: string, ): Promise { const fileId = existingFileId ?? nanoid(); const fileExtension = path.extname(filename); @@ -252,6 +263,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 +296,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 +448,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) { @@ -725,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/middleware/auth.ts b/service/src/middleware/auth.ts index b402e346..c087d1fb 100644 --- a/service/src/middleware/auth.ts +++ b/service/src/middleware/auth.ts @@ -5,8 +5,10 @@ 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 { internalServiceAuthEnabled } from '../internal-service-auth'; import { AuthProviderConfigError, getAuthProviderMode } from '../auth/provider'; import { authenticateSyntheticRequest, @@ -30,14 +32,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 +54,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, @@ -141,7 +190,21 @@ 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( + '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 +257,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 +303,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 new file mode 100644 index 00000000..55222ee8 --- /dev/null +++ b/service/src/personal-parity.test.ts @@ -0,0 +1,287 @@ +/** + * 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 () => { + 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 () => { + 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); + 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 }), + 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); + const golden = JSON.parse(readFileSync(GOLDEN_PATH, 'utf8')); + expect(snapshot).toEqual(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/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..395d78d2 100644 --- a/service/src/service/router.ts +++ b/service/src/service/router.ts @@ -10,13 +10,22 @@ 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_DELETE_OPERATION, + 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 +53,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 +68,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 +100,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 +147,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 +175,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 +195,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 +259,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 +288,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 +307,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 +315,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 +419,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 @@ -424,11 +492,14 @@ 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, version: uploadVersionRaw, - authContextUserId: req.codeApiAuthContext?.userId ?? userId, + authContextUserId: req.codeApiAuthContext?.userId ?? principal.userId ?? '', }); } catch (err) { clearTimeout(uploadTimeout); @@ -439,7 +510,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 +523,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 +585,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 +623,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 +647,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); }); @@ -638,15 +715,21 @@ 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, version: uploadVersionRaw, - authContextUserId: req.codeApiAuthContext?.userId ?? userId, + authContextUserId: req.codeApiAuthContext?.userId ?? principal.userId ?? '', }); } 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; @@ -654,7 +737,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 +748,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 +761,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 +848,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; @@ -879,6 +971,44 @@ 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; + /* 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}`, @@ -891,6 +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); + /* 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' }); + } 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/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..b565f1c5 --- /dev/null +++ b/service/src/test-support/route-harness.ts @@ -0,0 +1,392 @@ +/** + * 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 }; +/** 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; + +/** 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; + fileServerUrl: string; + redis: FakeRedis; + jobs: Array>; + logs: CapturedLog[]; + puts: CapturedPut[]; + fileServerCalls: CapturedFileServerCall[]; + objects: Map }>; + setOwnerDeleteHandler(handler: OwnerDeleteHandler | undefined): void; + setPutHandler(handler: PutHandler | 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' }, + })); + + /* 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; + + /* 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 ownerDeleteHandler: OwnerDeleteHandler | undefined; + let putHandler: PutHandler | 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 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; + 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 }); + 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); + 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 (!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`); + 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; + 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}`, + fileServerUrl: `http://127.0.0.1:${fileServerPort}`, + redis, + jobs, + logs, + puts, + fileServerCalls, + objects, + setOwnerDeleteHandler(handler) { + ownerDeleteHandler = 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; + } + }, + }; +} + +/** 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 }; + } +} 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