Skip to content

Commit 271e1cf

Browse files
committed
fix: preserve writable mode for mixed repo runs
1 parent d8454e9 commit 271e1cf

5 files changed

Lines changed: 562 additions & 109 deletions

File tree

packages/adapters/src/index.test.ts

Lines changed: 310 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import { join } from "node:path";
3-
import { chmod, mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
3+
import { chmod, mkdtemp, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { afterEach, test } from "vitest";
66
import {
@@ -26,24 +26,148 @@ function createRequest(
2626
executorId: TaskExecutionRequest["executor"]["executorId"],
2727
options: { model?: string; provider?: string; readOnly?: boolean } = {},
2828
): TaskExecutionRequest {
29+
const workspaceId = "workspace-1";
30+
const repositoryId = "repo-1";
2931
return {
3032
protocolVersion: PROTOCOL_VERSION,
3133
taskId: `task-${executorId}`,
3234
taskType: "triage",
33-
project: { id: "p1", name: "repo" },
34-
workItem: { kind: "github-issue", externalId: "1", title: "Smoke test" },
35-
workspace: {
36-
sourceRepoPath: "/tmp/repo",
37-
workBranch: `devagent/${executorId}/task`,
38-
isolation: "temp-copy",
39-
readOnly: options.readOnly,
35+
workspaceRef: {
36+
id: workspaceId,
37+
name: "Adapter Workspace",
38+
provider: "github",
39+
primaryRepositoryId: repositoryId,
4040
},
41+
repositories: [{
42+
id: repositoryId,
43+
workspaceId,
44+
alias: "primary",
45+
name: "repo",
46+
repoRoot: "/tmp/repo",
47+
repoFullName: "org/repo",
48+
defaultBranch: "main",
49+
provider: "github",
50+
}],
51+
workItem: {
52+
id: "issue-1",
53+
kind: "github-issue",
54+
externalId: "1",
55+
title: "Smoke test",
56+
repositoryId,
57+
},
58+
execution: {
59+
primaryRepositoryId: repositoryId,
60+
repositories: [{
61+
repositoryId,
62+
alias: "primary",
63+
sourceRepoPath: "/tmp/repo",
64+
workBranch: `devagent/${executorId}/task`,
65+
isolation: "temp-copy",
66+
readOnly: options.readOnly,
67+
}],
68+
},
69+
targetRepositoryIds: [repositoryId],
4170
executor: {
4271
executorId,
4372
model: options.model ?? "test-model",
4473
provider: options.provider,
4574
},
4675
constraints: {},
76+
capabilities: {
77+
canSyncTasks: true,
78+
canCreateTask: true,
79+
canComment: true,
80+
canReview: true,
81+
canMerge: true,
82+
canOpenReviewable: true,
83+
},
84+
context: { summary: "smoke" },
85+
expectedArtifacts: ["triage-report"],
86+
};
87+
}
88+
89+
function createMultiRepoRequest(
90+
executorId: TaskExecutionRequest["executor"]["executorId"],
91+
options: { primaryReadOnly?: boolean; secondaryReadOnly?: boolean; model?: string; provider?: string } = {},
92+
): TaskExecutionRequest {
93+
const workspaceId = "workspace-1";
94+
const primaryRepositoryId = "repo-1";
95+
const secondaryRepositoryId = "repo-2";
96+
return {
97+
protocolVersion: PROTOCOL_VERSION,
98+
taskId: `task-${executorId}-multi`,
99+
taskType: "triage",
100+
workspaceRef: {
101+
id: workspaceId,
102+
name: "Adapter Workspace",
103+
provider: "github",
104+
primaryRepositoryId,
105+
},
106+
repositories: [
107+
{
108+
id: primaryRepositoryId,
109+
workspaceId,
110+
alias: "primary",
111+
name: "repo",
112+
repoRoot: "/tmp/repo",
113+
repoFullName: "org/repo",
114+
defaultBranch: "main",
115+
provider: "github",
116+
},
117+
{
118+
id: secondaryRepositoryId,
119+
workspaceId,
120+
alias: "secondary",
121+
name: "repo-support",
122+
repoRoot: "/tmp/repo-support",
123+
repoFullName: "org/repo-support",
124+
defaultBranch: "main",
125+
provider: "github",
126+
},
127+
],
128+
workItem: {
129+
id: "issue-1",
130+
kind: "github-issue",
131+
externalId: "1",
132+
title: "Smoke test",
133+
repositoryId: primaryRepositoryId,
134+
},
135+
execution: {
136+
primaryRepositoryId,
137+
repositories: [
138+
{
139+
repositoryId: primaryRepositoryId,
140+
alias: "primary",
141+
sourceRepoPath: "/tmp/repo",
142+
workBranch: `devagent/${executorId}/task`,
143+
isolation: "temp-copy",
144+
readOnly: options.primaryReadOnly,
145+
},
146+
{
147+
repositoryId: secondaryRepositoryId,
148+
alias: "secondary",
149+
sourceRepoPath: "/tmp/repo-support",
150+
workBranch: `devagent/${executorId}/task`,
151+
isolation: "temp-copy",
152+
readOnly: options.secondaryReadOnly,
153+
},
154+
],
155+
},
156+
targetRepositoryIds: [primaryRepositoryId],
157+
executor: {
158+
executorId,
159+
model: options.model ?? "test-model",
160+
provider: options.provider,
161+
},
162+
constraints: {},
163+
capabilities: {
164+
canSyncTasks: true,
165+
canCreateTask: true,
166+
canComment: true,
167+
canReview: true,
168+
canMerge: true,
169+
canOpenReviewable: true,
170+
},
47171
context: { summary: "smoke" },
48172
expectedArtifacts: ["triage-report"],
49173
};
@@ -63,9 +187,10 @@ async function collectEvents(
63187
request: TaskExecutionRequest,
64188
workspacePath: string,
65189
artifactDir: string,
190+
repositoryPaths: Record<string, string> = {},
66191
): Promise<{ events: TaskExecutionEvent[]; result: TaskExecutionResult }> {
67192
const events: TaskExecutionEvent[] = [];
68-
const handle = await adapter.launch(request, workspacePath, artifactDir, (event) => {
193+
const handle = await adapter.launch(request, workspacePath, repositoryPaths, artifactDir, (event: TaskExecutionEvent) => {
69194
events.push(event);
70195
});
71196
return {
@@ -153,8 +278,9 @@ setTimeout(() => process.exit(0), 10000);
153278
const handle = await new DevAgentAdapter(`${process.execPath} ${stubPath}`).launch(
154279
createRequest("devagent"),
155280
workspacePath,
281+
{},
156282
artifactDir,
157-
(event) => {
283+
(event: TaskExecutionEvent) => {
158284
events.push(event);
159285
},
160286
);
@@ -189,6 +315,40 @@ process.exit(1);
189315
assert.match(events[0]?.type === "log" ? events[0].message : "", /result file was never written/);
190316
});
191317

318+
test("DevAgentAdapter launches from the prepared primary repository path", async () => {
319+
const { root, artifactDir, workspacePath } = await createWorkspace();
320+
const primaryRepoPath = join(workspacePath, "repos", "primary");
321+
await mkdir(primaryRepoPath, { recursive: true });
322+
const stubPath = join(root, "devagent-cwd-stub.js");
323+
await createStub(stubPath, `#!/usr/bin/env node
324+
const fs = require("fs");
325+
const path = require("path");
326+
const args = process.argv.slice(2);
327+
const requestPath = args[args.indexOf("--request") + 1];
328+
const artifactDir = args[args.indexOf("--artifact-dir") + 1];
329+
const request = JSON.parse(fs.readFileSync(requestPath, "utf8"));
330+
const artifactPath = path.join(artifactDir, "triage-report.md");
331+
const resultPath = path.join(artifactDir, "result.json");
332+
fs.writeFileSync(path.join(artifactDir, "cwd.txt"), process.cwd());
333+
fs.writeFileSync(artifactPath, "# Triage\\n\\nStub output\\n");
334+
fs.writeFileSync(resultPath, JSON.stringify({ protocolVersion: "0.1", taskId: request.taskId, status: "success", artifacts: [{ kind: "triage-report", path: artifactPath, createdAt: new Date().toISOString() }], metrics: { startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), durationMs: 1 } }, null, 2));
335+
`);
336+
337+
const { result } = await collectEvents(
338+
new DevAgentAdapter(`${process.execPath} ${stubPath}`),
339+
createRequest("devagent"),
340+
workspacePath,
341+
artifactDir,
342+
{ "repo-1": primaryRepoPath },
343+
);
344+
345+
assert.equal(result.status, "success");
346+
assert.equal(
347+
await realpath((await readFile(join(artifactDir, "cwd.txt"), "utf8")).trim()),
348+
await realpath(primaryRepoPath),
349+
);
350+
});
351+
192352
test("CodexAdapter smoke test with stub executable", async () => {
193353
const { root, artifactDir, workspacePath } = await createWorkspace();
194354
const stubPath = join(root, "codex-stub.js");
@@ -216,6 +376,58 @@ process.stdout.write("{\\"type\\":\\"turn.completed\\"}\\n");
216376
assert.match(await readFile(join(artifactDir, "triage-report.md"), "utf8"), /stub codex output/);
217377
});
218378

379+
test("CodexAdapter keeps mixed multi-repo requests writable", async () => {
380+
const { root, artifactDir, workspacePath } = await createWorkspace();
381+
const stubPath = join(root, "codex-mixed-stub.js");
382+
await createStub(stubPath, `#!/usr/bin/env node
383+
const fs = require("fs");
384+
const args = process.argv.slice(2);
385+
const modeIndex = args.indexOf("-s");
386+
if (modeIndex === -1 || args[modeIndex + 1] !== "workspace-write") {
387+
throw new Error("expected workspace-write mode");
388+
}
389+
const outIndex = args.indexOf("-o");
390+
if (outIndex >= 0) fs.writeFileSync(args[outIndex + 1], "stub codex output\\n");
391+
process.stdout.write("{\\"type\\":\\"item.completed\\",\\"item\\":{\\"type\\":\\"agent_message\\",\\"text\\":\\"stub codex output\\"}}\\n");
392+
`);
393+
394+
process.env.DEVAGENT_RUNNER_CODEX_BIN = `${process.execPath} ${stubPath}`;
395+
const { result } = await collectEvents(
396+
new CodexAdapter(),
397+
createMultiRepoRequest("codex", { primaryReadOnly: false, secondaryReadOnly: true }),
398+
workspacePath,
399+
artifactDir,
400+
);
401+
402+
assert.equal(result.status, "success");
403+
});
404+
405+
test("CodexAdapter keeps fully read-only multi-repo requests read-only", async () => {
406+
const { root, artifactDir, workspacePath } = await createWorkspace();
407+
const stubPath = join(root, "codex-readonly-stub.js");
408+
await createStub(stubPath, `#!/usr/bin/env node
409+
const fs = require("fs");
410+
const args = process.argv.slice(2);
411+
const modeIndex = args.indexOf("-s");
412+
if (modeIndex === -1 || args[modeIndex + 1] !== "read-only") {
413+
throw new Error("expected read-only mode");
414+
}
415+
const outIndex = args.indexOf("-o");
416+
if (outIndex >= 0) fs.writeFileSync(args[outIndex + 1], "stub codex output\\n");
417+
process.stdout.write("{\\"type\\":\\"item.completed\\",\\"item\\":{\\"type\\":\\"agent_message\\",\\"text\\":\\"stub codex output\\"}}\\n");
418+
`);
419+
420+
process.env.DEVAGENT_RUNNER_CODEX_BIN = `${process.execPath} ${stubPath}`;
421+
const { result } = await collectEvents(
422+
new CodexAdapter(),
423+
createMultiRepoRequest("codex", { primaryReadOnly: true, secondaryReadOnly: true }),
424+
workspacePath,
425+
artifactDir,
426+
);
427+
428+
assert.equal(result.status, "success");
429+
});
430+
219431
test("ClaudeAdapter smoke test with stub executable", async () => {
220432
const { root, artifactDir, workspacePath } = await createWorkspace();
221433
const stubPath = join(root, "claude-stub.js");
@@ -236,6 +448,52 @@ process.stdout.write(JSON.stringify({ type: "result", subtype: "success", result
236448
assert.match(await readFile(join(artifactDir, "triage-report.md"), "utf8"), /claude stub output/);
237449
});
238450

451+
test("ClaudeAdapter keeps mixed multi-repo requests writable", async () => {
452+
const { root, artifactDir, workspacePath } = await createWorkspace();
453+
const stubPath = join(root, "claude-mixed-stub.js");
454+
await createStub(stubPath, `#!/usr/bin/env node
455+
const args = process.argv.slice(2);
456+
const permissionIndex = args.indexOf("--permission-mode");
457+
if (permissionIndex === -1 || args[permissionIndex + 1] !== "bypassPermissions") {
458+
throw new Error("expected bypassPermissions");
459+
}
460+
process.stdout.write(JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "claude stub output" }] } }) + "\\n");
461+
process.stdout.write(JSON.stringify({ type: "result", subtype: "success", result: "claude stub output" }) + "\\n");
462+
`);
463+
464+
const { result } = await collectEvents(
465+
new ClaudeAdapter(`${process.execPath} ${stubPath}`),
466+
createMultiRepoRequest("claude", { primaryReadOnly: false, secondaryReadOnly: true }),
467+
workspacePath,
468+
artifactDir,
469+
);
470+
471+
assert.equal(result.status, "success");
472+
});
473+
474+
test("ClaudeAdapter keeps fully read-only multi-repo requests read-only", async () => {
475+
const { root, artifactDir, workspacePath } = await createWorkspace();
476+
const stubPath = join(root, "claude-readonly-stub.js");
477+
await createStub(stubPath, `#!/usr/bin/env node
478+
const args = process.argv.slice(2);
479+
const permissionIndex = args.indexOf("--permission-mode");
480+
if (permissionIndex === -1 || args[permissionIndex + 1] !== "plan") {
481+
throw new Error("expected plan permission mode");
482+
}
483+
process.stdout.write(JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "claude stub output" }] } }) + "\\n");
484+
process.stdout.write(JSON.stringify({ type: "result", subtype: "success", result: "claude stub output" }) + "\\n");
485+
`);
486+
487+
const { result } = await collectEvents(
488+
new ClaudeAdapter(`${process.execPath} ${stubPath}`),
489+
createMultiRepoRequest("claude", { primaryReadOnly: true, secondaryReadOnly: true }),
490+
workspacePath,
491+
artifactDir,
492+
);
493+
494+
assert.equal(result.status, "success");
495+
});
496+
239497
test("OpenCodeAdapter smoke test with stub executable", async () => {
240498
const { root, artifactDir, workspacePath } = await createWorkspace();
241499
const stubPath = join(root, "opencode-stub.js");
@@ -269,6 +527,48 @@ process.stdout.write(JSON.stringify({ type: "step_finish", part: { type: "step-f
269527
assert.match(await readFile(join(artifactDir, "triage-report.md"), "utf8"), /opencode stub output/);
270528
});
271529

530+
test("OpenCodeAdapter keeps mixed multi-repo requests writable", async () => {
531+
const { root, artifactDir, workspacePath } = await createWorkspace();
532+
const stubPath = join(root, "opencode-mixed-stub.js");
533+
await createStub(stubPath, `#!/usr/bin/env node
534+
const permissions = process.env.OPENCODE_PERMISSION || "";
535+
if (permissions.includes('"*":"deny"') || permissions.includes('"write":"deny"') || permissions.includes('"edit":"deny"')) {
536+
throw new Error("expected writable permissions");
537+
}
538+
process.stdout.write(JSON.stringify({ type: "text", part: { type: "text", text: "opencode stub output" } }) + "\\n");
539+
`);
540+
541+
const { result } = await collectEvents(
542+
new OpenCodeAdapter(`${process.execPath} ${stubPath}`),
543+
createMultiRepoRequest("opencode", { primaryReadOnly: false, secondaryReadOnly: true }),
544+
workspacePath,
545+
artifactDir,
546+
);
547+
548+
assert.equal(result.status, "success");
549+
});
550+
551+
test("OpenCodeAdapter keeps fully read-only multi-repo requests read-only", async () => {
552+
const { root, artifactDir, workspacePath } = await createWorkspace();
553+
const stubPath = join(root, "opencode-readonly-stub.js");
554+
await createStub(stubPath, `#!/usr/bin/env node
555+
const permissions = process.env.OPENCODE_PERMISSION || "";
556+
if (!permissions.includes('"*":"deny"') || !permissions.includes('"read":"allow"') || !permissions.includes('"edit":"deny"') || !permissions.includes('"write":"deny"')) {
557+
throw new Error("expected read-only permissions");
558+
}
559+
process.stdout.write(JSON.stringify({ type: "text", part: { type: "text", text: "opencode stub output" } }) + "\\n");
560+
`);
561+
562+
const { result } = await collectEvents(
563+
new OpenCodeAdapter(`${process.execPath} ${stubPath}`),
564+
createMultiRepoRequest("opencode", { primaryReadOnly: true, secondaryReadOnly: true }),
565+
workspacePath,
566+
artifactDir,
567+
);
568+
569+
assert.equal(result.status, "success");
570+
});
571+
272572
test("OpenCodeAdapter passes provider-qualified model names through", async () => {
273573
const { root, artifactDir, workspacePath } = await createWorkspace();
274574
const stubPath = join(root, "opencode-model-stub.js");

0 commit comments

Comments
 (0)