From 07e5cf025df61f9b7b55015cc8fc85186486488d Mon Sep 17 00:00:00 2001 From: JA Worldwide Date: Mon, 26 Oct 2026 11:27:14 +0100 Subject: [PATCH] fix: deliver new file references before replay continuation --- docs/MCP_CONTINUATION_FILES.md | 19 ++++ service/src/egress-gateway.ts | 2 +- service/src/egress-grant.ts | 2 +- .../src/service/continuation-files.test.ts | 87 +++++++++++++++++++ service/src/service/continuation-files.ts | 33 +++++++ service/src/service/programmatic-router.ts | 16 ++++ service/src/service/replay-state.ts | 23 ++++- service/src/types/service.ts | 1 + 8 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 docs/MCP_CONTINUATION_FILES.md create mode 100644 service/src/service/continuation-files.test.ts create mode 100644 service/src/service/continuation-files.ts diff --git a/docs/MCP_CONTINUATION_FILES.md b/docs/MCP_CONTINUATION_FILES.md new file mode 100644 index 00000000..05d8991b --- /dev/null +++ b/docs/MCP_CONTINUATION_FILES.md @@ -0,0 +1,19 @@ +# MCP inputs during programmatic execution + +Replay `/v1/exec/programmatic` responses advertise `continuation_files: 1` while +waiting for tool results. A result can add `files` using the existing owned input +reference schema. Bytes remain in file storage and never enter replay JSON. +Each reference is associated with an issued `call_id`. + +Under the execution lock, the API validates result shape, execution ownership, +upload ownership, filename conflicts, and aggregate count. The additive inputs +are committed with immutable history before replay; the next sandbox iteration +mounts them before resuming user code. Identical retries are accepted. Existing +upload and input-manifest limits continue to apply. + +Blocking mode does not advertise support and rejects continuation files with +`ptc_continuation_files_unsupported`. Hosts must check capability before promising +same-script availability. Existing clients retain current behavior. + +Local verification uses synthetic inputs and direct NsJail on Docker Desktop; +it does not establish MicroVM, OBO, or remote-bridge compatibility. diff --git a/service/src/egress-gateway.ts b/service/src/egress-gateway.ts index 2af379c1..7e52a991 100644 --- a/service/src/egress-gateway.ts +++ b/service/src/egress-gateway.ts @@ -696,7 +696,7 @@ app.get('/sessions/:sessionHandle/objects/:objectHandle', async (req, res) => { ), { redirect: 'error', headers: injectTraceHeaders(internalServiceHeaders(expectedVersion ? { 'X-CodeAPI-Input-Version': expectedVersion } : {})) }, ); - const headerOverrides = isOpaqueObjectContentDisposition( + const headerOverrides: Record = isOpaqueObjectContentDisposition( upstream.headers.get('content-disposition'), object.id, ) diff --git a/service/src/egress-grant.ts b/service/src/egress-grant.ts index 8148bec7..b0d4ff79 100644 --- a/service/src/egress-grant.ts +++ b/service/src/egress-grant.ts @@ -440,7 +440,7 @@ export function prepareSandboxEgress(args: { delete sandboxVisibleClaims.external_user_id; // Manifests signed by pre-rename workers carry the legacy field name; // keep scrubbing it until the external_user_id rollout completes. - delete (sandboxVisibleClaims as Record)['chc_user_id']; // leak-check:allow + delete (sandboxVisibleClaims as ExecutionManifestClaims & { chc_user_id?: string })['chc_user_id']; // leak-check:allow delete sandboxVisibleClaims.org_id; delete sandboxVisibleClaims.service_id; const executionManifestClaims: ExecutionManifestClaims = { diff --git a/service/src/service/continuation-files.test.ts b/service/src/service/continuation-files.test.ts new file mode 100644 index 00000000..99f13943 --- /dev/null +++ b/service/src/service/continuation-files.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test'; +import { mergeContinuationFiles } from './continuation-files'; +import type { RequestFile } from '../types'; +const file: RequestFile = { + id: 'aaaaaaaaaaaaaaaaaaaaa', + storage_session_id: 'bbbbbbbbbbbbbbbbbbbbb', + resource_id: 'syntheticuser01', + kind: 'user', + name: 'fixture.pdf', +}; +describe('continuation file references', () => { + test('merges references idempotently', () => { + const results = [{ call_id: 'call_001', files: [file] }]; + const issued = new Set(['call_001']); + expect(mergeContinuationFiles([], results, issued)).toEqual([file]); + expect(mergeContinuationFiles([file], results, issued)).toEqual([file]); + }); + test('rejects unissued calls and replacements', () => { + expect(() => + mergeContinuationFiles( + [], + [{ call_id: 'call_002', files: [file] }], + new Set(), + ), + ).toThrow(); + expect(() => + mergeContinuationFiles( + [file], + [ + { + call_id: 'call_001', + files: [{ ...file, id: 'syntheticfile000000002' }], + }, + ], + new Set(['call_001']), + ), + ).toThrow(); + }); + test('rejects traversal and aggregate overflow', () => { + expect(() => + mergeContinuationFiles( + [], + [ + { + call_id: 'call_001', + files: [{ ...file, name: '../fixture.pdf' }], + }, + ], + new Set(['call_001']), + ), + ).toThrow(); + expect(() => + mergeContinuationFiles( + [], + [ + { + call_id: 'call_001', + files: Array.from({ length: 101 }, (_, i) => ({ + ...file, + name: `fixture-${i}.pdf`, + })), + }, + ], + new Set(['call_001']), + ), + ).toThrow(); + }); +}); + +test('preserves immutable file references in continuation result validation', async () => { + const { validateToolResult, validateContinuationBatch } = + await import('./replay-state'); + const result = { + call_id: 'call_001', + result: { filename: file.name }, + is_error: false, + files: [file], + }; + expect(validateToolResult(result)).toMatchObject({ files: [file] }); + expect(validateContinuationBatch([result])).toMatchObject({ + ok: true, + results: [{ files: [file] }], + }); + expect(validateToolResult({ ...result, is_error: true })).toHaveProperty( + 'error', + ); +}); diff --git a/service/src/service/continuation-files.ts b/service/src/service/continuation-files.ts new file mode 100644 index 00000000..6bf5cef9 --- /dev/null +++ b/service/src/service/continuation-files.ts @@ -0,0 +1,33 @@ +import type { RequestFile } from '../types'; +import { env } from '../config'; +import { validateRequestedFiles } from './file-authorization'; +export type ContinuationFiles = { call_id: string; files?: RequestFile[] }; +/** Additive references cannot replace another input during replay. */ +export function mergeContinuationFiles( + existing: RequestFile[], + results: ContinuationFiles[], + issued: ReadonlySet, +): RequestFile[] { + const names = new Map(existing.map(file => [file.name, file])); + for (const result of results) { + if (!result.files?.length) continue; + if (!issued.has(result.call_id)) + throw new Error('unissued_continuation_file'); + for (const file of validateRequestedFiles(result.files)) { + const prior = names.get(file.name); + if ( + prior && + (prior.id !== file.id || + prior.storage_session_id !== file.storage_session_id || + prior.resource_id !== file.resource_id || + prior.kind !== file.kind || + prior.version !== file.version) + ) + throw new Error('continuation_filename_conflict'); + names.set(file.name, file); + if (names.size > Math.min(100, env.INPUT_MANIFEST_MAX_FILES)) + throw new Error('continuation_file_limit_exceeded'); + } + } + return [...names.values()]; +} diff --git a/service/src/service/programmatic-router.ts b/service/src/service/programmatic-router.ts index 86b1cdbb..19a06d99 100644 --- a/service/src/service/programmatic-router.ts +++ b/service/src/service/programmatic-router.ts @@ -1,3 +1,4 @@ +import { mergeContinuationFiles } from './continuation-files'; import axios from 'axios'; import { nanoid } from 'nanoid'; import { Router } from 'express'; @@ -882,6 +883,18 @@ async function handleReplayContinuation( return; } + try { + const referenceResults = validatedResults.filter(result => result.files?.length); + const requested = referenceResults.flatMap(result => result.files ?? []); + if (requested.length > 100) throw new Error('continuation_file_limit_exceeded'); + await authorizeRequestedFiles({ req, files: requested, store: connection }); + state.files = mergeContinuationFiles(state.files ?? [], referenceResults, + new Set((state.emittedToolCalls ?? []).map(call => call.id))); + } catch { + res.status(400).json({ error: 'continuation_file_delivery_failed' }); + return; + } + logger.info('Replay continuation received', { execution_id: state.execution_id, resultCount: validatedResults.length, @@ -1158,6 +1171,7 @@ async function runAndRespond( } res.status(200).json({ status: 'tool_call_required', + continuation_files: 1, continuation_token: generateContinuationToken(state.execution_id), tool_calls: pending.map(p => ({ id: p.call_id, @@ -1457,6 +1471,8 @@ router.post( tool_results, }, cancellation); } + if (tool_results.some(result => result.files?.length)) + return res.status(409).json({ error: 'ptc_continuation_files_unsupported' }); return await handleBlocking(req, res, { apiKeyId, userId }); } diff --git a/service/src/service/replay-state.ts b/service/src/service/replay-state.ts index 9f33586c..abad1112 100644 --- a/service/src/service/replay-state.ts +++ b/service/src/service/replay-state.ts @@ -33,6 +33,7 @@ import { connection } from '../queue'; import { env } from '../config'; import { internalServiceHeaders } from '../internal-service-auth'; import logger from '../logger'; +import { validateRequestedFiles } from './file-authorization'; import { ptcReplayHistorySize, ptcReplayHistoryEntries, @@ -179,6 +180,7 @@ export interface ExecutionState { } export interface HistoryEntry { + files?: t.RequestFile[]; result: unknown; is_error?: boolean; error_message?: string; @@ -318,7 +320,7 @@ export function resetRedisForTests(): void { * key; normalize on read. Safe to delete one EXECUTION_STATE_TTL window * after the external_user_id rollout. */ export function normalizeExecutionState(state: ExecutionState): ExecutionState { - const legacy = (state as Record)['chcUserId']; // leak-check:allow + const legacy = (state as ExecutionState & { chcUserId?: string })['chcUserId']; // leak-check:allow if (state.externalUserId == null && typeof legacy === 'string') { state.externalUserId = legacy; } @@ -526,11 +528,13 @@ function canonicalEntryBody( result: unknown, isError: boolean | undefined, errorMessage: string | undefined, + files?: t.RequestFile[], ): string { return JSON.stringify({ result, is_error: isError ?? false, error_message: errorMessage, + ...(files?.length ? { files } : {}), }); } @@ -558,6 +562,7 @@ export async function computeToolHistoryDelta( results: Array<{ call_id: string; result: unknown; + files?: t.RequestFile[]; is_error?: boolean; error_message?: string; tool_name?: string; @@ -595,6 +600,7 @@ export async function computeToolHistoryDelta( for (const r of results) { const entry: HistoryEntry = { result: r.result, + ...(r.files?.length ? { files: r.files } : {}), is_error: r.is_error ?? false, error_message: r.error_message, received_at: now, @@ -611,11 +617,12 @@ export async function computeToolHistoryDelta( } const existing = oldByCallId.get(r.call_id); if (existing) { - const incomingBody = canonicalEntryBody(r.result, r.is_error, r.error_message); + const incomingBody = canonicalEntryBody(r.result, r.is_error, r.error_message, r.files); const storedBody = canonicalEntryBody( existing.entry.result, existing.entry.is_error, existing.entry.error_message, + existing.entry.files, ); if (incomingBody !== storedBody) { return { @@ -859,7 +866,7 @@ export async function cleanupExecution(execution_id: string, mode: 'blocking' | export function validateToolResult( r: unknown, ): - | { call_id: string; result: unknown; is_error?: boolean; error_message?: string } + | { call_id: string; result: unknown; files?: t.RequestFile[]; is_error?: boolean; error_message?: string } | { error: string } { if (r == null || typeof r !== 'object' || Array.isArray(r)) { return { error: 'tool_results entries must be objects' }; @@ -902,9 +909,17 @@ export function validateToolResult( error: `tool_results[${callId}].result is ${resultBytes} bytes, exceeds per-result cap ${MAX_TOOL_RESULT_BYTES}`, }; } + let files: t.RequestFile[] | undefined; + try { + if (obj.files !== undefined) { + files = validateRequestedFiles(obj.files); + if (files.length > 100 || obj.is_error === true) return { error: 'Invalid continuation files' }; + } + } catch { return { error: 'Invalid continuation files' }; } return { call_id: callId, result: obj.result, + ...(files?.length ? { files } : {}), is_error: obj.is_error as boolean | undefined, error_message: obj.error_message as string | undefined, }; @@ -915,6 +930,7 @@ export function validateToolResult( // --------------------------------------------------------------------------- export interface ValidatedContinuationResult { + files?: t.RequestFile[]; call_id: string; result: unknown; is_error?: boolean; @@ -957,6 +973,7 @@ export function validateContinuationBatch(tool_results: unknown[]): results.push({ call_id: v.call_id, result: v.result, + ...(v.files?.length ? { files: v.files } : {}), is_error: v.is_error, error_message: v.error_message, }); diff --git a/service/src/types/service.ts b/service/src/types/service.ts index b0375b38..4736e455 100644 --- a/service/src/types/service.ts +++ b/service/src/types/service.ts @@ -383,6 +383,7 @@ export interface ProgrammaticRequestBody { timeout?: number; continuation_token?: string; tool_results?: Array<{ + files?: RequestFile[]; call_id: string; result: unknown; is_error?: boolean;