Skip to content

Commit 7b2a783

Browse files
authored
Enforce readonly repository boundaries (#2)
1 parent 271e1cf commit 7b2a783

3 files changed

Lines changed: 224 additions & 8 deletions

File tree

packages/adapters/src/index.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,66 @@ process.on("beforeExit", () => {
267267
assert.deepEqual(events.map((event) => event.type), ["started", "artifact", "completed"]);
268268
});
269269

270+
test("DevAgentAdapter forwards continuation requests and parses returned session metadata", async () => {
271+
const { root, artifactDir, workspacePath } = await createWorkspace();
272+
const stubPath = join(root, "devagent-continuation-stub.js");
273+
await createStub(stubPath, `#!/usr/bin/env node
274+
const fs = require("fs");
275+
const path = require("path");
276+
const args = process.argv.slice(2);
277+
const requestPath = args[args.indexOf("--request") + 1];
278+
const artifactDir = args[args.indexOf("--artifact-dir") + 1];
279+
const request = JSON.parse(fs.readFileSync(requestPath, "utf8"));
280+
if (!request.continuation || request.continuation.mode !== "resume") {
281+
process.stderr.write("missing continuation");
282+
process.exit(1);
283+
}
284+
const artifactPath = path.join(artifactDir, "triage-report.md");
285+
const resultPath = path.join(artifactDir, "result.json");
286+
fs.writeFileSync(artifactPath, "# Triage\\n\\nResumed session\\n");
287+
fs.writeFileSync(resultPath, JSON.stringify({
288+
protocolVersion: "0.1",
289+
taskId: request.taskId,
290+
status: "success",
291+
session: {
292+
kind: "devagent-headless-v1",
293+
payload: {
294+
version: 1,
295+
messages: [{ role: "assistant", content: "Resumed session" }]
296+
}
297+
},
298+
outcome: "completed",
299+
artifacts: [{ kind: "triage-report", path: artifactPath, createdAt: new Date().toISOString() }],
300+
metrics: { startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), durationMs: 1 }
301+
}, null, 2));
302+
`);
303+
304+
const request = createRequest("devagent");
305+
request.continuation = {
306+
mode: "resume",
307+
reason: "retry_no_progress",
308+
instructions: "Continue the previous session.",
309+
session: {
310+
kind: "devagent-headless-v1",
311+
payload: {
312+
version: 1,
313+
messages: [],
314+
},
315+
},
316+
};
317+
318+
const { result } = await collectEvents(
319+
new DevAgentAdapter(`${process.execPath} ${stubPath}`),
320+
request,
321+
workspacePath,
322+
artifactDir,
323+
);
324+
325+
assert.equal(result.status, "success");
326+
assert.equal(result.session?.kind, "devagent-headless-v1");
327+
assert.equal(result.outcome, "completed");
328+
});
329+
270330
test("DevAgentAdapter reports cancelled runs as cancelled", async () => {
271331
const { root, artifactDir, workspacePath } = await createWorkspace();
272332
const stubPath = join(root, "devagent-cancel-stub.js");

packages/local-runner/src/index.test.ts

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,33 @@ function createRequest(sourceRepoPath: string, taskId = "task-1"): TaskExecution
110110
};
111111
}
112112

113+
function createMultiRepoRequest(
114+
primarySourceRepoPath: string,
115+
secondarySourceRepoPath: string,
116+
taskId = "task-multi",
117+
): TaskExecutionRequest {
118+
const request = createRequest(primarySourceRepoPath, taskId);
119+
request.repositories.push({
120+
id: "repo-2",
121+
workspaceId: request.workspaceRef.id,
122+
alias: "secondary",
123+
name: "secondary",
124+
repoRoot: secondarySourceRepoPath,
125+
repoFullName: "example/secondary",
126+
defaultBranch: "main",
127+
provider: "github",
128+
});
129+
request.execution.repositories.push({
130+
repositoryId: "repo-2",
131+
alias: "secondary",
132+
sourceRepoPath: secondarySourceRepoPath,
133+
workBranch: "devagent/workflow/shared-branch",
134+
isolation: "temp-copy",
135+
readOnly: true,
136+
});
137+
return request;
138+
}
139+
113140
class StaticHandle implements RunHandle {
114141
constructor(
115142
readonly id: string,
@@ -260,6 +287,70 @@ class RunnerFinalizedAdapter implements ExecutorAdapter {
260287
}
261288
}
262289

290+
class RepositoryMutatingAdapter implements ExecutorAdapter {
291+
constructor(
292+
private readonly id: string,
293+
private readonly repositoryAliasToMutate: string,
294+
) {}
295+
296+
executorId(): string {
297+
return this.id;
298+
}
299+
300+
canHandle(): boolean {
301+
return true;
302+
}
303+
304+
handlesFinalEvents(): boolean {
305+
return false;
306+
}
307+
308+
async launch(
309+
request: TaskExecutionRequest,
310+
_workspacePath: string,
311+
repositoryPaths: Record<string, string>,
312+
artifactDir: string,
313+
onEvent: (event: TaskExecutionEvent) => void,
314+
): Promise<RunHandle> {
315+
onEvent({
316+
protocolVersion: PROTOCOL_VERSION,
317+
type: "started",
318+
at: new Date().toISOString(),
319+
taskId: request.taskId,
320+
});
321+
322+
const targetRepository = request.execution.repositories.find((repository) => repository.alias === this.repositoryAliasToMutate);
323+
if (!targetRepository) {
324+
throw new Error(`Missing repository alias ${this.repositoryAliasToMutate}`);
325+
}
326+
const repositoryPath = repositoryPaths[targetRepository.repositoryId];
327+
if (!repositoryPath) {
328+
throw new Error(`Missing workspace path for ${targetRepository.repositoryId}`);
329+
}
330+
await writeFile(join(repositoryPath, "README.md"), `mutated ${this.repositoryAliasToMutate}\n`);
331+
332+
const artifactPath = join(artifactDir, "triage-report.md");
333+
await writeFile(artifactPath, "# Triage\n");
334+
const artifact: ArtifactRef = {
335+
kind: "triage-report",
336+
path: artifactPath,
337+
createdAt: new Date().toISOString(),
338+
};
339+
340+
return new StaticHandle(`run-${this.id}-${this.repositoryAliasToMutate}`, Promise.resolve({
341+
protocolVersion: PROTOCOL_VERSION,
342+
taskId: request.taskId,
343+
status: "success",
344+
artifacts: [artifact],
345+
metrics: {
346+
startedAt: new Date().toISOString(),
347+
finishedAt: new Date().toISOString(),
348+
durationMs: 1,
349+
},
350+
}));
351+
}
352+
}
353+
263354
class SleepHandle implements RunHandle {
264355
private readonly done = new EventEmitter();
265356
private resolved = false;
@@ -633,7 +724,7 @@ test("local runner finalizes artifact and completed events for structured adapte
633724
test("local runner fails non-devagent executors that modify read-only workspaces", async () => {
634725
const repo = await createRepo();
635726
const runner = new LocalRunner({
636-
adapters: [new RunnerFinalizedAdapter(true)],
727+
adapters: [new RepositoryMutatingAdapter("codex", "primary")],
637728
});
638729
const request = createRequest(repo, "task-readonly-violation");
639730
request.executor.executorId = "codex";
@@ -645,3 +736,49 @@ test("local runner fails non-devagent executors that modify read-only workspaces
645736
assert.equal(result.status, "failed");
646737
assert.equal(result.error?.message, "Executor codex modified a read-only workspace.");
647738
});
739+
740+
test("local runner fails devagent executors that modify read-only workspaces", async () => {
741+
const repo = await createRepo();
742+
const runner = new LocalRunner({
743+
adapters: [new RepositoryMutatingAdapter("devagent", "primary")],
744+
});
745+
const request = createRequest(repo, "task-readonly-violation-devagent");
746+
request.execution.repositories[0]!.readOnly = true;
747+
748+
const { runId } = await runner.startTask(request);
749+
const result = await runner.awaitResult(runId);
750+
751+
assert.equal(result.status, "failed");
752+
assert.equal(result.error?.message, "Executor devagent modified a read-only workspace.");
753+
});
754+
755+
test("local runner allows writable repo changes while still protecting readonly repos", async () => {
756+
const primaryRepo = await createRepo();
757+
const secondaryRepo = await createRepo();
758+
const runner = new LocalRunner({
759+
adapters: [new RepositoryMutatingAdapter("devagent", "primary")],
760+
});
761+
const request = createMultiRepoRequest(primaryRepo, secondaryRepo, "task-readonly-mixed-primary");
762+
request.execution.repositories[0]!.readOnly = false;
763+
764+
const { runId } = await runner.startTask(request);
765+
const result = await runner.awaitResult(runId);
766+
767+
assert.equal(result.status, "success");
768+
});
769+
770+
test("local runner fails when a readonly secondary repo is modified", async () => {
771+
const primaryRepo = await createRepo();
772+
const secondaryRepo = await createRepo();
773+
const runner = new LocalRunner({
774+
adapters: [new RepositoryMutatingAdapter("devagent", "secondary")],
775+
});
776+
const request = createMultiRepoRequest(primaryRepo, secondaryRepo, "task-readonly-mixed-secondary");
777+
request.execution.repositories[0]!.readOnly = false;
778+
779+
const { runId } = await runner.startTask(request);
780+
const result = await runner.awaitResult(runId);
781+
782+
assert.equal(result.status, "failed");
783+
assert.equal(result.error?.message, "Executor devagent modified a read-only workspace.");
784+
});

packages/local-runner/src/index.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,24 @@ async function readLinkSafe(path: string): Promise<string> {
265265
}
266266
}
267267

268+
async function fingerprintReadonlyRepositories(
269+
request: TaskExecutionRequest,
270+
repositoryPaths: Record<string, string>,
271+
): Promise<Map<string, string>> {
272+
const fingerprints = new Map<string, string>();
273+
for (const repository of request.execution.repositories) {
274+
if (!repository.readOnly) {
275+
continue;
276+
}
277+
const repositoryPath = repositoryPaths[repository.repositoryId];
278+
if (!repositoryPath) {
279+
continue;
280+
}
281+
fingerprints.set(repository.repositoryId, await fingerprintWorkspace(repositoryPath));
282+
}
283+
return fingerprints;
284+
}
285+
268286
function enforceReadOnlyResult(
269287
request: TaskExecutionRequest,
270288
result: TaskExecutionResult,
@@ -370,10 +388,8 @@ export class LocalRunner implements RunnerClient {
370388
throw new RunnerError("WORKSPACE_PREPARE_FAILED", error instanceof Error ? error.message : String(error));
371389
}
372390
const runnerFinalizesEvents = !(adapter.handlesFinalEvents?.() ?? true);
373-
const initialWorkspaceFingerprint =
374-
request.execution.repositories.some((repository) => repository.readOnly) &&
375-
request.executor.executorId !== "devagent"
376-
? await fingerprintWorkspace(workspacePath)
391+
const initialReadonlyFingerprints = request.execution.repositories.some((repository) => repository.readOnly)
392+
? await fingerprintReadonlyRepositories(request, repositoryPaths)
377393
: null;
378394

379395
const onEvent = (event: TaskExecutionEvent): void => {
@@ -461,9 +477,12 @@ export class LocalRunner implements RunnerClient {
461477
handle.id,
462478
Promise.race([resultPromise, ...(timedPromise ? [timedPromise] : [])]).then(async (result: TaskExecutionResult) => {
463479
let finalResult = result;
464-
if (initialWorkspaceFingerprint) {
465-
const finalWorkspaceFingerprint = await fingerprintWorkspace(workspacePath);
466-
if (finalWorkspaceFingerprint !== initialWorkspaceFingerprint) {
480+
if (initialReadonlyFingerprints) {
481+
const finalReadonlyFingerprints = await fingerprintReadonlyRepositories(request, repositoryPaths);
482+
const readOnlyWorkspaceChanged = [...initialReadonlyFingerprints.entries()].some(([repositoryId, fingerprint]) =>
483+
finalReadonlyFingerprints.get(repositoryId) !== fingerprint
484+
);
485+
if (readOnlyWorkspaceChanged) {
467486
finalResult = enforceReadOnlyResult(request, finalResult);
468487
}
469488
}

0 commit comments

Comments
 (0)