diff --git a/src/agent-auth-store.ts b/src/agent-auth-store.ts index b63da30..283d29f 100644 --- a/src/agent-auth-store.ts +++ b/src/agent-auth-store.ts @@ -475,7 +475,7 @@ function persistState(storagePath: string | undefined, keys: ApiKeyRecord[], ide mkdirSync(directory, { recursive: true }); const state: PersistedState = { keys, - idempotencyEntries: [...idempotencyEntries.entries()], + idempotencyEntries: [...idempotencyEntries.entries()].map(([key, entry]) => [key, sanitizeIdempotencyEntry(entry)]), }; const tempPath = path.join(directory, `.${path.basename(storagePath)}.tmp`); writeFileSync(tempPath, JSON.stringify(state, null, 2) + "\n", "utf8"); @@ -552,6 +552,23 @@ function toKeyResponse(key: ApiKeyRecord, apiKey: string): ApiKeyResponse { }; } +function redactApiKeyResponse(response: ApiKeyResponse): ApiKeyResponse { + return { + ...structuredClone(response), + data: { + ...structuredClone(response.data), + apiKey: "", + }, + }; +} + +function sanitizeIdempotencyEntry(entry: IdempotencyEntry): IdempotencyEntry { + return { + requestFingerprint: entry.requestFingerprint, + response: redactApiKeyResponse(entry.response), + }; +} + export class AgentAuthStore { private now: () => Date; private storagePath: string | undefined; @@ -565,7 +582,10 @@ export class AgentAuthStore { const state = loadState(storagePath); this.keys = Array.isArray(state.keys) ? state.keys.map(key => clone(key)) : []; this.keyHashes = new Map(this.keys.map(key => [key.keyHash, key])); - this.idempotencyEntries = new Map(Array.isArray(state.idempotencyEntries) ? state.idempotencyEntries : []); + this.idempotencyEntries = new Map( + (Array.isArray(state.idempotencyEntries) ? state.idempotencyEntries : []) + .map(([key, entry]) => [key, sanitizeIdempotencyEntry(entry)]), + ); } canBootstrap(): boolean { diff --git a/src/cli/agent-management.ts b/src/cli/agent-management.ts index b27d5e0..f1c374e 100644 --- a/src/cli/agent-management.ts +++ b/src/cli/agent-management.ts @@ -309,6 +309,13 @@ const RUNNER_DEFINITIONS: RunnerDefinition[] = [ signInHint: "Make sure you are already signed in to Cursor Agent on this machine.", executable: "cursor-agent", }, + { + value: "opencode", + label: "OpenCode", + description: "Runs work through the OpenCode CLI.", + signInHint: "Make sure you are already signed in to OpenCode on this machine.", + executable: process.platform === "win32" ? "opencode.cmd" : "opencode", + }, { value: "devin", label: "Devin", @@ -1848,6 +1855,12 @@ function agentModelChoices(runner: string): PromptChoice[] { { value: "sonnet-4-thinking", label: "Sonnet 4 Thinking", hint: "Cursor Agent documented reasoning model example." }, ]; } + if (runner === "opencode") { + return [ + { value: "anthropic/claude-sonnet-4-20250514", label: "Claude Sonnet 4", hint: "OpenCode provider/model format." }, + { value: "openai/gpt-5", label: "GPT-5", hint: "OpenCode provider/model format." }, + ]; + } return []; } @@ -2466,7 +2479,7 @@ function renderAgentCreateUsage(): string { " --role-template ", " --role-template-file ", " --role ", - " --runner ", + " --runner ", " --model ", " --permission-preset ", " --scopes ", @@ -2492,7 +2505,7 @@ function renderAgentUpdateUsage(): string { " --role-template ", " --role-template-file ", " --role ", - " --runner ", + " --runner ", " --model ", " --permission-preset ", " --scopes ", diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 95e18bf..ab32d4e 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { spawnSync } from "node:child_process"; @@ -785,7 +786,7 @@ function checkRunnerPolicyReadiness(inputs: DoctorInputs): { ok: boolean; summar const strengths = [...new Set(plan.enforcement.map((item) => item.strength))].join(", "); return { ok: true, - summary: `Runner policy for "${inputs.agentRunner}" can launch as ${plan.executable}; enforcement levels: ${strengths}.`, + summary: `Runner policy for "${inputs.agentRunner}" can launch as ${plan.executable}; mode: ${plan.enforcementMode}; enforcement levels: ${strengths}.`, }; } @@ -904,10 +905,14 @@ function classifierExecutableForRunner(runner: string): string | null { if (runner === "codex") return "codex"; if (runner === "claude-code") return "claude"; if (runner === "cursor") return "cursor-agent"; + if (runner === "opencode") return process.platform === "win32" ? "opencode.cmd" : "opencode"; return null; } function commandExists(command: string): boolean { + if (path.isAbsolute(command)) { + return existsSync(command); + } const executable = process.platform === "win32" ? "where" : "which"; return spawnSync(executable, [command], { stdio: "ignore" }).status === 0; } diff --git a/src/cli/index.ts b/src/cli/index.ts index 14d9a48..e259b87 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -847,7 +847,7 @@ function writeUsage(output: Writer) { "Usage:", " agentrail init [flags]", " --repo-map-mode ", - " --repo-map-profiler-runner ", + " --repo-map-profiler-runner ", " --repo-map-profiler-model ", " agentrail doctor [--agent-id ] [flags]", " agentrail server start", @@ -888,7 +888,7 @@ function writeUsage(output: Writer) { " --telemetry", " --no-telemetry", " --repo-map-mode ", - " --repo-map-profiler-runner ", + " --repo-map-profiler-runner ", " --repo-map-profiler-model ", " --code-review-policy ", " --runner-policy ", diff --git a/src/cli/telemetry-hook.ts b/src/cli/telemetry-hook.ts index bb1bbc0..c279729 100644 --- a/src/cli/telemetry-hook.ts +++ b/src/cli/telemetry-hook.ts @@ -81,7 +81,7 @@ export function safeTelemetryRoutingMode(value: unknown): TelemetryRoutingMode | } export function safeTelemetryRunner(value: unknown): TelemetryRunner { - if (value === "codex" || value === "claude-code" || value === "cursor") { + if (value === "codex" || value === "claude-code" || value === "cursor" || value === "opencode") { return value; } return "custom"; diff --git a/src/repo-map-profiler.ts b/src/repo-map-profiler.ts index 7901c4b..5a7bf0e 100644 --- a/src/repo-map-profiler.ts +++ b/src/repo-map-profiler.ts @@ -232,6 +232,15 @@ export function repoMapProfilerLaunchCommand(config: RepoMapProfilerConfig): { e if (config.runner === "cursor") { return { executable: "cursor-agent", args: ["--print", ...modelArgs] }; } + if (config.runner === "opencode") { + const opencodeArgs = ["run", "--pure", "--dangerously-skip-permissions", "--format", "json", ...modelArgs]; + return process.platform === "win32" + ? { + executable: process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", "opencode.cmd", ...opencodeArgs], + } + : { executable: "opencode", args: opencodeArgs }; + } if (config.runner === "custom") { throw new Error("Custom repo-map profiler commands are not configured yet."); } diff --git a/src/runner-execution-policy.ts b/src/runner-execution-policy.ts index 691414c..d8b9776 100644 --- a/src/runner-execution-policy.ts +++ b/src/runner-execution-policy.ts @@ -458,7 +458,9 @@ export function compileRunnerExecutionPlan(params: CompileRunnerExecutionPlanPar ? claudePlan(params, policy, env) : runner === "cursor" ? cursorPlan(params, policy, env) - : customPlan(params, policy, env); + : runner === "opencode" + ? opencodePlan(params, policy, env) + : customPlan(params, policy, env); if (externalSandbox.length === 0) { return directPlan; @@ -705,6 +707,49 @@ function cursorPlan( }; } +function opencodePlan( + params: CompileRunnerExecutionPlanParams, + policy: RunnerExecutionPolicy, + env: NodeJS.ProcessEnv, +): RunnerPolicyPlan { + const opencodeArgs = [ + "run", + "--pure", + "--dangerously-skip-permissions", + "--format", + "json", + "--dir", + params.worktreePath, + ...(params.model ? ["--model", params.model] : []), + ]; + const executable = process.platform === "win32" + ? process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe" + : "opencode"; + const args = process.platform === "win32" + ? ["/d", "/s", "/c", "opencode.cmd", ...opencodeArgs] + : opencodeArgs; + + return { + runner: params.runner, + enforcementMode: policy.enforcementMode, + executable, + args, + cwd: params.worktreePath, + env, + generatedFiles: [], + filesystemPolicy: buildFilesystemPlan(params, policy, true), + manualContinuationAllowed: policy.enforcementMode !== "strict", + enforcement: [ + { area: "filesystem", strength: "partial", detail: "OpenCode is launched with permission bypass for managed automation; AgentRail can validate denied paths before and after launch, but OpenCode does not provide a verified AgentRail-owned filesystem sandbox.", critical: true }, + { area: "network", strength: "unsupported", detail: "OpenCode network isolation is not verified for managed local runs.", critical: true }, + { area: "credentials", strength: "enforced", detail: "Child environment is built from AgentRail's allowlist and run-scoped values.", critical: true }, + { area: "publish", strength: "partial", detail: "AgentRail owns branch push and task submission after handoff validation, but OpenCode command execution is not strictly sandboxed.", critical: true }, + { area: "commands", strength: "partial", detail: "OpenCode permission bypass is required for managed automation; AgentRail blocks direct publish by owning the publish step and validating denied paths.", critical: false }, + { area: "config", strength: "partial", detail: "OpenCode is launched in pure mode, but local CLI authentication/config remains runner-managed.", critical: false }, + ], + }; +} + function customPlan( params: CompileRunnerExecutionPlanParams, policy: RunnerExecutionPolicy, diff --git a/src/telemetry.ts b/src/telemetry.ts index dd4993c..ed149cd 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -26,7 +26,7 @@ export const TELEMETRY_EVENT_NAMES = [ const TELEMETRY_EVENT_NAME_SET = new Set(TELEMETRY_EVENT_NAMES); const TELEMETRY_PROVIDER_VALUES = ["github", "linear", "circleci", "github_actions", "agentrail", "disabled"] as const; -const TELEMETRY_RUNNER_VALUES = ["codex", "claude-code", "cursor", "custom"] as const; +const TELEMETRY_RUNNER_VALUES = ["codex", "claude-code", "cursor", "opencode", "custom"] as const; const TELEMETRY_ROUTING_MODE_VALUES = ["rules_only", "ai_assist"] as const; const TELEMETRY_ASSIGNMENT_SOURCE_VALUES = [ "deterministic_rule", diff --git a/test/agent-auth-store.test.ts b/test/agent-auth-store.test.ts index dbc4c50..5062b8c 100644 --- a/test/agent-auth-store.test.ts +++ b/test/agent-auth-store.test.ts @@ -64,6 +64,60 @@ test("AgentAuthStore persists keys and rotates with updated scopes and metadata" assert.equal(stored.keys.length, 2); }); +test("AgentAuthStore redacts idempotency response API keys before persistence", async (t) => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "agentrail-auth-idempotency-redact-")); + const storagePath = path.join(tempDir, "auth-store.json"); + + t.after(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + const authStore = new AgentAuthStore({ + now: () => new Date("2026-05-06T12:00:00Z"), + storagePath, + }); + + const created = authStore.createKey({ + agent: { + id: "agt_redact", + displayName: "Redact Example", + role: "coding_agent", + }, + scopes: ["tasks:read"], + }, "create-redact-example-v1"); + + assert.match(created.data.apiKey, /^ar_live_/); + + const replayed = authStore.createKey({ + agent: { + id: "agt_redact", + displayName: "Redact Example", + role: "coding_agent", + }, + scopes: ["tasks:read"], + }, "create-redact-example-v1"); + assert.equal(replayed.data.id, created.data.id); + assert.equal(replayed.data.apiKey, created.data.apiKey); + + const storedText = await readFile(storagePath, "utf8"); + assert.doesNotMatch(storedText, /ar_live_/); + + const reloaded = new AgentAuthStore({ + now: () => new Date("2026-05-06T12:05:00Z"), + storagePath, + }); + const persistedReplay = reloaded.createKey({ + agent: { + id: "agt_redact", + displayName: "Redact Example", + role: "coding_agent", + }, + scopes: ["tasks:read"], + }, "create-redact-example-v1"); + assert.equal(persistedReplay.data.id, created.data.id); + assert.equal(persistedReplay.data.apiKey, ""); +}); + test("AgentAuthStore persists usage counters and rate window state across restart", async (t) => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "agentrail-auth-usage-")); const storagePath = path.join(tempDir, "auth-store.json"); diff --git a/test/agent-management.test.ts b/test/agent-management.test.ts index c319b2d..1af17c5 100644 --- a/test/agent-management.test.ts +++ b/test/agent-management.test.ts @@ -169,6 +169,83 @@ test("agent create provisions a managed local agent and doctor passes", async (t assert.doesNotMatch(serialized, /oxnw\/agentrail/); }); +test("agent create provisions OpenCode as a managed local runner", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-opencode-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const harness = await createHarness(); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const telemetryEvents: Array<{ event: string; properties: unknown }> = []; + const previousHome = process.env.AGENTRAIL_HOME; + process.env.AGENTRAIL_HOME = homePath; + await installFakeExecutableOnPath(t, homePath, "opencode"); + + t.after(async () => { + await rm(repoRoot, { recursive: true, force: true }); + await rm(homePath, { recursive: true, force: true }); + if (previousHome === undefined) delete process.env.AGENTRAIL_HOME; + else process.env.AGENTRAIL_HOME = previousHome; + await harness.close(); + }); + + await writeSetupRepo(repoRoot, homePath, harness.baseUrl, harness.operatorApiKey, { + runnerPolicy: { preset: "advisory" }, + }); + + const exitCode = await runCli([ + "agent", + "create", + "--base-url", + harness.baseUrl, + "--agent-id", + "agt_opencode", + "--name", + "OpenCode Builder", + "--runner", + "opencode", + "--model", + "openai/gpt-5", + "--scopes", + "tasks:read,tasks:write", + "--repo-allowlist", + "oxnw/agentrail", + "--capability-tags", + "code,tests,api", + ], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + telemetry: { + capture(event, properties) { + telemetryEvents.push({ event, properties }); + }, + }, + }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.match(stdout.toString(), /Created agent OpenCode Builder \(agt_opencode\)\./); + assert.equal(stderr.toString(), ""); + + const envText = await readFile(path.join(homePath, "agents", "agt_opencode.env"), "utf8"); + const env = parseEnv(envText); + assert.equal(env.AGENTRAIL_AGENT_RUNNER, "opencode"); + assert.equal(env.AGENTRAIL_AGENT_MODEL, "openai/gpt-5"); + assert.match(stdout.toString(), /Runner policy for "opencode"/i); + assert.match(stdout.toString(), /advisory/i); + assert.deepEqual(telemetryEvents.slice(-1), [ + { + event: "agent_created", + properties: { + runner: "opencode", + routingMode: "rules_only", + count: 1, + }, + }, + ]); +}); + test("agent create refuses without a connected GitHub provider when provider mode is real", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-agent-create-no-gh-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); diff --git a/test/helpers/setup-doctor-fixture.ts b/test/helpers/setup-doctor-fixture.ts index e37f190..65b18dc 100644 --- a/test/helpers/setup-doctor-fixture.ts +++ b/test/helpers/setup-doctor-fixture.ts @@ -124,6 +124,7 @@ export async function writeDoctorRepo({ routingMode = "rules_only", routingClassifierRunner = "codex", routingClassifierModel = null, + runnerPolicy, }: { repoRoot: string; homePath?: string; @@ -134,6 +135,7 @@ export async function writeDoctorRepo({ routingMode?: "rules_only" | "ai_assist"; routingClassifierRunner?: "codex" | "claude-code" | "cursor" | "custom" | string; routingClassifierModel?: string | null; + runnerPolicy?: Parameters[0]["runnerPolicy"]; }): Promise { const detectedRepo: DetectedRepoContext = { repoPath: repoRoot, @@ -153,6 +155,7 @@ export async function writeDoctorRepo({ routingMode, routingClassifierRunner, routingClassifierModel, + runnerPolicy, }); await writeSetupFiles({ diff --git a/test/repo-map-profiler.test.ts b/test/repo-map-profiler.test.ts index 3c56e79..2624322 100644 --- a/test/repo-map-profiler.test.ts +++ b/test/repo-map-profiler.test.ts @@ -191,6 +191,21 @@ test("repoMapProfilerLaunchCommand uses local runner commands", () => { executable: "cursor-agent", args: ["--print", "--model", "sonnet-4"], }); + const opencode = repoMapProfilerLaunchCommand({ + runner: "opencode", + model: "openai/gpt-5", + timeoutMs: 1000, + confidenceThreshold: 0.65, + }); + if (process.platform === "win32") { + assert.match(opencode.executable, /cmd\.exe$/i); + assert.deepEqual(opencode.args, ["/d", "/s", "/c", "opencode.cmd", "run", "--pure", "--dangerously-skip-permissions", "--format", "json", "--model", "openai/gpt-5"]); + } else { + assert.deepEqual(opencode, { + executable: "opencode", + args: ["run", "--pure", "--dangerously-skip-permissions", "--format", "json", "--model", "openai/gpt-5"], + }); + } }); test("proposeRepoMapWithLocalRunner launches inside the target repo with a safe env allowlist", async (t) => { diff --git a/test/runner-execution-policy.test.ts b/test/runner-execution-policy.test.ts index dd8b706..a2e54d0 100644 --- a/test/runner-execution-policy.test.ts +++ b/test/runner-execution-policy.test.ts @@ -85,6 +85,74 @@ test("compileRunnerExecutionPlan maps strict Codex policy to sandboxed exec args assert.equal(plan.env.GITHUB_TOKEN, undefined); }); +test("compileRunnerExecutionPlan rejects OpenCode in strict mode without an external sandbox", () => { + const plan = compileRunnerExecutionPlan({ + ...basePlanInput, + runner: "opencode", + model: "openai/gpt-5", + }); + + const validation = validateRunnerPolicyPlan(plan); + assert.equal(validation.ok, false); + assert.match(validation.reasons.join("\n"), /filesystem/i); + assert.match(validation.reasons.join("\n"), /network/i); + if (process.platform === "win32") { + assert.match(plan.executable, /cmd\.exe$/i); + assert.deepEqual(plan.args.slice(0, 5), ["/d", "/s", "/c", "opencode.cmd", "run"]); + } else { + assert.equal(plan.executable, "opencode"); + assert.deepEqual(plan.args.slice(0, 2), ["run", "--pure"]); + } + assert.ok(plan.args.includes("--pure")); + assert.ok(plan.args.includes("--dangerously-skip-permissions")); + assert.ok(plan.args.includes("--format")); + assert.ok(plan.args.includes("json")); + assert.ok(plan.args.includes("--dir")); + assert.ok(plan.args.includes(basePlanInput.worktreePath)); + assert.ok(plan.args.includes("--model")); + assert.ok(plan.args.includes("openai/gpt-5")); + assert.equal(plan.env.GITHUB_TOKEN, undefined); + assert.equal(plan.env.AGENTRAIL_RUN_CONTEXT_TOKEN, "arrun_context"); + assert.equal(plan.filesystemPolicy.enforceDeniedPaths, true); + assert.equal(plan.manualContinuationAllowed, false); +}); + +test("compileRunnerExecutionPlan routes OpenCode through an external sandbox wrapper", () => { + const plan = compileRunnerExecutionPlan({ + ...basePlanInput, + runner: "opencode", + policy: { + preset: "external_sandbox", + externalSandbox: { + command: ["sandbox-runner", "--policy", "agentrail"], + }, + }, + }); + + assert.equal(validateRunnerPolicyPlan(plan).ok, true); + assert.equal(plan.executable, "sandbox-runner"); + if (process.platform === "win32") { + assert.deepEqual(plan.args.slice(0, 7), ["--policy", "agentrail", process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe", "/d", "/s", "/c", "opencode.cmd"]); + } else { + assert.deepEqual(plan.args.slice(0, 4), ["--policy", "agentrail", "opencode", "run"]); + } + assert.equal(plan.manualContinuationAllowed, true); +}); + +test("compileRunnerExecutionPlan allows OpenCode in advisory mode", () => { + const plan = compileRunnerExecutionPlan({ + ...basePlanInput, + runner: "opencode", + policy: { + preset: "advisory", + }, + }); + + assert.equal(validateRunnerPolicyPlan(plan).ok, true); + assert.equal(plan.enforcementMode, "advisory"); + assert.equal(plan.manualContinuationAllowed, true); +}); + test("compileRunnerExecutionPlan writes generated Claude settings", async (t) => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "agentrail-policy-claude-")); t.after(async () => { @@ -270,6 +338,7 @@ test("Codex filesystem post-run validation restores original instruction file mo await mkdir(runDir, { recursive: true }); const agentsPath = path.join(worktreePath, "AGENTS.md"); await writeFile(agentsPath, "Original project instructions\n", { encoding: "utf8", mode: 0o644 }); + const originalMode = (await stat(agentsPath)).mode & 0o777; const plan = compileRunnerExecutionPlan({ ...basePlanInput, @@ -288,7 +357,7 @@ test("Codex filesystem post-run validation restores original instruction file mo const result = await validateRunnerPolicyFilesystemPostRun(plan, preflight.snapshot); assert.equal(result.ok, true); - assert.equal((await stat(agentsPath)).mode & 0o777, 0o644); + assert.equal((await stat(agentsPath)).mode & 0o777, originalMode); }); test("compileRunnerExecutionPlan rejects Cursor in strict mode without an external sandbox", () => { diff --git a/test/setup-doctor.test.ts b/test/setup-doctor.test.ts index 2bbffe4..e46589e 100644 --- a/test/setup-doctor.test.ts +++ b/test/setup-doctor.test.ts @@ -24,6 +24,7 @@ test.before(async () => { previousDoctorPath = process.env.PATH; doctorBinDir = await mkdtemp(path.join(os.tmpdir(), "agentrail-doctor-bin-")); await installFakeExecutable(doctorBinDir, "codex"); + await installFakeExecutable(doctorBinDir, "opencode"); process.env.PATH = `${doctorBinDir}${path.delimiter}${previousDoctorPath ?? ""}`; }); @@ -235,6 +236,72 @@ test("agentrail doctor honors an explicit --env-file over a legacy default env f assert.doesNotMatch(serialized, /agt_setup/); }); +test("agentrail doctor accepts OpenCode as a first-class runner policy", async (t) => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-doctor-opencode-")); + const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); + const stdout = createMemoryWriter(); + const stderr = createMemoryWriter(); + const previousSetupApiKey = process.env.AGENTRAIL_SETUP_API_KEY; + const previousHome = process.env.AGENTRAIL_HOME; + let harness: SetupDoctorHarness | null = null; + t.after(createCleanup({ + previousSetupApiKey, + previousHome, + repoRoot, + homePath, + getHarness: () => harness, + })); + process.env.AGENTRAIL_HOME = homePath; + + harness = await createSetupDoctorHarness(); + process.env.AGENTRAIL_SETUP_API_KEY = harness.operatorApiKey; + + await seedSetupVerificationTask({ + baseUrl: harness.baseUrl, + operatorApiKey: harness.operatorApiKey, + agentId: harness.agentId, + }); + + await writeDoctorRepo({ + repoRoot, + homePath, + baseUrl: harness.baseUrl, + agentApiKey: harness.agentApiKey, + agentId: harness.agentId, + repoAllowlist: harness.repoAllowlist, + runnerPolicy: { preset: "advisory" }, + }); + + const explicitEnvPath = path.join(repoRoot, "tmp", "doctor-opencode.env"); + await mkdir(path.dirname(explicitEnvPath), { recursive: true }); + await writeFile( + explicitEnvPath, + [ + `AGENTRAIL_BASE_URL=${harness.baseUrl}`, + `AGENTRAIL_API_KEY=${harness.agentApiKey}`, + `AGENTRAIL_AGENT_ID=${harness.agentId}`, + "AGENTRAIL_AGENT_RUNNER=opencode", + `AGENTRAIL_REPO_ALLOWLIST=${harness.repoAllowlist.join(",")}`, + "", + ].join("\n"), + { mode: 0o600 }, + ); + + const exitCode = await runCli(["doctor", "--env-file", path.relative(repoRoot, explicitEnvPath)], { + cwd: repoRoot, + stdinIsTTY: false, + stdoutIsTTY: false, + stdout, + stderr, + }); + + assert.equal(exitCode, 0, stderr.toString()); + assert.match(stdout.toString(), /PASS runner_policy/i); + assert.match(stdout.toString(), /Runner policy for "opencode"/i); + assert.match(stdout.toString(), /advisory/i); + assert.equal(stderr.toString(), ""); +}); + test("agentrail doctor warns when a role template has unmapped preferred repo areas", async (t) => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "agentrail-doctor-repo-map-warn-")); const homePath = await mkdtemp(path.join(os.tmpdir(), "agentrail-home-")); diff --git a/test/setup-wizard.test.ts b/test/setup-wizard.test.ts index 1d4579a..e674d41 100644 --- a/test/setup-wizard.test.ts +++ b/test/setup-wizard.test.ts @@ -731,21 +731,23 @@ test("runCli validates --yes safe defaults against the target repo", async () => const stdout = createMemoryWriter(); const stderr = createMemoryWriter(); const writes: Array<{ homePath?: string; repoRoot?: string; config: SetupConfig }> = []; + const invocationPath = path.resolve("/tmp/invocation"); + const safeTargetPath = path.resolve("/tmp/safe-target"); const detectedByPath = new Map([ [ - "/tmp/invocation", + invocationPath, { ...detectedRepo, - repoPath: "/tmp/invocation", + repoPath: invocationPath, remoteSlug: "unsafe/invocation", gitIgnoreHasAgentrail: false, }, ], [ - "/tmp/safe-target", + safeTargetPath, { ...detectedRepo, - repoPath: "/tmp/safe-target", + repoPath: safeTargetPath, remoteSlug: "safe/target", gitIgnoreHasAgentrail: true, }, @@ -757,12 +759,29 @@ test("runCli validates --yes safe defaults against the target repo", async () => "--yes", "--mode", "server", + "--base-url", + "http://127.0.0.1:3000", + "--persistence", + "file", "--provider-mode", "disabled", + "--routing-mode", + "rules-only", + "--repo-map-mode", + "deterministic", + "--runner-policy", + "strict", "--repo", - "/tmp/safe-target", + safeTargetPath, + "--repo-allowlist", + "safe/target", + "--default-branch", + "main", + "--code-review-policy", + "github-rules", + "--no-markdown-export", ], { - cwd: "/tmp/invocation", + cwd: invocationPath, stdinIsTTY: false, stdoutIsTTY: false, stdout, @@ -960,7 +979,7 @@ test("runCli init help documents telemetry and repo-map flags", async () => { assert.match(stdout.toString(), /--telemetry/); assert.match(stdout.toString(), /--no-telemetry/); assert.match(stdout.toString(), /--repo-map-mode /); - assert.match(stdout.toString(), /--repo-map-profiler-runner /); + assert.match(stdout.toString(), /--repo-map-profiler-runner /); assert.match(stdout.toString(), /--repo-map-profiler-model /); assert.equal(stderr.toString(), ""); });