From 97d633171aabd74e4f43fb5462c7d4c74ae44a8c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 14:33:00 -0400 Subject: [PATCH 1/4] fix: report deleted persisted files --- api/README.md | 7 +++ api/src/job.ts | 26 +++++++++++ api/src/walker.test.ts | 50 ++++++++++++++++++++++ service/src/service/blocking-poll.test.ts | 2 + service/src/service/blocking-poll.ts | 2 + service/src/service/programmatic-router.ts | 4 ++ service/src/types/service.ts | 3 ++ service/src/workers.ts | 3 ++ 8 files changed, 97 insertions(+) diff --git a/api/README.md b/api/README.md index 665a2931..38e4cd98 100644 --- a/api/README.md +++ b/api/README.md @@ -94,6 +94,13 @@ Other package-format-compatible runtimes (Go, Rust, Java, GCC) can be installed Execute code in a sandboxed environment. +When a persisted input file is removed during execution, a complete artifact +scan reports its relative path in `deleted_files`. Callers can use this +explicit list to remove stale file references from their next session request. +The field is omitted when no persisted inputs were removed or when artifact +scanning is incomplete, so truncation or unreadable paths cannot be mistaken +for deletions. + When supported output files are omitted because the response reaches its file count limit, nesting or path limits, file-size limit, or a filesystem entry cannot be read, the response includes `artifact_truncation`. Its `reasons` diff --git a/api/src/job.ts b/api/src/job.ts index 27604d29..37973a3a 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -655,6 +655,8 @@ interface ExecuteResult { /** Top-level execution session id (one sandbox `/exec` invocation). */ session_id: string; files: FileRef[]; + /** Persisted input paths that no longer exist after this execution. */ + deleted_files?: string[]; artifact_delivery?: ArtifactDeliveryFailure; artifact_truncation?: ArtifactTruncation; } @@ -714,6 +716,8 @@ export class Job { private pendingSurfaced = new Map(); private sessionFiles: FileRef[] = []; private inheritedRefs: FileRef[] = []; + private presentInputFiles = new Set(); + private deletedFiles: string[] = []; private artifactTruncation: ArtifactTruncation | undefined; private truncationProbeState: TruncationProbeState = { remainingEntries: TRUNCATION_PROBE_MAX_ENTRIES, @@ -1699,6 +1703,9 @@ export class Job { version: this.runtime.version.raw, session_id: this.outputSessionId, files: this.sessionFiles, + ...(this.deletedFiles.length > 0 + ? { deleted_files: this.deletedFiles } + : {}), ...(this.artifactTruncation ? { artifact_truncation: this.artifactTruncation } : {}), }; } @@ -1707,6 +1714,8 @@ export class Job { this.generatedFiles = []; this.sessionFiles = []; this.inheritedRefs = []; + this.presentInputFiles.clear(); + this.deletedFiles = []; this.artifactTruncation = undefined; this.truncationProbeState = { remainingEntries: TRUNCATION_PROBE_MAX_ENTRIES, @@ -1720,6 +1729,19 @@ export class Job { await this.walkDir(this.submissionDir, 0, inputByName); } catch (error) { this.log.error({ err: error }, 'Error scanning submission directory'); + this.recordArtifactTruncation('unreadable', '.'); + } + + if (this.artifactTruncation == null) { + for (const file of this.files) { + if ( + file.id != null && + file.storage_session_id != null && + !this.presentInputFiles.has(file.name) + ) { + this.deletedFiles.push(file.name); + } + } } /* Generated files get priority in sessionFiles; fill remaining slots up @@ -2398,6 +2420,10 @@ export class Job { const kind = await this.classifyDirent(entry, fullPath, relativePath); if (kind === 'skip') continue; + if (kind === 'file' && inputByName.has(relativePath)) { + this.presentInputFiles.add(relativePath); + } + if (kind === 'dir') { /* Skip hidden directories (basename starts with `.`) unless the user * explicitly primed something under them. Matplotlib, pip, and other diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index 543505a9..9cddb6db 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -26,6 +26,8 @@ interface WalkerInternals { generatedFiles: Array<{ id: string; name: string; path: string }>; sessionFiles: Array<{ id: string; name: string; storage_session_id: string; modified_from?: { id: string; storage_session_id: string }; inherited?: true; entity_id?: string }>; inheritedRefs: Array<{ id: string; name: string; storage_session_id: string; inherited?: true; entity_id?: string }>; + presentInputFiles: Set; + deletedFiles: string[]; artifactTruncation?: { code: 'artifact_truncated'; reasons: Partial>; @@ -1257,6 +1259,54 @@ describe('handleSessionFiles / priority-fill composition', () => { }); }); +describe('handleSessionFiles / persisted input deletion', () => { + it('reports a persisted input that no longer exists', async () => { + const inherited: TFile = { + id: 'prior-id', + storage_session_id: 'prior-session', + name: 'removed.txt', + }; + const internals = asInternals(makeJob({ files: [inherited] })); + internals.submissionDir = tmpDir; + + await internals.handleSessionFiles(); + + expect(internals.deletedFiles).toEqual(['removed.txt']); + }); + + it('does not report a surviving input that is unsupported as an output artifact', async () => { + const inherited: TFile = { + id: 'prior-id', + storage_session_id: 'prior-session', + name: 'archive.bin', + }; + await fsp.writeFile(path.join(tmpDir, inherited.name), 'binary-placeholder'); + const internals = asInternals(makeJob({ files: [inherited] })); + internals.submissionDir = tmpDir; + + await internals.handleSessionFiles(); + + expect(internals.generatedFiles).toHaveLength(0); + expect(internals.deletedFiles).toEqual([]); + }); + + it('suppresses deletion reporting when the artifact scan is incomplete', async () => { + const inherited: TFile = { + id: 'prior-id', + storage_session_id: 'prior-session', + name: 'removed.txt', + }; + await fsp.writeFile(path.join(tmpDir, 'too-large.txt'), 'too large'); + const internals = asInternals(makeJob({ files: [inherited], maxFileSize: 3 })); + internals.submissionDir = tmpDir; + + await internals.handleSessionFiles(); + + expect(internals.artifactTruncation?.reasons).toEqual({ size: 1 }); + expect(internals.deletedFiles).toEqual([]); + }); +}); + describe('walkDir / dirent classification', () => { it('ignores symlinks (never classifies them as file or dir)', async () => { await fsp.writeFile(path.join(tmpDir, 'real.py'), 'print(1)'); diff --git a/service/src/service/blocking-poll.test.ts b/service/src/service/blocking-poll.test.ts index 35190157..96903d1f 100644 --- a/service/src/service/blocking-poll.test.ts +++ b/service/src/service/blocking-poll.test.ts @@ -4,6 +4,7 @@ import type * as t from '../types'; const result: t.ExecuteResult = { session_id: 'session', stdout: 'successful code', stderr: '', files: [], + deleted_files: ['removed.txt'], artifact_delivery: { code: 'artifact_delivery_failed', status: 'failed', attempted: 1, delivered: 0, failed: 1, }, @@ -29,6 +30,7 @@ describe('blocking worker settlement', () => { const deps = fixture(); expect(await pollBlockingExecution('exec', 5, deps)).toEqual({ status: 'completed', stdout: result.stdout, stderr: '', files: [], + deleted_files: result.deleted_files, artifact_delivery: result.artifact_delivery, artifact_truncation: result.artifact_truncation, }); diff --git a/service/src/service/blocking-poll.ts b/service/src/service/blocking-poll.ts index 03e386d4..1505f818 100644 --- a/service/src/service/blocking-poll.ts +++ b/service/src/service/blocking-poll.ts @@ -33,6 +33,7 @@ export async function pollBlockingExecution( stdout?: string; stderr?: string; files?: t.FileRefs; + deleted_files?: string[]; artifact_delivery?: t.ArtifactDeliveryFailure; artifact_truncation?: t.ArtifactTruncation; }> { @@ -48,6 +49,7 @@ export async function pollBlockingExecution( stdout: result.stdout, stderr: result.stderr, files: result.files, + deleted_files: result.deleted_files, artifact_delivery: result.artifact_delivery, artifact_truncation: result.artifact_truncation, }; diff --git a/service/src/service/programmatic-router.ts b/service/src/service/programmatic-router.ts index bb140013..1063fbfe 100644 --- a/service/src/service/programmatic-router.ts +++ b/service/src/service/programmatic-router.ts @@ -1165,6 +1165,7 @@ async function runAndRespond( stdout: cleanStdout, stderr: result.stderr, files: result.files, + deleted_files: result.deleted_files, artifact_delivery: result.artifact_delivery, artifact_truncation: result.artifact_truncation, session_id: state.session_id, @@ -1179,6 +1180,7 @@ async function runAndRespond( stdout: cleanStdout, stderr: result.stderr, files: result.files, + deleted_files: result.deleted_files, artifact_delivery: result.artifact_delivery, artifact_truncation: result.artifact_truncation, session_id: state.session_id, @@ -1571,6 +1573,7 @@ async function handleBlocking( stdout: state.stdout ?? '', stderr: state.stderr ?? '', files: state.files ?? [], + deleted_files: state.deleted_files, artifact_delivery: state.artifact_delivery, artifact_truncation: state.artifact_truncation, session_id: execution.session_id, @@ -1874,6 +1877,7 @@ async function handleBlocking( stdout: state.stdout ?? '', stderr: state.stderr ?? '', files: state.files ?? [], + deleted_files: state.deleted_files, artifact_delivery: state.artifact_delivery, artifact_truncation: state.artifact_truncation, session_id, diff --git a/service/src/types/service.ts b/service/src/types/service.ts index a0c78486..0404ad16 100644 --- a/service/src/types/service.ts +++ b/service/src/types/service.ts @@ -141,6 +141,7 @@ export type ExecuteResponse = { /** Top-level execution session id (one sandbox `/exec` invocation). */ session_id: string; files: FileRefs; + deleted_files?: string[]; artifact_delivery?: ArtifactDeliveryFailure; artifact_truncation?: ArtifactTruncation; }; @@ -259,6 +260,7 @@ export type ExecuteResult = { stdout: string; stderr: string; files: FileRefs; + deleted_files?: string[]; artifact_delivery?: ArtifactDeliveryFailure; artifact_truncation?: ArtifactTruncation; code?: number | null; @@ -408,6 +410,7 @@ export interface ProgrammaticResponse { stdout?: string; stderr?: string; files?: FileRefs; + deleted_files?: string[]; artifact_delivery?: ArtifactDeliveryFailure; artifact_truncation?: ArtifactTruncation; /** Top-level execution session id (one sandbox PTC invocation). */ diff --git a/service/src/workers.ts b/service/src/workers.ts index a7153b53..dbfd544b 100644 --- a/service/src/workers.ts +++ b/service/src/workers.ts @@ -305,6 +305,9 @@ async function processJobInner(job: t.ExecuteJob): Promise { * `[]` so the strictened response type from Phase B doesn't * surface a regression that wasn't there before. */ files: files ?? [], + ...(responseData.deleted_files != null + ? { deleted_files: responseData.deleted_files } + : {}), ...(responseData.artifact_delivery != null ? { artifact_delivery: responseData.artifact_delivery } : {}), From 0ea6cfb86eff406c154371216499940a161195a6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 15:15:27 -0400 Subject: [PATCH 2/4] fix: reconcile deletions across code runtimes --- api/src/job.ts | 11 ++- api/src/session-workspace.test.ts | 5 ++ api/src/session-workspace.ts | 6 ++ api/src/walker.test.ts | 69 +++++++++++++++++++ packages/code/src/native-programmatic.test.ts | 51 ++++++++++++++ packages/code/src/native-programmatic.ts | 10 ++- 6 files changed, 150 insertions(+), 2 deletions(-) diff --git a/api/src/job.ts b/api/src/job.ts index 37973a3a..0b9a3b8f 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -1733,13 +1733,19 @@ export class Job { } if (this.artifactTruncation == null) { + const returnedNames = new Set([ + ...this.sessionFiles.map(file => file.name), + ...this.inheritedRefs.map(file => file.name), + ]); for (const file of this.files) { if ( file.id != null && file.storage_session_id != null && - !this.presentInputFiles.has(file.name) + !this.presentInputFiles.has(file.name) && + !returnedNames.has(file.name) ) { this.deletedFiles.push(file.name); + this.session?.forgetPrimed(file.name); } } } @@ -2265,6 +2271,9 @@ export class Job { } if (kind === 'file') { sawVisibleNonHiddenEntry = true; + if (inputByName.has(relativePath)) { + this.presentInputFiles.add(relativePath); + } if (entry.name !== DIRKEEP && !isSupportedOutputFilename(entry.name)) continue; const existingFile = inputByName.get(relativePath); const inputFileInfo = this.inputFileHashes.get(relativePath); diff --git a/api/src/session-workspace.test.ts b/api/src/session-workspace.test.ts index 87ca71df..1a5320c6 100644 --- a/api/src/session-workspace.test.ts +++ b/api/src/session-workspace.test.ts @@ -113,6 +113,11 @@ describe('SessionWorkspace state', () => { expect(ws.isPrimedInput('in.csv')).toBe(false); ws.markPrimed('in.csv', 'file_abc'); expect(ws.primedInputId('in.csv')).toBe('file_abc'); + ws.markSurfaced('in.csv', 'old-output'); + ws.forgetPrimed('in.csv'); + expect(ws.primedInputId('in.csv')).toBeUndefined(); + expect(ws.isSurfaced('in.csv', 'old-output')).toBe(false); + ws.markPrimed('in.csv', 'file_abc'); /* read-only primes report as not-primed so the caller re-downloads them * (a reused on-disk copy could have been tampered via the writable dir). */ diff --git a/api/src/session-workspace.ts b/api/src/session-workspace.ts index 3f2105bc..82ed6c89 100644 --- a/api/src/session-workspace.ts +++ b/api/src/session-workspace.ts @@ -202,6 +202,12 @@ export class SessionWorkspace { this.primed.set(relPath, { id: storageFileId, readOnly, hash }); } + /** Clears input lineage after execution proves that the path was deleted. */ + forgetPrimed(relPath: string): void { + this.primed.delete(relPath); + this.forget(relPath); + } + markDirty(reason: string): void { this.dirty = reason; logger.error( diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index 9cddb6db..7baa5c0f 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -41,6 +41,14 @@ interface WalkerInternals { reusePrimedInput: (file: TFile) => Promise; writeFile: (file: TFile) => Promise; computeFileHash: (filePath: string, noFollow?: boolean) => Promise; + findTruncatedArtifact: ( + dir: string, + inputByName: Map, + state?: { remainingEntries: number; remainingHashBytes: number }, + probeDepth?: number, + rootPath?: string, + respectSessionSuppression?: boolean, + ) => Promise; walkDir: (dir: string, depth: number, inputByName: Map) => Promise<'collected' | 'empty' | 'skipped'>; handleSessionFiles: () => Promise; } @@ -1305,6 +1313,67 @@ describe('handleSessionFiles / persisted input deletion', () => { expect(internals.artifactTruncation?.reasons).toEqual({ size: 1 }); expect(internals.deletedFiles).toEqual([]); }); + + it('does not report an inherited marker that is returned for an empty directory', async () => { + const name = path.join('empty', DIRKEEP); + const inherited: TFile = { + id: 'marker-id', + storage_session_id: 'prior-session', + name, + }; + await fsp.mkdir(path.join(tmpDir, 'empty')); + const internals = asInternals(makeJob({ files: [inherited] })); + internals.submissionDir = tmpDir; + + await internals.handleSessionFiles(); + + expect(internals.deletedFiles).toEqual([]); + expect([ + ...internals.sessionFiles, + ...internals.inheritedRefs, + ].map(file => file.name)).toContain(name); + }); + + it('clears stateful priming lineage when a persisted input is deleted', async () => { + const inherited: TFile = { + id: 'prior-id', + storage_session_id: 'prior-session', + name: 'removed.txt', + }; + const session = new SessionWorkspace({ runtimeSessionId: 'rt_deleted' }); + session.markPrimed(inherited.name, inherited.id!, true, 'old-hash'); + session.markSurfaced(inherited.name, 'old-output-hash'); + const internals = asInternals(makeJob({ files: [inherited], session })); + internals.submissionDir = tmpDir; + + await internals.handleSessionFiles(); + + expect(internals.deletedFiles).toEqual([inherited.name]); + expect(session.isPrimedInput(inherited.name)).toBe(false); + expect(session.isSurfaced(inherited.name, 'old-output-hash')).toBe(false); + }); + + it('tracks surviving persisted inputs during capped subtree probes', async () => { + const inherited: TFile = { + id: 'prior-id', + storage_session_id: 'prior-session', + name: path.join('assets', 'model.bin'), + }; + await fsp.mkdir(path.join(tmpDir, 'assets')); + await fsp.writeFile( + path.join(tmpDir, inherited.name), + 'unsupported-but-persisted', + ); + const internals = asInternals(makeJob({ files: [inherited] })); + internals.submissionDir = tmpDir; + + await internals.findTruncatedArtifact( + tmpDir, + new Map([[inherited.name, inherited]]), + ); + + expect(internals.presentInputFiles.has(inherited.name)).toBe(true); + }); }); describe('walkDir / dirent classification', () => { diff --git a/packages/code/src/native-programmatic.test.ts b/packages/code/src/native-programmatic.test.ts index 82650f95..5718f33a 100644 --- a/packages/code/src/native-programmatic.test.ts +++ b/packages/code/src/native-programmatic.test.ts @@ -121,6 +121,57 @@ test('stages skill files privately and returns generated artifacts', async () => } }); +test('reports persisted inputs deleted by selected-workspace execution', async t => { + const scratch = await mkdtemp(join(tmpdir(), 'native-ptc-delete-test-')); + t.after(() => rm(scratch, { recursive: true, force: true })); + const server = createServer((_req, res) => res.end('persisted input')); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(resolve => server.close(() => resolve()))); + const address = server.address() as AddressInfo; + const executor = new NativeWorkspaceProgrammaticExecutor({ + upstreamUrl: `http://127.0.0.1:${address.port}`, + sandbox: { + async createExecutionDirectory() { + return await mkdtemp(join(scratch, 'execution-')); + }, + async executeProgrammatic(request, dataDirectory) { + await rm(join(dataDirectory, 'input.txt')); + return { + protocolVersion: 1, + operation: 'execute_command' as const, + workspaceId: request.workspaceId, + exitCode: 0, + stdout: '', + stderr: '', + truncated: false, + timedOut: false, + }; + }, + }, + }); + + const result = await executor.execute({ + headers: {}, + body: { + language: 'bash', + version: '5.2.0', + session_id: 'execution-session', + egress_grant: 'grant', + files: [ + { name: 'main.sh', content: 'rm input.txt' }, + { + name: 'input.txt', + id: 'input-id', + storage_session_id: 'input-session', + }, + ], + }, + }, 'primary'); + + assert.deepEqual(result.files, []); + assert.deepEqual(result.deleted_files, ['input.txt']); +}); + test('reports unsupported and rejected artifacts without invalidating a completed command', async () => { const scratch = await mkdtemp(join(tmpdir(), 'native-ptc-artifact-test-')); const uploads = new Map(); diff --git a/packages/code/src/native-programmatic.ts b/packages/code/src/native-programmatic.ts index 42974b79..f9206398 100644 --- a/packages/code/src/native-programmatic.ts +++ b/packages/code/src/native-programmatic.ts @@ -50,6 +50,7 @@ type ProgrammaticResult = { version: string; session_id: string; files: ProgrammaticFileResult[]; + deleted_files?: string[]; artifact_delivery?: { code: 'artifact_delivery_failed'; status: 'partial' | 'failed'; @@ -578,7 +579,11 @@ export class NativeWorkspaceProgrammaticExecutor { } const outputSessionId = request.body.output_session_id; - const outputNames = (await listRegularFiles(dataDirectory)).filter( + const survivingNames = new Set(await listRegularFiles(dataDirectory)); + const deletedFiles = refFiles + .filter(file => !survivingNames.has(file.name)) + .map(file => file.name); + const outputNames = [...survivingNames].filter( name => name !== EXECUTION_MAIN_FILE && name !== EXECUTION_HISTORY_FILE && @@ -731,6 +736,7 @@ export class NativeWorkspaceProgrammaticExecutor { performance.now() - startedAt, undefined, artifactDelivery, + deletedFiles, ); } catch (error) { if (!commandDispatched) { @@ -785,6 +791,7 @@ export class NativeWorkspaceProgrammaticExecutor { elapsedMs: number, pendingToolCallsPayload?: string, artifactDelivery?: ProgrammaticResult['artifact_delivery'], + deletedFiles: string[] = [], ): ProgrammaticResult { return { language: 'bash', @@ -795,6 +802,7 @@ export class NativeWorkspaceProgrammaticExecutor { session_id: request.body.output_session_id ?? request.body.session_id, files, + ...(deletedFiles.length > 0 ? { deleted_files: deletedFiles } : {}), ...(artifactDelivery ? { artifact_delivery: artifactDelivery } : {}), ...(pendingToolCallsPayload ? { pending_tool_calls_payload: pendingToolCallsPayload } From 1d078900e79d87e6ad7513a68c708622875545bb Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 15:28:55 -0400 Subject: [PATCH 3/4] fix: preserve protected session inputs --- api/src/job.ts | 9 ++- api/src/walker.test.ts | 38 ++++++++++++ packages/code/src/native-programmatic.test.ts | 61 +++++++++++++++++++ packages/code/src/native-programmatic.ts | 36 +++++++---- 4 files changed, 131 insertions(+), 13 deletions(-) diff --git a/api/src/job.ts b/api/src/job.ts index 0b9a3b8f..667ce9c2 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -1741,6 +1741,7 @@ export class Job { if ( file.id != null && file.storage_session_id != null && + this.inputFileHashes.get(file.name)?.readOnly !== true && !this.presentInputFiles.has(file.name) && !returnedNames.has(file.name) ) { @@ -2422,8 +2423,6 @@ export class Job { let skippedHiddenDirs = 0; for (const entry of entries) { - if (isPtcReserved(entry.name)) continue; - const fullPath = path.join(dir, entry.name); const relativePath = path.relative(this.submissionDir, fullPath); const kind = await this.classifyDirent(entry, fullPath, relativePath); @@ -2433,6 +2432,12 @@ export class Job { this.presentInputFiles.add(relativePath); } + /* A by-reference input may legitimately use the reserved replay-history + * basename on the ordinary execution endpoint. It remains hidden from + * output collection, but must be observed before the runtime fixture is + * skipped so an untouched input is not reported as deleted. */ + if (isPtcReserved(entry.name)) continue; + if (kind === 'dir') { /* Skip hidden directories (basename starts with `.`) unless the user * explicitly primed something under them. Matplotlib, pip, and other diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index 7baa5c0f..da037867 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -1282,6 +1282,44 @@ describe('handleSessionFiles / persisted input deletion', () => { expect(internals.deletedFiles).toEqual(['removed.txt']); }); + it('retains a read-only persisted input when sandbox code removes its local copy', async () => { + const inherited: TFile = { + id: 'skill-id', + storage_session_id: 'skill-session', + name: path.join('skills', 'review', 'SKILL.md'), + }; + const internals = asInternals(makeJob({ files: [inherited] })); + internals.submissionDir = tmpDir; + internals.inputFileHashes.set(inherited.name, { + hash: sha256('trusted-skill'), + path: path.join(tmpDir, inherited.name), + originalId: inherited.id, + originalSessionId: inherited.storage_session_id, + readOnly: true, + }); + + await internals.handleSessionFiles(); + + expect(internals.deletedFiles).toEqual([]); + }); + + it('tracks a persisted input using the reserved PTC history basename', async () => { + const inherited: TFile = { + id: 'history-id', + storage_session_id: 'prior-session', + name: path.join('fixtures', '_ptc_history.json'), + }; + await fsp.mkdir(path.join(tmpDir, 'fixtures')); + await fsp.writeFile(path.join(tmpDir, inherited.name), '{}'); + const internals = asInternals(makeJob({ files: [inherited] })); + internals.submissionDir = tmpDir; + + await internals.handleSessionFiles(); + + expect(internals.deletedFiles).toEqual([]); + expect(internals.generatedFiles.map(file => file.name)).not.toContain(inherited.name); + }); + it('does not report a surviving input that is unsupported as an output artifact', async () => { const inherited: TFile = { id: 'prior-id', diff --git a/packages/code/src/native-programmatic.test.ts b/packages/code/src/native-programmatic.test.ts index 5718f33a..902c707a 100644 --- a/packages/code/src/native-programmatic.test.ts +++ b/packages/code/src/native-programmatic.test.ts @@ -172,6 +172,67 @@ test('reports persisted inputs deleted by selected-workspace execution', async t assert.deepEqual(result.deleted_files, ['input.txt']); }); +test('retains read-only persisted inputs removed by selected-workspace execution', async t => { + const scratch = await mkdtemp(join(tmpdir(), 'native-ptc-readonly-delete-test-')); + t.after(() => rm(scratch, { recursive: true, force: true })); + let downloads = 0; + const server = createServer((_req, res) => { + downloads++; + res.setHeader('X-Read-Only', 'true'); + res.end('trusted skill'); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(resolve => server.close(() => resolve()))); + const address = server.address() as AddressInfo; + const executor = new NativeWorkspaceProgrammaticExecutor({ + upstreamUrl: `http://127.0.0.1:${address.port}`, + sandbox: { + async createExecutionDirectory() { + return await mkdtemp(join(scratch, 'execution-')); + }, + async executeProgrammatic(request, dataDirectory) { + await rm(join(dataDirectory, 'skills', 'review', 'SKILL.md')); + return { + protocolVersion: 1, + operation: 'execute_command' as const, + workspaceId: request.workspaceId, + exitCode: 0, + stdout: '', + stderr: '', + truncated: false, + timedOut: false, + }; + }, + }, + }); + const request = { + headers: {}, + body: { + language: 'bash' as const, + version: '5.2.0', + execution_id: 'readonly-execution', + session_id: 'execution-session', + egress_grant: 'grant', + files: [ + { name: 'main.sh', content: 'rm skills/review/SKILL.md' }, + { + name: 'skills/review/SKILL.md', + id: 'skill-id', + storage_session_id: 'skill-session', + input_cache_key: 'a'.repeat(64), + }, + ], + }, + }; + + const result = await executor.execute(request, 'primary'); + const replay = await executor.execute(request, 'primary'); + + assert.equal(downloads, 1); + assert.equal(result.deleted_files, undefined); + assert.equal(replay.deleted_files, undefined); +}); + test('reports unsupported and rejected artifacts without invalidating a completed command', async () => { const scratch = await mkdtemp(join(tmpdir(), 'native-ptc-artifact-test-')); const uploads = new Map(); diff --git a/packages/code/src/native-programmatic.ts b/packages/code/src/native-programmatic.ts index f9206398..bd2700f2 100644 --- a/packages/code/src/native-programmatic.ts +++ b/packages/code/src/native-programmatic.ts @@ -76,8 +76,11 @@ type ProgrammaticResult = { type InputBaseline = { sha256: string; source?: { id: string; storage_session_id: string }; + readOnly?: boolean; }; +type CachedInput = { bytes: Buffer; readOnly: boolean }; + function sha256(value: Uint8Array): string { return createHash('sha256').update(value).digest('hex'); } @@ -228,7 +231,7 @@ export class NativeWorkspaceProgrammaticExecutor { /** Parent-process cache: sandboxed children cannot inspect this memory. */ private readonly inputCache = new Map< string, - { bytes: Buffer; lastUsed: number } + CachedInput & { lastUsed: number } >(); private inputCacheBytes = 0; @@ -273,14 +276,15 @@ export class NativeWorkspaceProgrammaticExecutor { : undefined; } - private cachedInput(key: string): Buffer | undefined { + private cachedInput(key: string): CachedInput | undefined { const cached = this.inputCache.get(key); if (!cached) return undefined; cached.lastUsed = Date.now(); - return cached.bytes; + return { bytes: cached.bytes, readOnly: cached.readOnly }; } - private cacheInput(key: string, bytes: Buffer): void { + private cacheInput(key: string, input: CachedInput): void { + const { bytes } = input; if (bytes.byteLength > INPUT_CACHE_MAX_BYTES) return; const existing = this.inputCache.get(key); if (existing) this.inputCacheBytes -= existing.bytes.byteLength; @@ -301,7 +305,7 @@ export class NativeWorkspaceProgrammaticExecutor { this.inputCache.get(oldestKey)!.bytes.byteLength; this.inputCache.delete(oldestKey); } - this.inputCache.set(key, { bytes, lastUsed: Date.now() }); + this.inputCache.set(key, { ...input, lastUsed: Date.now() }); this.inputCacheBytes += bytes.byteLength; } @@ -311,7 +315,7 @@ export class NativeWorkspaceProgrammaticExecutor { executionId: string | undefined, signal?: AbortSignal, transferTimeoutMs = TRANSFER_TIMEOUT_MS, - ): Promise { + ): Promise { const key = this.cacheKey(executionId, file); const cached = key ? this.cachedInput(key) : undefined; if (cached) return cached; @@ -342,8 +346,12 @@ export class NativeWorkspaceProgrammaticExecutor { response, controller.signal, ); - if (key) this.cacheInput(key, bytes); - return bytes; + const input = { + bytes, + readOnly: response.headers.get('x-read-only')?.toLowerCase() === 'true', + }; + if (key) this.cacheInput(key, input); + return input; } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); @@ -394,9 +402,9 @@ export class NativeWorkspaceProgrammaticExecutor { request.body.files, TRANSFER_CONCURRENCY, async (file): Promise => { - const bytes = + const input = 'content' in file - ? Buffer.from(file.content) + ? { bytes: Buffer.from(file.content), readOnly: false } : await this.downloadInput( file, grant!, @@ -404,6 +412,7 @@ export class NativeWorkspaceProgrammaticExecutor { signal, request.body.transfer_timeout_ms, ); + const { bytes } = input; totalInputBytes += bytes.byteLength; if ( totalInputBytes > @@ -422,6 +431,7 @@ export class NativeWorkspaceProgrammaticExecutor { await writeFile(path, bytes, { flag: 'wx', mode: 0o600 }); baselines.set(file.name, { sha256: sha256(bytes), + ...(input.readOnly ? { readOnly: true } : {}), ...('id' in file ? { source: { @@ -581,7 +591,11 @@ export class NativeWorkspaceProgrammaticExecutor { const outputSessionId = request.body.output_session_id; const survivingNames = new Set(await listRegularFiles(dataDirectory)); const deletedFiles = refFiles - .filter(file => !survivingNames.has(file.name)) + .filter( + file => + baselines.get(file.name)?.readOnly !== true && + !survivingNames.has(file.name), + ) .map(file => file.name); const outputNames = [...survivingNames].filter( name => From b31c942a374839f3eecb99d126e2259e12f644e8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 15:37:34 -0400 Subject: [PATCH 4/4] fix: classify reserved runtime paths --- api/src/job.ts | 15 ++++++++++----- api/src/walker.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/api/src/job.ts b/api/src/job.ts index 667ce9c2..af58d294 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -2256,13 +2256,18 @@ export class Job { let sawVisibleNonHiddenEntry = false; try { for await (const entry of directory) { - if (entry.name === PTC_HISTORY_FILENAME) continue; - sawVisibleEntry = true; - state.remainingEntries--; - if (state.remainingEntries < 0) return rootPath; const fullPath = path.join(dir, entry.name); const relativePath = path.relative(this.submissionDir, fullPath); const kind = await this.classifyDirent(entry, fullPath, relativePath); + if (kind === 'file' && entry.name === PTC_HISTORY_FILENAME) { + if (inputByName.has(relativePath)) { + this.presentInputFiles.add(relativePath); + } + continue; + } + sawVisibleEntry = true; + state.remainingEntries--; + if (state.remainingEntries < 0) return rootPath; if (kind === 'skip') { /* Ordinary walking counts symlinks/special entries as non-empty even * though it does not surface them, so the probe must not invent a @@ -2436,7 +2441,7 @@ export class Job { * basename on the ordinary execution endpoint. It remains hidden from * output collection, but must be observed before the runtime fixture is * skipped so an untouched input is not reported as deleted. */ - if (isPtcReserved(entry.name)) continue; + if (kind === 'file' && isPtcReserved(entry.name)) continue; if (kind === 'dir') { /* Skip hidden directories (basename starts with `.`) unless the user diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index da037867..ec615370 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -1320,6 +1320,45 @@ describe('handleSessionFiles / persisted input deletion', () => { expect(internals.generatedFiles.map(file => file.name)).not.toContain(inherited.name); }); + it('traverses a directory that uses the reserved PTC history basename', async () => { + const inherited: TFile = { + id: 'nested-id', + storage_session_id: 'prior-session', + name: path.join('_ptc_history.json', 'data.csv'), + }; + await fsp.mkdir(path.join(tmpDir, '_ptc_history.json')); + await fsp.writeFile(path.join(tmpDir, inherited.name), 'persisted'); + const internals = asInternals(makeJob({ files: [inherited] })); + internals.submissionDir = tmpDir; + + await internals.handleSessionFiles(); + + expect(internals.deletedFiles).toEqual([]); + expect(internals.presentInputFiles.has(inherited.name)).toBe(true); + }); + + it('tracks a reserved persisted input during a capped subtree probe', async () => { + const inherited: TFile = { + id: 'history-id', + storage_session_id: 'prior-session', + name: path.join('fixtures', '_ptc_history.json'), + }; + const fixtures = path.join(tmpDir, 'fixtures'); + await fsp.mkdir(fixtures); + await fsp.writeFile(path.join(tmpDir, inherited.name), '{}'); + await fsp.writeFile(path.join(fixtures, 'unsupported.bin'), 'binary'); + const internals = asInternals(makeJob({ files: [inherited] })); + internals.submissionDir = tmpDir; + + const skipped = await internals.findTruncatedArtifact( + fixtures, + new Map([[inherited.name, inherited]]) + ); + + expect(skipped).toBeUndefined(); + expect(internals.presentInputFiles.has(inherited.name)).toBe(true); + }); + it('does not report a surviving input that is unsupported as an output artifact', async () => { const inherited: TFile = { id: 'prior-id',