diff --git a/packages/adapters/src/index.test.ts b/packages/adapters/src/index.test.ts index c706b46..2b87e50 100644 --- a/packages/adapters/src/index.test.ts +++ b/packages/adapters/src/index.test.ts @@ -267,6 +267,66 @@ process.on("beforeExit", () => { assert.deepEqual(events.map((event) => event.type), ["started", "artifact", "completed"]); }); +test("DevAgentAdapter forwards continuation requests and parses returned session metadata", async () => { + const { root, artifactDir, workspacePath } = await createWorkspace(); + const stubPath = join(root, "devagent-continuation-stub.js"); + await createStub(stubPath, `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const args = process.argv.slice(2); +const requestPath = args[args.indexOf("--request") + 1]; +const artifactDir = args[args.indexOf("--artifact-dir") + 1]; +const request = JSON.parse(fs.readFileSync(requestPath, "utf8")); +if (!request.continuation || request.continuation.mode !== "resume") { + process.stderr.write("missing continuation"); + process.exit(1); +} +const artifactPath = path.join(artifactDir, "triage-report.md"); +const resultPath = path.join(artifactDir, "result.json"); +fs.writeFileSync(artifactPath, "# Triage\\n\\nResumed session\\n"); +fs.writeFileSync(resultPath, JSON.stringify({ + protocolVersion: "0.1", + taskId: request.taskId, + status: "success", + session: { + kind: "devagent-headless-v1", + payload: { + version: 1, + messages: [{ role: "assistant", content: "Resumed session" }] + } + }, + outcome: "completed", + artifacts: [{ kind: "triage-report", path: artifactPath, createdAt: new Date().toISOString() }], + metrics: { startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), durationMs: 1 } +}, null, 2)); +`); + + const request = createRequest("devagent"); + request.continuation = { + mode: "resume", + reason: "retry_no_progress", + instructions: "Continue the previous session.", + session: { + kind: "devagent-headless-v1", + payload: { + version: 1, + messages: [], + }, + }, + }; + + const { result } = await collectEvents( + new DevAgentAdapter(`${process.execPath} ${stubPath}`), + request, + workspacePath, + artifactDir, + ); + + assert.equal(result.status, "success"); + assert.equal(result.session?.kind, "devagent-headless-v1"); + assert.equal(result.outcome, "completed"); +}); + test("DevAgentAdapter reports cancelled runs as cancelled", async () => { const { root, artifactDir, workspacePath } = await createWorkspace(); const stubPath = join(root, "devagent-cancel-stub.js"); diff --git a/packages/local-runner/src/index.test.ts b/packages/local-runner/src/index.test.ts index 8af0dbc..92e0c8d 100644 --- a/packages/local-runner/src/index.test.ts +++ b/packages/local-runner/src/index.test.ts @@ -110,6 +110,33 @@ function createRequest(sourceRepoPath: string, taskId = "task-1"): TaskExecution }; } +function createMultiRepoRequest( + primarySourceRepoPath: string, + secondarySourceRepoPath: string, + taskId = "task-multi", +): TaskExecutionRequest { + const request = createRequest(primarySourceRepoPath, taskId); + request.repositories.push({ + id: "repo-2", + workspaceId: request.workspaceRef.id, + alias: "secondary", + name: "secondary", + repoRoot: secondarySourceRepoPath, + repoFullName: "example/secondary", + defaultBranch: "main", + provider: "github", + }); + request.execution.repositories.push({ + repositoryId: "repo-2", + alias: "secondary", + sourceRepoPath: secondarySourceRepoPath, + workBranch: "devagent/workflow/shared-branch", + isolation: "temp-copy", + readOnly: true, + }); + return request; +} + class StaticHandle implements RunHandle { constructor( readonly id: string, @@ -260,6 +287,70 @@ class RunnerFinalizedAdapter implements ExecutorAdapter { } } +class RepositoryMutatingAdapter implements ExecutorAdapter { + constructor( + private readonly id: string, + private readonly repositoryAliasToMutate: string, + ) {} + + executorId(): string { + return this.id; + } + + canHandle(): boolean { + return true; + } + + handlesFinalEvents(): boolean { + return false; + } + + async launch( + request: TaskExecutionRequest, + _workspacePath: string, + repositoryPaths: Record, + artifactDir: string, + onEvent: (event: TaskExecutionEvent) => void, + ): Promise { + onEvent({ + protocolVersion: PROTOCOL_VERSION, + type: "started", + at: new Date().toISOString(), + taskId: request.taskId, + }); + + const targetRepository = request.execution.repositories.find((repository) => repository.alias === this.repositoryAliasToMutate); + if (!targetRepository) { + throw new Error(`Missing repository alias ${this.repositoryAliasToMutate}`); + } + const repositoryPath = repositoryPaths[targetRepository.repositoryId]; + if (!repositoryPath) { + throw new Error(`Missing workspace path for ${targetRepository.repositoryId}`); + } + await writeFile(join(repositoryPath, "README.md"), `mutated ${this.repositoryAliasToMutate}\n`); + + const artifactPath = join(artifactDir, "triage-report.md"); + await writeFile(artifactPath, "# Triage\n"); + const artifact: ArtifactRef = { + kind: "triage-report", + path: artifactPath, + createdAt: new Date().toISOString(), + }; + + return new StaticHandle(`run-${this.id}-${this.repositoryAliasToMutate}`, Promise.resolve({ + protocolVersion: PROTOCOL_VERSION, + taskId: request.taskId, + status: "success", + artifacts: [artifact], + metrics: { + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + durationMs: 1, + }, + })); + } +} + class SleepHandle implements RunHandle { private readonly done = new EventEmitter(); private resolved = false; @@ -633,7 +724,7 @@ test("local runner finalizes artifact and completed events for structured adapte test("local runner fails non-devagent executors that modify read-only workspaces", async () => { const repo = await createRepo(); const runner = new LocalRunner({ - adapters: [new RunnerFinalizedAdapter(true)], + adapters: [new RepositoryMutatingAdapter("codex", "primary")], }); const request = createRequest(repo, "task-readonly-violation"); request.executor.executorId = "codex"; @@ -645,3 +736,49 @@ test("local runner fails non-devagent executors that modify read-only workspaces assert.equal(result.status, "failed"); assert.equal(result.error?.message, "Executor codex modified a read-only workspace."); }); + +test("local runner fails devagent executors that modify read-only workspaces", async () => { + const repo = await createRepo(); + const runner = new LocalRunner({ + adapters: [new RepositoryMutatingAdapter("devagent", "primary")], + }); + const request = createRequest(repo, "task-readonly-violation-devagent"); + request.execution.repositories[0]!.readOnly = true; + + const { runId } = await runner.startTask(request); + const result = await runner.awaitResult(runId); + + assert.equal(result.status, "failed"); + assert.equal(result.error?.message, "Executor devagent modified a read-only workspace."); +}); + +test("local runner allows writable repo changes while still protecting readonly repos", async () => { + const primaryRepo = await createRepo(); + const secondaryRepo = await createRepo(); + const runner = new LocalRunner({ + adapters: [new RepositoryMutatingAdapter("devagent", "primary")], + }); + const request = createMultiRepoRequest(primaryRepo, secondaryRepo, "task-readonly-mixed-primary"); + request.execution.repositories[0]!.readOnly = false; + + const { runId } = await runner.startTask(request); + const result = await runner.awaitResult(runId); + + assert.equal(result.status, "success"); +}); + +test("local runner fails when a readonly secondary repo is modified", async () => { + const primaryRepo = await createRepo(); + const secondaryRepo = await createRepo(); + const runner = new LocalRunner({ + adapters: [new RepositoryMutatingAdapter("devagent", "secondary")], + }); + const request = createMultiRepoRequest(primaryRepo, secondaryRepo, "task-readonly-mixed-secondary"); + request.execution.repositories[0]!.readOnly = false; + + const { runId } = await runner.startTask(request); + const result = await runner.awaitResult(runId); + + assert.equal(result.status, "failed"); + assert.equal(result.error?.message, "Executor devagent modified a read-only workspace."); +}); diff --git a/packages/local-runner/src/index.ts b/packages/local-runner/src/index.ts index 1a21b92..3ca4387 100644 --- a/packages/local-runner/src/index.ts +++ b/packages/local-runner/src/index.ts @@ -265,6 +265,24 @@ async function readLinkSafe(path: string): Promise { } } +async function fingerprintReadonlyRepositories( + request: TaskExecutionRequest, + repositoryPaths: Record, +): Promise> { + const fingerprints = new Map(); + for (const repository of request.execution.repositories) { + if (!repository.readOnly) { + continue; + } + const repositoryPath = repositoryPaths[repository.repositoryId]; + if (!repositoryPath) { + continue; + } + fingerprints.set(repository.repositoryId, await fingerprintWorkspace(repositoryPath)); + } + return fingerprints; +} + function enforceReadOnlyResult( request: TaskExecutionRequest, result: TaskExecutionResult, @@ -370,10 +388,8 @@ export class LocalRunner implements RunnerClient { throw new RunnerError("WORKSPACE_PREPARE_FAILED", error instanceof Error ? error.message : String(error)); } const runnerFinalizesEvents = !(adapter.handlesFinalEvents?.() ?? true); - const initialWorkspaceFingerprint = - request.execution.repositories.some((repository) => repository.readOnly) && - request.executor.executorId !== "devagent" - ? await fingerprintWorkspace(workspacePath) + const initialReadonlyFingerprints = request.execution.repositories.some((repository) => repository.readOnly) + ? await fingerprintReadonlyRepositories(request, repositoryPaths) : null; const onEvent = (event: TaskExecutionEvent): void => { @@ -461,9 +477,12 @@ export class LocalRunner implements RunnerClient { handle.id, Promise.race([resultPromise, ...(timedPromise ? [timedPromise] : [])]).then(async (result: TaskExecutionResult) => { let finalResult = result; - if (initialWorkspaceFingerprint) { - const finalWorkspaceFingerprint = await fingerprintWorkspace(workspacePath); - if (finalWorkspaceFingerprint !== initialWorkspaceFingerprint) { + if (initialReadonlyFingerprints) { + const finalReadonlyFingerprints = await fingerprintReadonlyRepositories(request, repositoryPaths); + const readOnlyWorkspaceChanged = [...initialReadonlyFingerprints.entries()].some(([repositoryId, fingerprint]) => + finalReadonlyFingerprints.get(repositoryId) !== fingerprint + ); + if (readOnlyWorkspaceChanged) { finalResult = enforceReadOnlyResult(request, finalResult); } }