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
60 changes: 60 additions & 0 deletions packages/adapters/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
139 changes: 138 additions & 1 deletion packages/local-runner/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string>,
artifactDir: string,
onEvent: (event: TaskExecutionEvent) => void,
): Promise<RunHandle> {
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;
Expand Down Expand Up @@ -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";
Expand All @@ -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.");
});
33 changes: 26 additions & 7 deletions packages/local-runner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,24 @@ async function readLinkSafe(path: string): Promise<string> {
}
}

async function fingerprintReadonlyRepositories(
request: TaskExecutionRequest,
repositoryPaths: Record<string, string>,
): Promise<Map<string, string>> {
const fingerprints = new Map<string, string>();
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));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle deleted read-only repos before hashing

If an executor removes an entire read-only repo directory (for example via rm -rf), fingerprintWorkspace(repositoryPath) throws ENOENT here and the error escapes the result-finalization path, so awaitResult rejects instead of returning a structured failed result with the read-only violation message. This regresses behavior from the previous workspace-level fingerprint check, which would mark the run as an EXECUTION_FAILED policy violation rather than surfacing an internal exception.

Useful? React with 👍 / 👎.

}
return fingerprints;
}

function enforceReadOnlyResult(
request: TaskExecutionRequest,
result: TaskExecutionResult,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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);
}
}
Expand Down
Loading