From 6c43aebc1024a9745332cb39f80af8cc8f42f17c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 10:51:14 -0400 Subject: [PATCH 1/7] feat: report truncated output artifacts --- api/README.md | 9 +++ api/src/job.ts | 64 ++++++++++++++++++++-- api/src/walker.test.ts | 61 +++++++++++++++++++++ service/src/execution-log.test.ts | 13 +++++ service/src/execution-log.ts | 18 ++++++ service/src/service/blocking-poll.test.ts | 4 ++ service/src/service/blocking-poll.ts | 2 + service/src/service/programmatic-router.ts | 3 + service/src/types/service.ts | 12 ++++ service/src/workers.ts | 3 + 10 files changed, 185 insertions(+), 4 deletions(-) diff --git a/api/README.md b/api/README.md index bbbe36ec..0d5d388d 100644 --- a/api/README.md +++ b/api/README.md @@ -94,6 +94,15 @@ Other package-format-compatible runtimes (Go, Rust, Java, GCC) can be installed Execute code in a sandboxed environment. +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` +object counts detected omissions by cause, `skipped_count` reports the total +detected omissions, and `skipped` contains up to 20 relative paths so callers +can match an expected output. Intentional filters such as unsupported file +extensions, hidden runtime directories, and unchanged session files do not +produce this marker. + ### `GET /api/v2/runtimes` List available language runtimes. diff --git a/api/src/job.ts b/api/src/job.ts index b7b82148..b677f287 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -39,6 +39,7 @@ import { hasRunnableSource, isDirkeep, isValidPathShape, + checkPathShape, validateFilePath, isValidFilePath, } from './validation'; @@ -647,8 +648,20 @@ interface ExecuteResult { session_id: string; files: FileRef[]; artifact_delivery?: ArtifactDeliveryFailure; + artifact_truncation?: ArtifactTruncation; } +export type ArtifactTruncationReason = 'max_files' | 'depth' | 'size' | 'path' | 'unreadable'; + +export interface ArtifactTruncation { + code: 'artifact_truncated'; + reasons: Partial>; + skipped: string[]; + skipped_count: number; +} + +const MAX_REPORTED_TRUNCATED_PATHS = 20; + const jobQueue: Array<() => void> = []; async function acquireJobIdentity(log: Logger): Promise { @@ -693,6 +706,7 @@ export class Job { private pendingSurfaced = new Map(); private sessionFiles: FileRef[] = []; private inheritedRefs: FileRef[] = []; + private artifactTruncation: ArtifactTruncation | undefined; private inputFileHashes = new Map(); private inputManifest = new Map(); private inputDestinations = new Map(); @@ -1673,6 +1687,7 @@ export class Job { version: this.runtime.version.raw, session_id: this.outputSessionId, files: this.sessionFiles, + ...(this.artifactTruncation ? { artifact_truncation: this.artifactTruncation } : {}), }; } @@ -1680,6 +1695,7 @@ export class Job { this.generatedFiles = []; this.sessionFiles = []; this.inheritedRefs = []; + this.artifactTruncation = undefined; const inputByName = new Map(); for (const f of this.files) inputByName.set(f.name, f); @@ -1699,6 +1715,23 @@ export class Job { if (remaining > 0 && this.inheritedRefs.length > 0) { this.sessionFiles.push(...this.inheritedRefs.slice(0, remaining)); } + for (const ref of this.inheritedRefs.slice(remaining)) { + this.recordArtifactTruncation('max_files', ref.name); + } + } + + private recordArtifactTruncation(reason: ArtifactTruncationReason, relativePath: string): void { + this.artifactTruncation ??= { + code: 'artifact_truncated', + reasons: {}, + skipped: [], + skipped_count: 0, + }; + this.artifactTruncation.reasons[reason] = (this.artifactTruncation.reasons[reason] ?? 0) + 1; + this.artifactTruncation.skipped_count++; + if (this.artifactTruncation.skipped.length < MAX_REPORTED_TRUNCATED_PATHS) { + this.artifactTruncation.skipped.push(relativePath); + } } /** @@ -1722,6 +1755,7 @@ export class Job { isRegularFile = st.isFile(); } catch (err) { this.log.debug({ path: relativePath, err }, 'walkDir: failed to lstat entry'); + this.recordArtifactTruncation('unreadable', relativePath); return 'skip'; } } @@ -1770,6 +1804,7 @@ export class Job { return this.createDirkeepMarker(keepPath, keepFullPath); } if (this.generatedFiles.length >= config.max_output_files) { + this.recordArtifactTruncation('max_files', keepPath); return { collected: false, truncated: true }; } const id = nanoid(); @@ -1819,6 +1854,7 @@ export class Job { if (!keepModified || keepInfo?.readOnly === true) return this.echoInheritedKeep(keepPath, inheritedKeep); if (this.generatedFiles.length >= config.max_output_files) { + this.recordArtifactTruncation('max_files', keepPath); return { collected: false, truncated: true }; } const refreshedId = nanoid(); @@ -1860,6 +1896,7 @@ export class Job { inheritedKeep: TFile, ): { collected: boolean; truncated: boolean } { if (this.inheritedRefs.length >= config.max_output_files) { + this.recordArtifactTruncation('max_files', keepPath); return { collected: false, truncated: true }; } this.inheritedRefs.push({ @@ -1886,6 +1923,7 @@ export class Job { keepFullPath: string, ): Promise<{ collected: boolean; truncated: boolean }> { if (this.generatedFiles.length >= config.max_output_files) { + this.recordArtifactTruncation('max_files', keepPath); return { collected: false, truncated: true }; } try { @@ -1944,6 +1982,7 @@ export class Job { if (existingFile.id && existingFile.storage_session_id) { if (this.inheritedRefs.length >= config.max_output_files) { + this.recordArtifactTruncation('max_files', relativePath); return { collected: false, truncated: true }; } this.inheritedRefs.push({ @@ -2003,9 +2042,11 @@ export class Job { size = st.size; } catch (err) { this.log.debug({ path: relativePath, err }, 'walkDir: unable to stat file'); + this.recordArtifactTruncation('unreadable', relativePath); return { collected: false, truncated: false, stopLoop: false }; } if (size > this.runtime.max_file_size) { + this.recordArtifactTruncation('size', relativePath); return { collected: false, truncated: false, stopLoop: false }; } @@ -2067,6 +2108,7 @@ export class Job { if (echoed) return { ...echoed, stopLoop: false }; if (this.generatedFiles.length >= config.max_output_files) { + this.recordArtifactTruncation('max_files', relativePath); return { collected: false, truncated: true, stopLoop: true }; } @@ -2120,14 +2162,22 @@ export class Job { depth: number, inputByName: Map, ): Promise<'collected' | 'empty' | 'skipped'> { - if (depth >= config.max_nesting_depth) return 'skipped'; - if (this.isOutputCapFull()) return 'skipped'; + const relativeDir = path.relative(this.submissionDir, dir) || '.'; + if (depth >= config.max_nesting_depth) { + this.recordArtifactTruncation('depth', relativeDir); + return 'skipped'; + } + if (this.isOutputCapFull()) { + this.recordArtifactTruncation('max_files', relativeDir); + return 'skipped'; + } let entries: fs.Dirent[]; try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch (err) { this.log.debug({ dir, err }, 'walkDir: unable to read directory'); + this.recordArtifactTruncation('unreadable', relativeDir); return 'skipped'; } @@ -2166,12 +2216,18 @@ export class Job { let skippedHiddenDirs = 0; for (const entry of entries) { - if (this.isOutputCapFull()) { truncated = true; break; } if (isPtcReserved(entry.name)) continue; const fullPath = path.join(dir, entry.name); const relativePath = path.relative(this.submissionDir, fullPath); - if (!isValidPathShape(relativePath)) continue; + const pathShapeError = checkPathShape(relativePath); + if (pathShapeError) { + this.recordArtifactTruncation( + pathShapeError.includes('nesting depth') ? 'depth' : 'path', + relativePath, + ); + continue; + } const kind = await this.classifyDirent(entry, fullPath, relativePath); if (kind === 'skip') continue; diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index 91382468..a4c5132d 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -26,6 +26,12 @@ 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 }>; + artifactTruncation?: { + code: 'artifact_truncated'; + reasons: Partial>; + skipped: string[]; + skipped_count: number; + }; pendingSurfaced: Map; inputFileHashes: Map; files: TFile[]; @@ -743,6 +749,10 @@ describe('walkDir / output caps', () => { await internals.walkDir(tmpDir, 0, new Map()); expect(internals.generatedFiles.length).toBeLessThanOrEqual(cap); + expect(internals.artifactTruncation).toMatchObject({ + code: 'artifact_truncated', + reasons: { max_files: 1 }, + }); }); it('respects max_output_files cap on inherited refs', async () => { @@ -771,6 +781,11 @@ describe('walkDir / output caps', () => { expect(internals.inheritedRefs.length).toBeLessThanOrEqual(cap); expect(internals.generatedFiles).toHaveLength(0); + expect(internals.artifactTruncation).toMatchObject({ + code: 'artifact_truncated', + reasons: { max_files: 5 }, + skipped_count: 5, + }); }); }); @@ -792,6 +807,52 @@ describe('walkDir / depth cap', () => { const deepName = path.relative(tmpDir, path.join(cursor, 'deep.py')); expect(internals.generatedFiles.map(f => f.name)).not.toContain(deepName); + expect(internals.artifactTruncation).toMatchObject({ + code: 'artifact_truncated', + reasons: { depth: 1 }, + skipped_count: 1, + }); + expect(internals.artifactTruncation?.skipped[0]).toBe( + deepName.split(path.sep).slice(0, config.max_nesting_depth).join(path.sep), + ); + }); +}); + +describe('walkDir / artifact truncation details', () => { + it('reports oversized supported outputs while leaving them out of files', async () => { + await fsp.writeFile(path.join(tmpDir, 'large.txt'), 'too large'); + const job = makeJob({ maxFileSize: 3 }); + const internals = asInternals(job); + internals.submissionDir = tmpDir; + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.generatedFiles).toHaveLength(0); + expect(internals.artifactTruncation).toEqual({ + code: 'artifact_truncated', + reasons: { size: 1 }, + skipped: ['large.txt'], + skipped_count: 1, + }); + }); + + it('reports overlong output paths', async () => { + const directory = 'a'.repeat(200); + await fsp.mkdir(path.join(tmpDir, directory)); + const name = path.join(directory, `${'b'.repeat(60)}.txt`); + await fsp.writeFile(path.join(tmpDir, name), 'content'); + const job = makeJob(); + const internals = asInternals(job); + internals.submissionDir = tmpDir; + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.artifactTruncation).toEqual({ + code: 'artifact_truncated', + reasons: { path: 1 }, + skipped: [name], + skipped_count: 1, + }); }); }); diff --git a/service/src/execution-log.test.ts b/service/src/execution-log.test.ts index f04bad90..0f538125 100644 --- a/service/src/execution-log.test.ts +++ b/service/src/execution-log.test.ts @@ -28,6 +28,12 @@ describe('execution log summaries', () => { failed: 1, detail: 'private storage failure', }, + artifact_truncation: { + code: 'artifact_truncated', + reasons: { max_files: 2 }, + skipped: ['secret-one.txt', 'secret-two.txt'], + skipped_count: 2, + }, run: { code: 0, stdout: 'top secret stdout', @@ -42,6 +48,7 @@ describe('execution log summaries', () => { expect(JSON.stringify(summary)).not.toContain('sensitive stderr'); expect(JSON.stringify(summary)).not.toContain('combined output'); expect(JSON.stringify(summary)).not.toContain('private storage failure'); + expect(JSON.stringify(summary)).not.toContain('secret-one.txt'); expect(summary).toMatchObject({ session_id: 'sess_123', files: { count: 2, inheritedCount: 1, modifiedCount: 1 }, @@ -52,6 +59,12 @@ describe('execution log summaries', () => { delivered: 2, failed: 1, }, + artifact_truncation: { + code: 'artifact_truncated', + reasons: { max_files: 2 }, + skipped_count: 2, + reported_paths: 2, + }, run: { stdout: { length: 17, present: true }, stderr: { length: 16, present: true }, diff --git a/service/src/execution-log.ts b/service/src/execution-log.ts index 48a93cce..143e5541 100644 --- a/service/src/execution-log.ts +++ b/service/src/execution-log.ts @@ -19,6 +19,7 @@ type SandboxResponseLike = { version?: unknown; files?: unknown; artifact_delivery?: unknown; + artifact_truncation?: unknown; run?: RunLike; }; @@ -40,6 +41,22 @@ function summarizeArtifactDelivery(value: unknown): Record | un }; } +function summarizeArtifactTruncation(value: unknown): Record | undefined { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const truncation = value as { + code?: unknown; + reasons?: unknown; + skipped?: unknown; + skipped_count?: unknown; + }; + return { + code: truncation.code, + reasons: truncation.reasons, + skipped_count: truncation.skipped_count, + reported_paths: Array.isArray(truncation.skipped) ? truncation.skipped.length : undefined, + }; +} + export function summarizeText(value: unknown): { length: number; present: boolean } { if (typeof value !== 'string') { return { length: 0, present: false }; @@ -87,6 +104,7 @@ export function summarizeSandboxResponse(data: SandboxResponseLike): Record { expect(await pollBlockingExecution('exec', 5, deps)).toEqual({ status: 'completed', stdout: result.stdout, stderr: '', files: [], artifact_delivery: result.artifact_delivery, + artifact_truncation: result.artifact_truncation, }); expect(deps.now()).toBe(2); }); diff --git a/service/src/service/blocking-poll.ts b/service/src/service/blocking-poll.ts index 8ce07fa8..03e386d4 100644 --- a/service/src/service/blocking-poll.ts +++ b/service/src/service/blocking-poll.ts @@ -34,6 +34,7 @@ export async function pollBlockingExecution( stderr?: string; files?: t.FileRefs; artifact_delivery?: t.ArtifactDeliveryFailure; + artifact_truncation?: t.ArtifactTruncation; }> { const start = deps.now(); while (deps.now() - start < timeout) { @@ -48,6 +49,7 @@ export async function pollBlockingExecution( stderr: result.stderr, files: result.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 9896518c..3e409f07 100644 --- a/service/src/service/programmatic-router.ts +++ b/service/src/service/programmatic-router.ts @@ -933,6 +933,7 @@ async function runAndRespond( stderr: result.stderr, files: result.files, artifact_delivery: result.artifact_delivery, + artifact_truncation: result.artifact_truncation, session_id: state.session_id, }); } @@ -1152,6 +1153,7 @@ async function handleBlocking( stderr: state.stderr ?? '', files: state.files ?? [], artifact_delivery: state.artifact_delivery, + artifact_truncation: state.artifact_truncation, session_id: execution.session_id, }); } @@ -1405,6 +1407,7 @@ async function handleBlocking( stderr: state.stderr ?? '', files: state.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 2a90eac7..91fb2611 100644 --- a/service/src/types/service.ts +++ b/service/src/types/service.ts @@ -111,6 +111,15 @@ export interface ArtifactDeliveryFailure { failed: number; } +export type ArtifactTruncationReason = 'max_files' | 'depth' | 'size' | 'path' | 'unreadable'; + +export interface ArtifactTruncation { + code: 'artifact_truncated'; + reasons: Partial>; + skipped: string[]; + skipped_count: number; +} + export type ExecuteResponse = { run?: { stdout: string; @@ -130,6 +139,7 @@ export type ExecuteResponse = { session_id: string; files: FileRefs; artifact_delivery?: ArtifactDeliveryFailure; + artifact_truncation?: ArtifactTruncation; }; export interface RequestBody { @@ -233,6 +243,7 @@ export type ExecuteResult = { stderr: string; files: FileRefs; artifact_delivery?: ArtifactDeliveryFailure; + artifact_truncation?: ArtifactTruncation; code?: number | null; signal?: string | null; message?: string | null; @@ -371,6 +382,7 @@ export interface ProgrammaticResponse { stderr?: string; files?: FileRefs; artifact_delivery?: ArtifactDeliveryFailure; + artifact_truncation?: ArtifactTruncation; /** Top-level execution session id (one sandbox PTC invocation). */ session_id?: string; tool_calls_made?: number; diff --git a/service/src/workers.ts b/service/src/workers.ts index ad20fd0f..281265fd 100644 --- a/service/src/workers.ts +++ b/service/src/workers.ts @@ -182,6 +182,9 @@ async function processJobInner(job: t.ExecuteJob): Promise { ...(responseData.artifact_delivery != null ? { artifact_delivery: responseData.artifact_delivery } : {}), + ...(responseData.artifact_truncation != null + ? { artifact_truncation: responseData.artifact_truncation } + : {}), stdout, stderr, }; From ccbd7982ac76765fb44604d1fa69eb5ef231133b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 11:12:03 -0400 Subject: [PATCH 2/7] fix: classify omitted artifacts precisely --- api/src/job.ts | 59 +++++++++++-------- api/src/walker.test.ts | 66 ++++++++++++++++++++++ service/src/service/programmatic-router.ts | 3 + 3 files changed, 106 insertions(+), 22 deletions(-) diff --git a/api/src/job.ts b/api/src/job.ts index b677f287..4a60b92b 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -36,10 +36,9 @@ import { SANDBOX_DIR_MODE, SANDBOX_FILE_MODE, ValidationError, + checkPathShape, hasRunnableSource, isDirkeep, - isValidPathShape, - checkPathShape, validateFilePath, isValidFilePath, } from './validation'; @@ -1776,7 +1775,14 @@ export class Job { inputByName: Map, ): Promise<{ collected: boolean; truncated: boolean }> { const keepPath = path.join(relativePath, DIRKEEP); - if (!isValidPathShape(keepPath)) return { collected: false, truncated: false }; + const pathShapeError = checkPathShape(keepPath); + if (pathShapeError) { + this.recordArtifactTruncation( + pathShapeError.includes('nesting depth') ? 'depth' : 'path', + keepPath, + ); + return { collected: false, truncated: true }; + } const keepFullPath = path.join(fullPath, DIRKEEP); const inheritedKeep = inputByName.get(keepPath); @@ -2045,11 +2051,6 @@ export class Job { this.recordArtifactTruncation('unreadable', relativePath); return { collected: false, truncated: false, stopLoop: false }; } - if (size > this.runtime.max_file_size) { - this.recordArtifactTruncation('size', relativePath); - return { collected: false, truncated: false, stopLoop: false }; - } - /* Session mode output diffing + input-modification detection. Hash by * CONTENT (not size+mtime): a program can rewrite a surfaced output with * different bytes while preserving size+mtime (os.utime / touch -r), which a @@ -2099,6 +2100,20 @@ export class Job { if (wasModified) this.log.info({ file: relativePath }, 'Input file was modified'); } + /* The unchanged inline entrypoint is executable request input, not an + * output artifact. Suppress it before applying output-size reporting; + * downloaded inputs still flow through the size limit below, preserving + * the existing response-cap behavior for inherited refs. */ + if (!wasModified && inputFileInfo && existingFile?.id == null + && relativePath === this.entryPointName) { + return { collected: true, truncated: false, stopLoop: false }; + } + + if (size > this.runtime.max_file_size) { + this.recordArtifactTruncation('size', relativePath); + return { collected: false, truncated: false, stopLoop: false }; + } + const echoed = this.tryEchoUnchangedInput({ wasModified, inputFileInfo, @@ -2167,11 +2182,6 @@ export class Job { this.recordArtifactTruncation('depth', relativeDir); return 'skipped'; } - if (this.isOutputCapFull()) { - this.recordArtifactTruncation('max_files', relativeDir); - return 'skipped'; - } - let entries: fs.Dirent[]; try { entries = await fsp.readdir(dir, { withFileTypes: true }); @@ -2220,15 +2230,6 @@ export class Job { const fullPath = path.join(dir, entry.name); const relativePath = path.relative(this.submissionDir, fullPath); - const pathShapeError = checkPathShape(relativePath); - if (pathShapeError) { - this.recordArtifactTruncation( - pathShapeError.includes('nesting depth') ? 'depth' : 'path', - relativePath, - ); - continue; - } - const kind = await this.classifyDirent(entry, fullPath, relativePath); if (kind === 'skip') continue; @@ -2248,6 +2249,20 @@ export class Job { const res = await this.walkSubdirectory(relativePath, fullPath, depth, inputByName); if (res.collected) hasCollectedChild = true; if (res.truncated) truncated = true; + if (this.artifactTruncation?.reasons.max_files) break; + continue; + } + + /* Check intentional filename filtering before path limits. Unsupported + * files never belong in files[], regardless of how long their path is. */ + if (entry.name !== DIRKEEP && !isSupportedOutputFilename(entry.name)) continue; + + const pathShapeError = checkPathShape(relativePath); + if (pathShapeError) { + this.recordArtifactTruncation( + pathShapeError.includes('nesting depth') ? 'depth' : 'path', + relativePath, + ); continue; } diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index a4c5132d..41177a0d 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -854,6 +854,72 @@ describe('walkDir / artifact truncation details', () => { skipped_count: 1, }); }); + + it('does not report an overlong path for an unsupported output', async () => { + const directory = 'a'.repeat(200); + await fsp.mkdir(path.join(tmpDir, directory)); + await fsp.writeFile(path.join(tmpDir, directory, `${'b'.repeat(60)}.bin`), 'ignored'); + const job = makeJob(); + const internals = asInternals(job); + internals.submissionDir = tmpDir; + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.generatedFiles).toHaveLength(0); + expect(internals.artifactTruncation).toBeUndefined(); + }); + + it('reports an empty-directory marker whose appended path is too long', async () => { + const directory = 'a'.repeat(config.max_path_length - 6); + await fsp.mkdir(path.join(tmpDir, directory)); + const job = makeJob(); + const internals = asInternals(job); + internals.submissionDir = tmpDir; + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.artifactTruncation).toEqual({ + code: 'artifact_truncated', + reasons: { path: 1 }, + skipped: [path.join(directory, DIRKEEP)], + skipped_count: 1, + }); + }); + + it('does not report an unchanged oversized inline entrypoint', async () => { + const name = 'main.py'; + const content = 'print(1)'; + const full = path.join(tmpDir, name); + await fsp.writeFile(full, content); + const inline: TFile = { name, content }; + const job = makeJob({ files: [inline], maxFileSize: 3 }); + const internals = asInternals(job); + internals.submissionDir = tmpDir; + internals.entryPointName = name; + internals.inputFileHashes.set(name, { hash: sha256(content), path: full }); + + await internals.walkDir(tmpDir, 0, buildInputByName([inline])); + + expect(internals.generatedFiles).toHaveLength(0); + expect(internals.artifactTruncation).toBeUndefined(); + }); + + it('inspects a capped directory before deciding whether an artifact was omitted', async () => { + const job = makeJob(); + const internals = asInternals(job); + internals.submissionDir = tmpDir; + internals.generatedFiles = Array.from({ length: config.max_output_files }, (_, i) => ({ + id: `id-${i}`, + name: `file-${i}.txt`, + path: path.join(tmpDir, `file-${i}.txt`), + })); + await fsp.mkdir(path.join(tmpDir, 'ignored')); + await fsp.writeFile(path.join(tmpDir, 'ignored', 'cache.bin'), 'ignored'); + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.artifactTruncation).toBeUndefined(); + }); }); describe('handleSessionFiles / priority-fill composition', () => { diff --git a/service/src/service/programmatic-router.ts b/service/src/service/programmatic-router.ts index 3e409f07..8e7cec3a 100644 --- a/service/src/service/programmatic-router.ts +++ b/service/src/service/programmatic-router.ts @@ -920,6 +920,9 @@ async function runAndRespond( error: errorMessage, stdout: cleanStdout, stderr: result.stderr, + files: result.files, + artifact_delivery: result.artifact_delivery, + artifact_truncation: result.artifact_truncation, session_id: state.session_id, }); return; From a9e6bb64aa5648362d454e029a4df12a7b28c5e8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 11:26:32 -0400 Subject: [PATCH 3/7] fix: preserve artifact scan invariants --- api/src/job.ts | 90 ++++++++++++++++++++++++++++++++++++------ api/src/walker.test.ts | 82 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 158 insertions(+), 14 deletions(-) diff --git a/api/src/job.ts b/api/src/job.ts index 4a60b92b..4280ac57 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -60,6 +60,7 @@ export { const AUTO_LOAD_DIRKEEP_TIMEOUT_MS = 10000; const AUTO_LOAD_DIRKEEP_RETRIES = 2; +const PTC_HISTORY_FILENAME = '_ptc_history.json'; /** Replaying the same sealed grant cannot repair an authorization denial. */ class InputAuthorizationError extends Error { @@ -2051,14 +2052,35 @@ export class Job { this.recordArtifactTruncation('unreadable', relativePath); return { collected: false, truncated: false, stopLoop: false }; } + + const inputFileInfo = this.inputFileHashes.get(relativePath); + const existingFile = inputByName.get(relativePath); + if (size > this.runtime.max_file_size) { + /* Only an inline entrypoint needs hashing to decide whether this is + * intentional request-input suppression. Every other oversized file + * is rejected immediately, preserving the scan's bounded I/O cost. */ + if (!inputFileInfo || existingFile?.id != null || relativePath !== this.entryPointName) { + this.recordArtifactTruncation('size', relativePath); + return { collected: false, truncated: false, stopLoop: false }; + } + try { + const currentHash = await this.computeFileHash(fullPath, true); + if (currentHash === inputFileInfo.hash) { + return { collected: true, truncated: false, stopLoop: false }; + } + } catch (err) { + this.log.debug({ path: relativePath, err }, 'walkDir: failed to hash oversized entrypoint'); + } + this.recordArtifactTruncation('size', relativePath); + return { collected: false, truncated: false, stopLoop: false }; + } + /* Session mode output diffing + input-modification detection. Hash by * CONTENT (not size+mtime): a program can rewrite a surfaced output with * different bytes while preserving size+mtime (os.utime / touch -r), which a * stat-only signature would wrongly suppress. Compute once per session/input * file and reuse for the suppression check, wasModified, and the surfaced * mark; non-session jobs still only hash their inputs. */ - const inputFileInfo = this.inputFileHashes.get(relativePath); - const existingFile = inputByName.get(relativePath); let contentHash: string | undefined; if (inputFileInfo != null || this.session != null) { try { @@ -2109,11 +2131,6 @@ export class Job { return { collected: true, truncated: false, stopLoop: false }; } - if (size > this.runtime.max_file_size) { - this.recordArtifactTruncation('size', relativePath); - return { collected: false, truncated: false, stopLoop: false }; - } - const echoed = this.tryEchoUnchangedInput({ wasModified, inputFileInfo, @@ -2162,10 +2179,61 @@ export class Job { const childStatus = await this.walkDir(fullPath, parentDepth + 1, inputByName); if (childStatus === 'collected') return { collected: true, truncated: false }; if (childStatus === 'skipped') return { collected: false, truncated: true }; - if (this.isOutputCapFull()) return { collected: false, truncated: true }; return this.handleEmptyDirectory(relativePath, fullPath, inputByName); } + /** Finds the first artifact that a depth cap would hide without reading file + * contents. Files below this boundary cannot be valid primed inputs, and + * symlinks/unsupported files/hidden runtime directories remain intentional + * exclusions. An empty directory represents a reportable `.dirkeep`. */ + private async findDepthTruncatedArtifact( + dir: string, + inputByName: Map, + ): Promise { + let entries: fs.Dirent[]; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch (err) { + const relativeDir = path.relative(this.submissionDir, dir) || '.'; + this.log.debug({ dir, err }, 'walkDir: unable to inspect depth-capped directory'); + this.recordArtifactTruncation('unreadable', relativeDir); + return undefined; + } + + const visibleEntries = entries.filter(entry => entry.name !== PTC_HISTORY_FILENAME); + if (visibleEntries.length === 0) { + return path.join(path.relative(this.submissionDir, dir), DIRKEEP); + } + + let sawVisibleNonHiddenEntry = false; + for (const entry of visibleEntries) { + const fullPath = path.join(dir, entry.name); + const relativePath = path.relative(this.submissionDir, fullPath); + const kind = await this.classifyDirent(entry, fullPath, relativePath); + if (kind === 'skip') { + /* Ordinary walking counts symlinks/special entries as non-empty even + * though it does not surface them, so the depth probe must not invent + * a parent .dirkeep for that shape. */ + sawVisibleNonHiddenEntry = true; + continue; + } + if (kind === 'file') { + sawVisibleNonHiddenEntry = true; + if (entry.name === DIRKEEP || isSupportedOutputFilename(entry.name)) return relativePath; + continue; + } + if (kind === 'dir') { + if (isHiddenDirectory(entry.name) && !inputsLiveUnder(inputByName, relativePath)) continue; + sawVisibleNonHiddenEntry = true; + const nested = await this.findDepthTruncatedArtifact(fullPath, inputByName); + if (nested) return nested; + } + } + return sawVisibleNonHiddenEntry + ? undefined + : path.join(path.relative(this.submissionDir, dir), DIRKEEP); + } + /** * Recursively scans the submission directory for output files. Returns a * status distinguishing truly empty directories from scans truncated by @@ -2179,7 +2247,8 @@ export class Job { ): Promise<'collected' | 'empty' | 'skipped'> { const relativeDir = path.relative(this.submissionDir, dir) || '.'; if (depth >= config.max_nesting_depth) { - this.recordArtifactTruncation('depth', relativeDir); + const skippedPath = await this.findDepthTruncatedArtifact(dir, inputByName); + if (skippedPath) this.recordArtifactTruncation('depth', skippedPath); return 'skipped'; } let entries: fs.Dirent[]; @@ -2206,7 +2275,6 @@ export class Job { * separate npm packages so we can't import directly; the filename literal * is asserted-equal in `service/scripts/test-ptc-sentinel.ts` to catch * accidental drift in CI. */ - const PTC_HISTORY_FILENAME = '_ptc_history.json'; const isPtcReserved = (name: string): boolean => name === PTC_HISTORY_FILENAME; const nonDirkeepCount = entries.reduce( @@ -2249,7 +2317,7 @@ export class Job { const res = await this.walkSubdirectory(relativePath, fullPath, depth, inputByName); if (res.collected) hasCollectedChild = true; if (res.truncated) truncated = true; - if (this.artifactTruncation?.reasons.max_files) break; + if (this.isOutputCapFull() && this.artifactTruncation?.reasons.max_files) break; continue; } diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index 41177a0d..bc38b3f6 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -37,6 +37,7 @@ interface WalkerInternals { files: TFile[]; reusePrimedInput: (file: TFile) => Promise; writeFile: (file: TFile) => Promise; + computeFileHash: (filePath: string, noFollow?: boolean) => Promise; walkDir: (dir: string, depth: number, inputByName: Map) => Promise<'collected' | 'empty' | 'skipped'>; handleSessionFiles: () => Promise; } @@ -812,9 +813,22 @@ describe('walkDir / depth cap', () => { reasons: { depth: 1 }, skipped_count: 1, }); - expect(internals.artifactTruncation?.skipped[0]).toBe( - deepName.split(path.sep).slice(0, config.max_nesting_depth).join(path.sep), - ); + expect(internals.artifactTruncation?.skipped[0]).toBe(deepName); + }); + + it('does not report a depth cap when the skipped subtree has only unsupported files', async () => { + let cursor = tmpDir; + for (let i = 0; i < config.max_nesting_depth; i++) { + cursor = path.join(cursor, `d${i}`); + await fsp.mkdir(cursor); + } + await fsp.writeFile(path.join(cursor, 'cache.bin'), 'ignored'); + const internals = asInternals(makeJob()); + internals.submissionDir = tmpDir; + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.artifactTruncation).toBeUndefined(); }); }); @@ -920,6 +934,68 @@ describe('walkDir / artifact truncation details', () => { expect(internals.artifactTruncation).toBeUndefined(); }); + + it('reports a capped empty-directory marker', async () => { + const job = makeJob(); + const internals = asInternals(job); + internals.submissionDir = tmpDir; + internals.generatedFiles = Array.from({ length: config.max_output_files }, (_, i) => ({ + id: `id-${i}`, + name: `file-${i}.txt`, + path: path.join(tmpDir, `file-${i}.txt`), + })); + await fsp.mkdir(path.join(tmpDir, 'empty')); + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.artifactTruncation).toEqual({ + code: 'artifact_truncated', + reasons: { max_files: 1 }, + skipped: [path.join('empty', DIRKEEP)], + skipped_count: 1, + }); + }); + + it('does not hash ordinary oversized files in session mode', async () => { + await fsp.writeFile(path.join(tmpDir, 'large.txt'), 'too large'); + const session = new SessionWorkspace({ runtimeSessionId: 'rt_large' }); + const internals = asInternals(makeJob({ maxFileSize: 3, session })); + internals.submissionDir = tmpDir; + let hashCalls = 0; + internals.computeFileHash = async () => { + hashCalls++; + return sha256('too large'); + }; + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(hashCalls).toBe(0); + expect(internals.artifactTruncation?.reasons).toEqual({ size: 1 }); + }); + + it('keeps scanning for generated outputs when only inherited refs are capped', async () => { + await fsp.mkdir(path.join(tmpDir, 'a-ignored')); + await fsp.writeFile(path.join(tmpDir, 'a-ignored', 'cache.bin'), 'ignored'); + await fsp.writeFile(path.join(tmpDir, 'z-generated.txt'), 'new'); + const internals = asInternals(makeJob()); + internals.submissionDir = tmpDir; + internals.inheritedRefs = Array.from({ length: config.max_output_files }, (_, i) => ({ + id: `id-${i}`, + name: `inherited-${i}.txt`, + storage_session_id: 'previous', + inherited: true, + })); + internals.artifactTruncation = { + code: 'artifact_truncated', + reasons: { max_files: 1 }, + skipped: ['another-inherited.txt'], + skipped_count: 1, + }; + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.generatedFiles.map(file => file.name)).toContain('z-generated.txt'); + }); }); describe('handleSessionFiles / priority-fill composition', () => { From 2bfe84a42b72b604ce5022fb8a33dd0640fe16c4 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 11:35:58 -0400 Subject: [PATCH 4/7] fix: bound depth truncation probes --- api/README.md | 4 +++- api/src/job.ts | 19 ++++++++++++++++++- api/src/walker.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/api/README.md b/api/README.md index 0d5d388d..665a2931 100644 --- a/api/README.md +++ b/api/README.md @@ -101,7 +101,9 @@ object counts detected omissions by cause, `skipped_count` reports the total detected omissions, and `skipped` contains up to 20 relative paths so callers can match an expected output. Intentional filters such as unsupported file extensions, hidden runtime directories, and unchanged session files do not -produce this marker. +produce this marker when they can be classified within the bounded scan. A +depth-capped subtree that exceeds the metadata probe budget is reported +conservatively rather than allowing post-execution traversal to run unbounded. ### `GET /api/v2/runtimes` diff --git a/api/src/job.ts b/api/src/job.ts index 4280ac57..0d8c7ff3 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -61,6 +61,8 @@ export { const AUTO_LOAD_DIRKEEP_TIMEOUT_MS = 10000; const AUTO_LOAD_DIRKEEP_RETRIES = 2; const PTC_HISTORY_FILENAME = '_ptc_history.json'; +const DEPTH_TRUNCATION_PROBE_MAX_ENTRIES = 1000; +const DEPTH_TRUNCATION_PROBE_MAX_LEVELS = 10; /** Replaying the same sealed grant cannot repair an authorization denial. */ class InputAuthorizationError extends Error { @@ -2189,6 +2191,9 @@ export class Job { private async findDepthTruncatedArtifact( dir: string, inputByName: Map, + state = { remainingEntries: DEPTH_TRUNCATION_PROBE_MAX_ENTRIES }, + probeDepth = 0, + rootPath = path.relative(this.submissionDir, dir) || '.', ): Promise { let entries: fs.Dirent[]; try { @@ -2207,6 +2212,8 @@ export class Job { let sawVisibleNonHiddenEntry = false; for (const entry of visibleEntries) { + 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); @@ -2225,7 +2232,17 @@ export class Job { if (kind === 'dir') { if (isHiddenDirectory(entry.name) && !inputsLiveUnder(inputByName, relativePath)) continue; sawVisibleNonHiddenEntry = true; - const nested = await this.findDepthTruncatedArtifact(fullPath, inputByName); + /* The probe exists only to avoid false warnings for small, obviously + * unsupported-only subtrees. Once either budget is exhausted, report + * the capped root conservatively instead of defeating the scan bound. */ + if (probeDepth >= DEPTH_TRUNCATION_PROBE_MAX_LEVELS) return rootPath; + const nested = await this.findDepthTruncatedArtifact( + fullPath, + inputByName, + state, + probeDepth + 1, + rootPath, + ); if (nested) return nested; } } diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index bc38b3f6..5b80e308 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -830,6 +830,31 @@ describe('walkDir / depth cap', () => { expect(internals.artifactTruncation).toBeUndefined(); }); + + it('bounds depth-cap eligibility probes and reports the capped subtree conservatively', async () => { + let cursor = tmpDir; + const totalDepth = config.max_nesting_depth + 12; + for (let i = 0; i < totalDepth; i++) { + cursor = path.join(cursor, `d${i}`); + await fsp.mkdir(cursor); + } + await fsp.writeFile(path.join(cursor, 'cache.bin'), 'ignored'); + const internals = asInternals(makeJob()); + internals.submissionDir = tmpDir; + + await internals.walkDir(tmpDir, 0, new Map()); + + const cappedRoot = Array.from( + { length: config.max_nesting_depth }, + (_, i) => `d${i}`, + ).join(path.sep); + expect(internals.artifactTruncation).toEqual({ + code: 'artifact_truncated', + reasons: { depth: 1 }, + skipped: [cappedRoot], + skipped_count: 1, + }); + }); }); describe('walkDir / artifact truncation details', () => { From ba2c778eb10e9f8c2681e14c5e5948f11cb93023 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 11:43:02 -0400 Subject: [PATCH 5/7] fix: bound capped directory enumeration --- api/src/job.ts | 81 +++++++++++++++++++++++------------------- api/src/walker.test.ts | 23 ++++++++++++ 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/api/src/job.ts b/api/src/job.ts index 0d8c7ff3..278058b6 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -61,8 +61,8 @@ export { const AUTO_LOAD_DIRKEEP_TIMEOUT_MS = 10000; const AUTO_LOAD_DIRKEEP_RETRIES = 2; const PTC_HISTORY_FILENAME = '_ptc_history.json'; -const DEPTH_TRUNCATION_PROBE_MAX_ENTRIES = 1000; -const DEPTH_TRUNCATION_PROBE_MAX_LEVELS = 10; +const TRUNCATION_PROBE_MAX_ENTRIES = 1000; +const TRUNCATION_PROBE_MAX_LEVELS = 10; /** Replaying the same sealed grant cannot repair an authorization denial. */ class InputAuthorizationError extends Error { @@ -2184,20 +2184,20 @@ export class Job { return this.handleEmptyDirectory(relativePath, fullPath, inputByName); } - /** Finds the first artifact that a depth cap would hide without reading file - * contents. Files below this boundary cannot be valid primed inputs, and + /** Finds the first artifact that a scan cap would hide without reading file + * contents. Files below a depth boundary cannot be valid primed inputs, and * symlinks/unsupported files/hidden runtime directories remain intentional * exclusions. An empty directory represents a reportable `.dirkeep`. */ - private async findDepthTruncatedArtifact( + private async findTruncatedArtifact( dir: string, inputByName: Map, - state = { remainingEntries: DEPTH_TRUNCATION_PROBE_MAX_ENTRIES }, + state = { remainingEntries: TRUNCATION_PROBE_MAX_ENTRIES }, probeDepth = 0, rootPath = path.relative(this.submissionDir, dir) || '.', ): Promise { - let entries: fs.Dirent[]; + let directory: fs.Dir; try { - entries = await fsp.readdir(dir, { withFileTypes: true }); + directory = await fsp.opendir(dir); } catch (err) { const relativeDir = path.relative(this.submissionDir, dir) || '.'; this.log.debug({ dir, err }, 'walkDir: unable to inspect depth-capped directory'); @@ -2205,38 +2205,36 @@ export class Job { return undefined; } - const visibleEntries = entries.filter(entry => entry.name !== PTC_HISTORY_FILENAME); - if (visibleEntries.length === 0) { - return path.join(path.relative(this.submissionDir, dir), DIRKEEP); - } - + let sawVisibleEntry = false; let sawVisibleNonHiddenEntry = false; - for (const entry of visibleEntries) { - 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 === 'skip') { - /* Ordinary walking counts symlinks/special entries as non-empty even - * though it does not surface them, so the depth probe must not invent - * a parent .dirkeep for that shape. */ - sawVisibleNonHiddenEntry = true; - continue; - } - if (kind === 'file') { - sawVisibleNonHiddenEntry = true; - if (entry.name === DIRKEEP || isSupportedOutputFilename(entry.name)) return relativePath; - continue; - } - if (kind === 'dir') { + 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 === 'skip') { + /* Ordinary walking counts symlinks/special entries as non-empty even + * though it does not surface them, so the probe must not invent a + * parent .dirkeep for that shape. */ + sawVisibleNonHiddenEntry = true; + continue; + } + if (kind === 'file') { + sawVisibleNonHiddenEntry = true; + if (entry.name === DIRKEEP || isSupportedOutputFilename(entry.name)) return relativePath; + continue; + } if (isHiddenDirectory(entry.name) && !inputsLiveUnder(inputByName, relativePath)) continue; sawVisibleNonHiddenEntry = true; /* The probe exists only to avoid false warnings for small, obviously * unsupported-only subtrees. Once either budget is exhausted, report * the capped root conservatively instead of defeating the scan bound. */ - if (probeDepth >= DEPTH_TRUNCATION_PROBE_MAX_LEVELS) return rootPath; - const nested = await this.findDepthTruncatedArtifact( + if (probeDepth >= TRUNCATION_PROBE_MAX_LEVELS) return rootPath; + const nested = await this.findTruncatedArtifact( fullPath, inputByName, state, @@ -2245,8 +2243,14 @@ export class Job { ); if (nested) return nested; } + } catch (err) { + const relativeDir = path.relative(this.submissionDir, dir) || '.'; + this.log.debug({ dir, err }, 'walkDir: failed during bounded directory inspection'); + this.recordArtifactTruncation('unreadable', relativeDir); + return undefined; } - return sawVisibleNonHiddenEntry + + return sawVisibleEntry && sawVisibleNonHiddenEntry ? undefined : path.join(path.relative(this.submissionDir, dir), DIRKEEP); } @@ -2264,10 +2268,15 @@ export class Job { ): Promise<'collected' | 'empty' | 'skipped'> { const relativeDir = path.relative(this.submissionDir, dir) || '.'; if (depth >= config.max_nesting_depth) { - const skippedPath = await this.findDepthTruncatedArtifact(dir, inputByName); + const skippedPath = await this.findTruncatedArtifact(dir, inputByName); if (skippedPath) this.recordArtifactTruncation('depth', skippedPath); return 'skipped'; } + if (this.isOutputCapFull()) { + const skippedPath = await this.findTruncatedArtifact(dir, inputByName); + if (skippedPath) this.recordArtifactTruncation('max_files', skippedPath); + return 'skipped'; + } let entries: fs.Dirent[]; try { entries = await fsp.readdir(dir, { withFileTypes: true }); diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index 5b80e308..d038764b 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -1021,6 +1021,29 @@ describe('walkDir / artifact truncation details', () => { expect(internals.generatedFiles.map(file => file.name)).toContain('z-generated.txt'); }); + + it('bounds output-cap eligibility probes for wide unsupported-only directories', async () => { + const job = makeJob(); + const internals = asInternals(job); + internals.submissionDir = tmpDir; + internals.generatedFiles = Array.from({ length: config.max_output_files }, (_, i) => ({ + id: `id-${i}`, + name: `file-${i}.txt`, + path: path.join(tmpDir, `file-${i}.txt`), + })); + for (let i = 0; i < 1001; i++) { + await fsp.writeFile(path.join(tmpDir, `ignored-${i}.bin`), 'ignored'); + } + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.artifactTruncation).toEqual({ + code: 'artifact_truncated', + reasons: { max_files: 1 }, + skipped: ['.'], + skipped_count: 1, + }); + }); }); describe('handleSessionFiles / priority-fill composition', () => { From 2427902240e08684acb0c301e7023cb2b83b5d60 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 12:07:19 -0400 Subject: [PATCH 6/7] fix: constrain truncation probes across the job --- api/src/job.ts | 75 ++++++++++++++++++++++++++++++++++++--- api/src/walker.test.ts | 80 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/api/src/job.ts b/api/src/job.ts index 278058b6..e8b96eb5 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -63,6 +63,12 @@ const AUTO_LOAD_DIRKEEP_RETRIES = 2; const PTC_HISTORY_FILENAME = '_ptc_history.json'; const TRUNCATION_PROBE_MAX_ENTRIES = 1000; const TRUNCATION_PROBE_MAX_LEVELS = 10; +const TRUNCATION_PROBE_MAX_HASH_BYTES = 50_000_000; + +interface TruncationProbeState { + remainingEntries: number; + remainingHashBytes: number; +} /** Replaying the same sealed grant cannot repair an authorization denial. */ class InputAuthorizationError extends Error { @@ -709,6 +715,10 @@ export class Job { private sessionFiles: FileRef[] = []; private inheritedRefs: FileRef[] = []; private artifactTruncation: ArtifactTruncation | undefined; + private truncationProbeState: TruncationProbeState = { + remainingEntries: TRUNCATION_PROBE_MAX_ENTRIES, + remainingHashBytes: TRUNCATION_PROBE_MAX_HASH_BYTES, + }; private inputFileHashes = new Map(); private inputManifest = new Map(); private inputDestinations = new Map(); @@ -1698,6 +1708,10 @@ export class Job { this.sessionFiles = []; this.inheritedRefs = []; this.artifactTruncation = undefined; + this.truncationProbeState = { + remainingEntries: TRUNCATION_PROBE_MAX_ENTRIES, + remainingHashBytes: TRUNCATION_PROBE_MAX_HASH_BYTES, + }; const inputByName = new Map(); for (const f of this.files) inputByName.set(f.name, f); @@ -2191,9 +2205,10 @@ export class Job { private async findTruncatedArtifact( dir: string, inputByName: Map, - state = { remainingEntries: TRUNCATION_PROBE_MAX_ENTRIES }, + state = this.truncationProbeState, probeDepth = 0, rootPath = path.relative(this.submissionDir, dir) || '.', + respectSessionSuppression = false, ): Promise { let directory: fs.Dir; try { @@ -2225,8 +2240,33 @@ export class Job { } if (kind === 'file') { sawVisibleNonHiddenEntry = true; - if (entry.name === DIRKEEP || isSupportedOutputFilename(entry.name)) return relativePath; - continue; + if (entry.name !== DIRKEEP && !isSupportedOutputFilename(entry.name)) continue; + /* Once generated outputs fill the response cap, a persistent + * workspace may still contain unchanged artifacts from earlier + * turns. Ordinary walking suppresses those via their content hash, + * so the bounded cap probe must do the same or it reports a false + * max_files warning. Current-request inputs remain reportable: they + * would otherwise have been echoed into this response. */ + if (respectSessionSuppression && this.session && !inputByName.has(relativePath)) { + if (this.session.isPrimedReadOnly(relativePath)) continue; + try { + const st = await fsp.lstat(fullPath); + if (!st.isFile()) continue; + if (st.size > state.remainingHashBytes) return rootPath; + state.remainingHashBytes -= st.size; + const hash = await this.computeFileHash(fullPath, true); + if (this.session.isSurfaced(relativePath, hash)) continue; + if ( + this.session.isPrimedInput(relativePath) + && this.session.primedHash(relativePath) === hash + ) continue; + } catch (err) { + this.log.debug({ path: relativePath, err }, 'walkDir: failed during cap-probe hashing'); + this.recordArtifactTruncation('unreadable', relativePath); + continue; + } + } + return relativePath; } if (isHiddenDirectory(entry.name) && !inputsLiveUnder(inputByName, relativePath)) continue; sawVisibleNonHiddenEntry = true; @@ -2240,6 +2280,7 @@ export class Job { state, probeDepth + 1, rootPath, + respectSessionSuppression, ); if (nested) return nested; } @@ -2273,7 +2314,14 @@ export class Job { return 'skipped'; } if (this.isOutputCapFull()) { - const skippedPath = await this.findTruncatedArtifact(dir, inputByName); + const skippedPath = await this.findTruncatedArtifact( + dir, + inputByName, + this.truncationProbeState, + 0, + relativeDir, + true, + ); if (skippedPath) this.recordArtifactTruncation('max_files', skippedPath); return 'skipped'; } @@ -2340,6 +2388,24 @@ export class Job { skippedHiddenDirs++; continue; } + const pathShapeError = checkPathShape(relativePath); + if (pathShapeError) { + const skippedPath = await this.findTruncatedArtifact( + fullPath, + inputByName, + this.truncationProbeState, + 0, + relativePath, + ); + if (skippedPath) { + this.recordArtifactTruncation( + pathShapeError.includes('nesting depth') ? 'depth' : 'path', + skippedPath, + ); + truncated = true; + } + continue; + } const res = await this.walkSubdirectory(relativePath, fullPath, depth, inputByName); if (res.collected) hasCollectedChild = true; if (res.truncated) truncated = true; @@ -2357,6 +2423,7 @@ export class Job { pathShapeError.includes('nesting depth') ? 'depth' : 'path', relativePath, ); + truncated = true; continue; } diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index d038764b..a468c664 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -925,6 +925,45 @@ describe('walkDir / artifact truncation details', () => { }); }); + it('reports an explicit overlong .dirkeep exactly once', async () => { + const directory = 'a'.repeat(config.max_path_length - 6); + await fsp.mkdir(path.join(tmpDir, directory)); + const keepName = path.join(directory, DIRKEEP); + await fsp.writeFile(path.join(tmpDir, keepName), ''); + const internals = asInternals(makeJob()); + internals.submissionDir = tmpDir; + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.artifactTruncation).toEqual({ + code: 'artifact_truncated', + reasons: { path: 1 }, + skipped: [keepName], + skipped_count: 1, + }); + }); + + it('uses a bounded probe instead of recursively walking an overlong directory', async () => { + const first = 'a'.repeat(200); + const second = 'b'.repeat(60); + const overlongDir = path.join(first, second); + await fsp.mkdir(path.join(tmpDir, overlongDir), { recursive: true }); + for (let i = 0; i < 1001; i++) { + await fsp.writeFile(path.join(tmpDir, overlongDir, `ignored-${i}.bin`), 'ignored'); + } + const internals = asInternals(makeJob()); + internals.submissionDir = tmpDir; + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.artifactTruncation).toEqual({ + code: 'artifact_truncated', + reasons: { path: 1 }, + skipped: [overlongDir], + skipped_count: 1, + }); + }); + it('does not report an unchanged oversized inline entrypoint', async () => { const name = 'main.py'; const content = 'print(1)'; @@ -1044,6 +1083,47 @@ describe('walkDir / artifact truncation details', () => { skipped_count: 1, }); }); + + it('shares the output-cap probe budget across sibling subtrees', async () => { + for (const dirname of ['b-ignored', 'c-ignored']) { + await fsp.mkdir(path.join(tmpDir, dirname)); + for (let i = 0; i < 600; i++) { + await fsp.writeFile(path.join(tmpDir, dirname, `ignored-${i}.bin`), 'ignored'); + } + } + const internals = asInternals(makeJob()); + internals.submissionDir = tmpDir; + internals.generatedFiles = Array.from({ length: config.max_output_files }, (_, i) => ({ + id: `id-${i}`, + name: `file-${i}.txt`, + path: path.join(tmpDir, `file-${i}.txt`), + })); + + await internals.walkDir(path.join(tmpDir, 'b-ignored'), 1, new Map()); + await internals.walkDir(path.join(tmpDir, 'c-ignored'), 1, new Map()); + + expect(internals.artifactTruncation?.reasons).toEqual({ max_files: 1 }); + expect(internals.artifactTruncation?.skipped).toEqual(['c-ignored']); + }); + + it('does not report surfaced session artifacts during output-cap probing', async () => { + const name = 'old-output.txt'; + const content = 'already returned'; + await fsp.writeFile(path.join(tmpDir, name), content); + const session = new SessionWorkspace({ runtimeSessionId: 'rt_capped' }); + session.markSurfaced(name, sha256(content)); + const internals = asInternals(makeJob({ session })); + internals.submissionDir = tmpDir; + internals.generatedFiles = Array.from({ length: config.max_output_files }, (_, i) => ({ + id: `id-${i}`, + name: `file-${i}.txt`, + path: path.join(tmpDir, `file-${i}.txt`), + })); + + await internals.walkDir(tmpDir, 0, new Map()); + + expect(internals.artifactTruncation).toBeUndefined(); + }); }); describe('handleSessionFiles / priority-fill composition', () => { From c842020561e6371af9a3294433f9b3f80a186f91 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 14 Sep 2026 12:13:48 -0400 Subject: [PATCH 7/7] fix: stop exhausted artifact probes --- api/src/job.ts | 25 +++++++++++++++++++++++- api/src/walker.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/api/src/job.ts b/api/src/job.ts index e8b96eb5..27604d29 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -2210,6 +2210,9 @@ export class Job { rootPath = path.relative(this.submissionDir, dir) || '.', respectSessionSuppression = false, ): Promise { + /* The state is shared by every probe in this job. Once exhausted, return + * conservatively before opening yet another capped sibling directory. */ + if (state.remainingEntries <= 0) return rootPath; let directory: fs.Dir; try { directory = await fsp.opendir(dir); @@ -2241,13 +2244,33 @@ export class Job { if (kind === 'file') { sawVisibleNonHiddenEntry = true; if (entry.name !== DIRKEEP && !isSupportedOutputFilename(entry.name)) continue; + const existingFile = inputByName.get(relativePath); + const inputFileInfo = this.inputFileHashes.get(relativePath); + if ( + respectSessionSuppression + && relativePath === this.entryPointName + && existingFile?.id == null + && inputFileInfo + ) { + try { + const st = await fsp.lstat(fullPath); + if (!st.isFile()) continue; + if (st.size > state.remainingHashBytes) return rootPath; + state.remainingHashBytes -= st.size; + if (await this.computeFileHash(fullPath, true) === inputFileInfo.hash) continue; + } catch (err) { + this.log.debug({ path: relativePath, err }, 'walkDir: failed during entrypoint cap probe'); + this.recordArtifactTruncation('unreadable', relativePath); + continue; + } + } /* Once generated outputs fill the response cap, a persistent * workspace may still contain unchanged artifacts from earlier * turns. Ordinary walking suppresses those via their content hash, * so the bounded cap probe must do the same or it reports a false * max_files warning. Current-request inputs remain reportable: they * would otherwise have been echoed into this response. */ - if (respectSessionSuppression && this.session && !inputByName.has(relativePath)) { + if (respectSessionSuppression && this.session && !existingFile) { if (this.session.isPrimedReadOnly(relativePath)) continue; try { const st = await fsp.lstat(fullPath); diff --git a/api/src/walker.test.ts b/api/src/walker.test.ts index a468c664..543505a9 100644 --- a/api/src/walker.test.ts +++ b/api/src/walker.test.ts @@ -32,6 +32,7 @@ interface WalkerInternals { skipped: string[]; skipped_count: number; }; + truncationProbeState: { remainingEntries: number; remainingHashBytes: number }; pendingSurfaced: Map; inputFileHashes: Map; files: TFile[]; @@ -1124,6 +1125,49 @@ describe('walkDir / artifact truncation details', () => { expect(internals.artifactTruncation).toBeUndefined(); }); + + it('does not reopen capped directories after the shared probe budget is exhausted', async () => { + const internals = asInternals(makeJob()); + internals.submissionDir = tmpDir; + internals.truncationProbeState.remainingEntries = 0; + internals.generatedFiles = Array.from({ length: config.max_output_files }, (_, i) => ({ + id: `id-${i}`, + name: `file-${i}.txt`, + path: path.join(tmpDir, `file-${i}.txt`), + })); + const absentDir = path.join(tmpDir, 'not-opened'); + + await internals.walkDir(absentDir, 1, new Map()); + + expect(internals.artifactTruncation).toEqual({ + code: 'artifact_truncated', + reasons: { max_files: 1 }, + skipped: ['not-opened'], + skipped_count: 1, + }); + }); + + it('does not report an unchanged inline entrypoint during output-cap probing', async () => { + const directory = path.join(tmpDir, 'src'); + const name = path.join('src', 'main.py'); + const content = 'print(1)'; + await fsp.mkdir(directory); + await fsp.writeFile(path.join(tmpDir, name), content); + const inline: TFile = { name, content }; + const internals = asInternals(makeJob({ files: [inline] })); + internals.submissionDir = tmpDir; + internals.entryPointName = name; + internals.inputFileHashes.set(name, { hash: sha256(content), path: path.join(tmpDir, name) }); + internals.generatedFiles = Array.from({ length: config.max_output_files }, (_, i) => ({ + id: `id-${i}`, + name: `file-${i}.txt`, + path: path.join(tmpDir, `file-${i}.txt`), + })); + + await internals.walkDir(directory, 1, buildInputByName([inline])); + + expect(internals.artifactTruncation).toBeUndefined(); + }); }); describe('handleSessionFiles / priority-fill composition', () => {