Skip to content
Open
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
24 changes: 22 additions & 2 deletions src/agent-auth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions src/cli/agent-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 [];
}

Expand Down Expand Up @@ -2466,7 +2479,7 @@ function renderAgentCreateUsage(): string {
" --role-template <backend-api|frontend-ui|docs|ci-release|code-review|debugger|test-automation|security>",
" --role-template-file <path>",
" --role <role>",
" --runner <codex|claude-code|cursor|devin|custom>",
" --runner <codex|claude-code|cursor|opencode|devin|custom>",
" --model <model>",
" --permission-preset <read_only|read_write|read_write_ship|advanced>",
" --scopes <comma,separated>",
Expand All @@ -2492,7 +2505,7 @@ function renderAgentUpdateUsage(): string {
" --role-template <template-id>",
" --role-template-file <path>",
" --role <role>",
" --runner <codex|claude-code|cursor|devin|custom>",
" --runner <codex|claude-code|cursor|opencode|devin|custom>",
" --model <model>",
" --permission-preset <read_only|read_write|read_write_ship|advanced>",
" --scopes <comma,separated>",
Expand Down
7 changes: 6 additions & 1 deletion src/cli/doctor.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}.`,
};
}

Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,7 @@ function writeUsage(output: Writer) {
"Usage:",
" agentrail init [flags]",
" --repo-map-mode <deterministic|ai-assist>",
" --repo-map-profiler-runner <codex|claude-code|cursor|custom>",
" --repo-map-profiler-runner <codex|claude-code|cursor|opencode|custom>",
" --repo-map-profiler-model <model>",
" agentrail doctor [--agent-id <id>] [flags]",
" agentrail server start",
Expand Down Expand Up @@ -888,7 +888,7 @@ function writeUsage(output: Writer) {
" --telemetry",
" --no-telemetry",
" --repo-map-mode <deterministic|ai-assist>",
" --repo-map-profiler-runner <codex|claude-code|cursor|custom>",
" --repo-map-profiler-runner <codex|claude-code|cursor|opencode|custom>",
" --repo-map-profiler-model <model>",
" --code-review-policy <github-rules|always-require|never-require>",
" --runner-policy <strict|balanced|advisory|external-sandbox>",
Expand Down
2 changes: 1 addition & 1 deletion src/cli/telemetry-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
9 changes: 9 additions & 0 deletions src/repo-map-profiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
47 changes: 46 additions & 1 deletion src/runner-execution-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const TELEMETRY_EVENT_NAMES = [

const TELEMETRY_EVENT_NAME_SET = new Set<string>(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",
Expand Down
54 changes: 54 additions & 0 deletions test/agent-auth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
77 changes: 77 additions & 0 deletions test/agent-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
Loading