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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
57 changes: 51 additions & 6 deletions api/src/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -714,6 +716,8 @@ export class Job {
private pendingSurfaced = new Map<string, { name: string; signature: string }>();
private sessionFiles: FileRef[] = [];
private inheritedRefs: FileRef[] = [];
private presentInputFiles = new Set<string>();
private deletedFiles: string[] = [];
private artifactTruncation: ArtifactTruncation | undefined;
private truncationProbeState: TruncationProbeState = {
remainingEntries: TRUNCATION_PROBE_MAX_ENTRIES,
Expand Down Expand Up @@ -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 } : {}),
};
}
Expand All @@ -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,
Expand All @@ -1720,6 +1729,26 @@ 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) {
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.inputFileHashes.get(file.name)?.readOnly !== true &&
!this.presentInputFiles.has(file.name) &&
!returnedNames.has(file.name)
) {
this.deletedFiles.push(file.name);
Comment thread
danny-avila marked this conversation as resolved.
Comment thread
danny-avila marked this conversation as resolved.
Comment thread
danny-avila marked this conversation as resolved.
this.session?.forgetPrimed(file.name);
}
}
}

/* Generated files get priority in sessionFiles; fill remaining slots up
Expand Down Expand Up @@ -2227,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
Expand All @@ -2243,6 +2277,9 @@ export class Job {
}
if (kind === 'file') {
sawVisibleNonHiddenEntry = true;
if (inputByName.has(relativePath)) {
this.presentInputFiles.add(relativePath);
Comment thread
danny-avila marked this conversation as resolved.
}
if (entry.name !== DIRKEEP && !isSupportedOutputFilename(entry.name)) continue;
const existingFile = inputByName.get(relativePath);
const inputFileInfo = this.inputFileHashes.get(relativePath);
Expand Down Expand Up @@ -2391,13 +2428,21 @@ 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);
if (kind === 'skip') continue;

if (kind === 'file' && inputByName.has(relativePath)) {
this.presentInputFiles.add(relativePath);
Comment thread
danny-avila marked this conversation as resolved.
}

/* 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 (kind === 'file' && 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
Expand Down
5 changes: 5 additions & 0 deletions api/src/session-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
6 changes: 6 additions & 0 deletions api/src/session-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
196 changes: 196 additions & 0 deletions api/src/walker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
deletedFiles: string[];
artifactTruncation?: {
code: 'artifact_truncated';
reasons: Partial<Record<'max_files' | 'depth' | 'size' | 'path' | 'unreadable', number>>;
Expand All @@ -39,6 +41,14 @@ interface WalkerInternals {
reusePrimedInput: (file: TFile) => Promise<boolean>;
writeFile: (file: TFile) => Promise<void>;
computeFileHash: (filePath: string, noFollow?: boolean) => Promise<string>;
findTruncatedArtifact: (
dir: string,
inputByName: Map<string, TFile>,
state?: { remainingEntries: number; remainingHashBytes: number },
probeDepth?: number,
rootPath?: string,
respectSessionSuppression?: boolean,
) => Promise<string | undefined>;
walkDir: (dir: string, depth: number, inputByName: Map<string, TFile>) => Promise<'collected' | 'empty' | 'skipped'>;
handleSessionFiles: () => Promise<void>;
}
Expand Down Expand Up @@ -1257,6 +1267,192 @@ 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('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('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',
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([]);
});

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', () => {
it('ignores symlinks (never classifies them as file or dir)', async () => {
await fsp.writeFile(path.join(tmpDir, 'real.py'), 'print(1)');
Expand Down
Loading