Skip to content

Commit 23848ee

Browse files
committed
fix: derive filepath worker patches from task-start state
1 parent 9fd318c commit 23848ee

1 file changed

Lines changed: 158 additions & 23 deletions

File tree

src/lib/runtime/agent-runtime.ts

Lines changed: 158 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,44 +1021,153 @@ function buildDiffSummary(filesTouched: readonly string[]): string | null {
10211021
: `${filesTouched.length} files touched`;
10221022
}
10231023

1024-
async function collectWorkspacePatch(
1024+
function quoteShell(value: string): string {
1025+
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
1026+
}
1027+
1028+
function normalizePatchPathPrefix(path: string): string {
1029+
return path.replace(/^\/+/, "").replace(/\/+$/, "");
1030+
}
1031+
1032+
function rewriteSnapshotPatchPaths(
1033+
patch: string,
1034+
beforeRoot: string,
1035+
afterRoot: string,
1036+
): string {
1037+
const beforePrefix = normalizePatchPathPrefix(beforeRoot);
1038+
const afterPrefix = normalizePatchPathPrefix(afterRoot);
1039+
1040+
return [
1041+
[`a/${beforePrefix}/`, "a/"],
1042+
[`b/${beforePrefix}/`, "b/"],
1043+
[`a/${afterPrefix}/`, "a/"],
1044+
[`b/${afterPrefix}/`, "b/"],
1045+
].reduce(
1046+
(nextPatch, [from, to]) => nextPatch.split(from).join(to),
1047+
patch,
1048+
);
1049+
}
1050+
1051+
async function writeScopedSnapshot(
10251052
sandbox: Sandbox,
10261053
workspaceRoot: string,
1027-
): Promise<string | null> {
1028-
const insideGit = await sandbox.exec("git rev-parse --is-inside-work-tree", {
1054+
snapshotRoot: string,
1055+
allowedPaths: readonly string[],
1056+
forbiddenPaths: readonly string[],
1057+
): Promise<void> {
1058+
const command = [
1059+
"node <<'NODE'",
1060+
"const fs = require('fs');",
1061+
"const path = require('path');",
1062+
`const workspaceRoot = ${JSON.stringify(workspaceRoot)};`,
1063+
`const snapshotRoot = ${JSON.stringify(snapshotRoot)};`,
1064+
`const allowedPaths = ${JSON.stringify([...allowedPaths])};`,
1065+
`const forbiddenPaths = ${JSON.stringify([...forbiddenPaths])};`,
1066+
"const normalize = (value) => value.replaceAll('\\\\', '/').replace(/^\\.\\//, '').replace(/\\/+$/, '');",
1067+
"const matchesPrefix = (value, prefixes) => {",
1068+
" const normalizedValue = normalize(value);",
1069+
" if (prefixes.length === 0) return true;",
1070+
" return prefixes.some((prefix) => {",
1071+
" const normalizedPrefix = normalize(prefix);",
1072+
" if (!normalizedPrefix || normalizedPrefix === '.') return true;",
1073+
" return normalizedValue === normalizedPrefix || normalizedValue.startsWith(`${normalizedPrefix}/`);",
1074+
" });",
1075+
"};",
1076+
"const shouldCopy = (relativePath) => {",
1077+
" const normalizedPath = normalize(relativePath);",
1078+
" if (!matchesPrefix(normalizedPath, allowedPaths)) return false;",
1079+
" return !forbiddenPaths.some((prefix) => matchesPrefix(normalizedPath, [prefix]));",
1080+
"};",
1081+
"const ensureInsideWorkspace = (candidate) => {",
1082+
" const relativePath = normalize(path.relative(workspaceRoot, candidate));",
1083+
" if (relativePath === '' || (!relativePath.startsWith('../') && relativePath !== '..')) return;",
1084+
" throw new Error(`Scoped snapshot path escapes workspace: ${candidate}`);",
1085+
"};",
1086+
"const copyRecursive = (source, destination, relativePath) => {",
1087+
" ensureInsideWorkspace(source);",
1088+
" if (!shouldCopy(relativePath)) return;",
1089+
" const stat = fs.lstatSync(source);",
1090+
" if (stat.isDirectory()) {",
1091+
" fs.mkdirSync(destination, { recursive: true });",
1092+
" for (const entry of fs.readdirSync(source)) {",
1093+
" const childSource = path.join(source, entry);",
1094+
" const childDestination = path.join(destination, entry);",
1095+
" const childRelativePath = relativePath ? `${relativePath}/${entry}` : entry;",
1096+
" copyRecursive(childSource, childDestination, childRelativePath);",
1097+
" }",
1098+
" return;",
1099+
" }",
1100+
" fs.mkdirSync(path.dirname(destination), { recursive: true });",
1101+
" if (stat.isSymbolicLink()) {",
1102+
" const linkTarget = fs.readlinkSync(source);",
1103+
" try { fs.unlinkSync(destination); } catch {}",
1104+
" fs.symlinkSync(linkTarget, destination);",
1105+
" return;",
1106+
" }",
1107+
" fs.copyFileSync(source, destination);",
1108+
"};",
1109+
"fs.rmSync(snapshotRoot, { recursive: true, force: true });",
1110+
"fs.mkdirSync(snapshotRoot, { recursive: true });",
1111+
"const uniqueAllowedPaths = [...new Set(allowedPaths.map((value) => {",
1112+
" const normalized = normalize(value);",
1113+
" return normalized || '.';",
1114+
"}))];",
1115+
"for (const allowedPath of uniqueAllowedPaths) {",
1116+
" const relativePath = allowedPath === '.' ? '' : allowedPath;",
1117+
" const source = relativePath ? path.resolve(workspaceRoot, relativePath) : workspaceRoot;",
1118+
" ensureInsideWorkspace(source);",
1119+
" if (!fs.existsSync(source)) continue;",
1120+
" if (!relativePath) {",
1121+
" for (const entry of fs.readdirSync(source)) {",
1122+
" copyRecursive(path.join(source, entry), path.join(snapshotRoot, entry), entry);",
1123+
" }",
1124+
" continue;",
1125+
" }",
1126+
" copyRecursive(source, path.resolve(snapshotRoot, relativePath), relativePath);",
1127+
"}",
1128+
"NODE",
1129+
].join("\n");
1130+
1131+
const result = await sandbox.exec(command, {
10291132
cwd: workspaceRoot,
10301133
}).catch(() => null);
1031-
const insideGitExitCode = (insideGit as { code?: number; exitCode?: number } | null)?.code
1032-
?? (insideGit as { code?: number; exitCode?: number } | null)?.exitCode
1134+
const exitCode = (result as { code?: number; exitCode?: number } | null)?.code
1135+
?? (result as { code?: number; exitCode?: number } | null)?.exitCode
10331136
?? null;
10341137

1035-
if (!insideGit || insideGitExitCode !== 0 || insideGit.stdout.trim() !== "true") {
1036-
return null;
1138+
if (!result || exitCode !== 0) {
1139+
const stderr = (result as { stderr?: string } | null)?.stderr?.trim() || "unknown error";
1140+
throw new RuntimeTaskError(
1141+
"PATCH_SNAPSHOT_FAILED",
1142+
`Could not snapshot scoped workspace state: ${stderr}`,
1143+
);
10371144
}
1145+
}
10381146

1147+
async function collectScopedWorkspacePatch(
1148+
sandbox: Sandbox,
1149+
workspaceRoot: string,
1150+
beforeRoot: string,
1151+
afterRoot: string,
1152+
): Promise<string | null> {
10391153
const result = await sandbox.exec(
1040-
[
1041-
"tracked=$(git diff --binary --relative HEAD -- 2>/dev/null || true)",
1042-
"untracked=$(git ls-files --others --exclude-standard | while IFS= read -r file; do",
1043-
" [ -n \"$file\" ] || continue",
1044-
" git diff --binary --no-index -- /dev/null \"$file\" 2>/dev/null || true",
1045-
"done)",
1046-
"printf '%s%s' \"$tracked\" \"$untracked\"",
1047-
].join("\n"),
1154+
`git diff --binary --no-index ${quoteShell(beforeRoot)} ${quoteShell(afterRoot)} || true`,
10481155
{
10491156
cwd: workspaceRoot,
10501157
},
10511158
).catch(() => null);
1052-
const resultExitCode = (result as { code?: number; exitCode?: number } | null)?.code
1053-
?? (result as { code?: number; exitCode?: number } | null)?.exitCode
1054-
?? null;
10551159

1056-
if (!result || resultExitCode !== 0) {
1160+
if (!result) {
10571161
return null;
10581162
}
10591163

1060-
const patch = result.stdout.trim();
1061-
return patch.length > 0 ? `${patch}\n` : null;
1164+
const patch = ((result as { stdout?: string }).stdout ?? "").trim();
1165+
if (!patch) {
1166+
return null;
1167+
}
1168+
1169+
const rewritten = rewriteSnapshotPatchPaths(patch, beforeRoot, afterRoot).trim();
1170+
return rewritten.length > 0 ? `${rewritten}\n` : null;
10621171
}
10631172

10641173
async function persistAssistantText(
@@ -1844,8 +1953,12 @@ async function runSandboxTask(
18441953
): Promise<AgentTaskCompletion> {
18451954
const processId = `task-${taskId}`;
18461955
const startedAt = now();
1956+
const workspaceRoot = resolveWorkspaceRoot(config.gitRepoUrl);
1957+
const snapshotBase = `/tmp/filepath-task-snapshots/${taskId}`;
1958+
const beforeSnapshotRoot = `${snapshotBase}/before`;
1959+
const afterSnapshotRoot = `${snapshotBase}/after`;
18471960
let process: Process | null = null;
1848-
let sandbox: Sandbox;
1961+
let sandbox: Sandbox | null = null;
18491962

18501963
try {
18511964
sandbox = await withSandboxStartupRetry(env, taskId, agentId, {
@@ -1858,6 +1971,13 @@ async function runSandboxTask(
18581971
await nextSandbox.mkdir(config.executionRoot, { recursive: true });
18591972
return nextSandbox;
18601973
});
1974+
await writeScopedSnapshot(
1975+
sandbox,
1976+
workspaceRoot,
1977+
beforeSnapshotRoot,
1978+
config.policy.allowedPaths,
1979+
config.policy.forbiddenPaths,
1980+
);
18611981
} catch (error) {
18621982
const runtimeError = classifySandboxStartupError(error);
18631983
const failed = await finalizeTaskFailure(env, {
@@ -1970,7 +2090,19 @@ async function runSandboxTask(
19702090
startedAt,
19712091
finishedAt,
19722092
);
1973-
result.patch = await collectWorkspacePatch(sandbox, resolveWorkspaceRoot(config.gitRepoUrl));
2093+
await writeScopedSnapshot(
2094+
sandbox,
2095+
workspaceRoot,
2096+
afterSnapshotRoot,
2097+
config.policy.allowedPaths,
2098+
config.policy.forbiddenPaths,
2099+
);
2100+
result.patch = await collectScopedWorkspacePatch(
2101+
sandbox,
2102+
workspaceRoot,
2103+
beforeSnapshotRoot,
2104+
afterSnapshotRoot,
2105+
);
19742106
if (!result.diffSummary) {
19752107
result.diffSummary = buildDiffSummary(result.filesTouched);
19762108
}
@@ -2063,6 +2195,9 @@ async function runSandboxTask(
20632195
if (heartbeatTimer) {
20642196
clearInterval(heartbeatTimer);
20652197
}
2198+
await sandbox?.exec(`rm -rf ${quoteShell(snapshotBase)}`, {
2199+
cwd: workspaceRoot,
2200+
}).catch(() => {});
20662201
await updateAgentExecutionState(env, agentId, {
20672202
activeProcessId: null,
20682203
cancelRequested: false,

0 commit comments

Comments
 (0)