Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/MCP_CONTINUATION_FILES.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion service/src/egress-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = isOpaqueObjectContentDisposition(
upstream.headers.get('content-disposition'),
object.id,
)
Expand Down
2 changes: 1 addition & 1 deletion service/src/egress-grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)['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 = {
Expand Down
87 changes: 87 additions & 0 deletions service/src/service/continuation-files.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
33 changes: 33 additions & 0 deletions service/src/service/continuation-files.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
): 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()];
}
16 changes: 16 additions & 0 deletions service/src/service/programmatic-router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mergeContinuationFiles } from './continuation-files';
import axios from 'axios';
import { nanoid } from 'nanoid';
import { Router } from 'express';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
}

Expand Down
23 changes: 20 additions & 3 deletions service/src/service/replay-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -179,6 +180,7 @@ export interface ExecutionState {
}

export interface HistoryEntry {
files?: t.RequestFile[];
result: unknown;
is_error?: boolean;
error_message?: string;
Expand Down Expand Up @@ -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<string, unknown>)['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;
}
Expand Down Expand Up @@ -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 } : {}),
});
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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' };
Expand Down Expand Up @@ -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,
};
Expand All @@ -915,6 +930,7 @@ export function validateToolResult(
// ---------------------------------------------------------------------------

export interface ValidatedContinuationResult {
files?: t.RequestFile[];
call_id: string;
result: unknown;
is_error?: boolean;
Expand Down Expand Up @@ -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,
});
Expand Down
1 change: 1 addition & 0 deletions service/src/types/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down