Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ Supported provider names today:
- `acpx`: any ACP-compatible coding agent (Codex / Claude / Pi / Gemini / ...) via openclaw/acpx
- `claude`: local Claude Code CLI in print mode
- `cursor`: local Cursor Agent CLI (experimental; `doctor` is enabled by default)
- `devin`: local Devin CLI in print mode (`--permission-mode` enforces read-only for review/revalidate)
- `grok`: local Grok Build CLI
- `opencode`: local OpenCode CLI
- `pi`: local Pi coding agent in print mode
Expand Down
39 changes: 39 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Provider names today:
- `codex`: shells out to `codex exec` (default)
- `acpx`: routes through any ACP-compatible coding agent via `acpx`
- `claude`: shells out to Claude Code in print mode (`claude -p`)
- `devin`: shells out to the local Devin CLI in print mode (`devin --print --prompt-file`)
- `grok`: shells out to the xAI Grok Build CLI in headless mode (`grok --prompt-file`)
- `opencode`: shells out to `opencode run --format json`
- `pi`: shells out to `pi -p` (non-interactive print mode)
Expand Down Expand Up @@ -138,6 +139,44 @@ Migration note: `--provider codex --model gpt-5-codex` is not equivalent to
`--provider acpx --model gpt-5-codex`; the latter selects an ACP agent named
`gpt-5-codex`. Use `--provider acpx --model codex:gpt-5-codex`.

## Devin

The `devin` provider shells out to the local [Devin CLI](https://docs.devin.ai/cli)
in non-interactive print mode:

- availability check: `devin --version`
- review / revalidate: `--permission-mode auto` — the Devin runtime auto-approves
read-only tools only and never grants write/execute in print mode, so this is
enforced read-only behavior (not a prompt-only directive)
- fix: `--permission-mode accept-edits` — the Devin runtime auto-approves workspace
edits; run `fix` only in an isolated trusted checkout
- prompt delivery: writes the full Clawpatch prompt to a temporary file and calls
`devin --print --prompt-file <path> --permission-mode <mode>`
- reasoning effort: the Devin CLI has no `--reasoning-effort` flag (verified against
v3000.2.17), so the requested effort level is injected into the prompt text
- output: parsed from stdout with the shared JSON extractor
- timeout: 300 seconds by default, override with `CLAWPATCH_DEVIN_TIMEOUT_MS` or
`CLAWPATCH_PROVIDER_TIMEOUT_MS`
- model selection: `--model <model>` is passed through to Devin when configured;
when unset, Devin uses its own configured default (`~/.config/devin/config.json`
on macOS/Linux, `%APPDATA%\devin\config.json` on Windows)
- environment: the Devin subprocess inherits the ambient environment so it can
locate its config-file credentials and proxy settings; clawpatch does not inject
or strip environment variables for this provider

Provider selection:

```bash
clawpatch review --provider devin
CLAWPATCH_PROVIDER=devin clawpatch review
clawpatch fix --finding <id> --provider devin
clawpatch doctor --provider devin
```

Authentication: run `devin auth login` once before using this provider. The
provider `check` runs `devin --version` only; a missing or expired auth token
surfaces as a `provider-failure` error on the first review/map/fix call.

## Claude

The `claude` provider shells out to the local
Expand Down
1 change: 1 addition & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,7 @@ Implemented providers:
- `acpx`: ACP-compatible agents through `acpx`.
- `claude`: Claude Code CLI in print mode.
- `cursor`: experimental Cursor Agent CLI integration.
- `devin`: Devin CLI in print mode with `--permission-mode` read-only enforcement.
- `grok`: Grok Build CLI.
- `opencode`: OpenCode CLI.
- `pi`: pi coding agent.
Expand Down
77 changes: 62 additions & 15 deletions src/provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ClawpatchError } from "./errors.js";
import { __testing, extractJson, providerByName } from "./provider.js";
Expand Down Expand Up @@ -31,6 +32,9 @@ const {
cursorFailureMessage,
cursorPrompt,
cursorTimeoutMs,
devinFailureMessage,
devinPrompt,
devinTimeoutMs,
extractAcpxJson,
extractCursorJson,
extractClaudeStructuredOutput,
Expand Down Expand Up @@ -796,21 +800,21 @@ describe("Claude provider helpers", () => {

expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({
PATH: "/bin",
HOME: "/tmp/claude/home",
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
XDG_CACHE_HOME: "/tmp/claude/xdg-cache",
XDG_DATA_HOME: "/tmp/claude/xdg-data",
HOME: join("/tmp/claude", "home"),
XDG_CONFIG_HOME: join("/tmp/claude", "xdg-config"),
XDG_CACHE_HOME: join("/tmp/claude", "xdg-cache"),
XDG_DATA_HOME: join("/tmp/claude", "xdg-data"),
TMPDIR: "/tmp/claude",
TEMP: "/tmp/claude",
TMP: "/tmp/claude",
CLAUDE_CODE_SUBPROCESS_ENV_SCRUB: "1",
});
expect(claudeEnv(true, "/tmp/claude", "isolated")).toEqual({
PATH: "/bin",
HOME: "/tmp/claude/home",
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
XDG_CACHE_HOME: "/tmp/claude/xdg-cache",
XDG_DATA_HOME: "/tmp/claude/xdg-data",
HOME: join("/tmp/claude", "home"),
XDG_CONFIG_HOME: join("/tmp/claude", "xdg-config"),
XDG_CACHE_HOME: join("/tmp/claude", "xdg-cache"),
XDG_DATA_HOME: join("/tmp/claude", "xdg-data"),
TMPDIR: "/tmp/claude",
TEMP: "/tmp/claude",
TMP: "/tmp/claude",
Expand All @@ -836,9 +840,9 @@ describe("Claude provider helpers", () => {
HOME: "/host-home",
USERPROFILE: "C:\\Users\\operator",
CLAUDE_CONFIG_DIR: "/host-claude-config",
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
XDG_CACHE_HOME: "/tmp/claude/xdg-cache",
XDG_DATA_HOME: "/tmp/claude/xdg-data",
XDG_CONFIG_HOME: join("/tmp/claude", "xdg-config"),
XDG_CACHE_HOME: join("/tmp/claude", "xdg-cache"),
XDG_DATA_HOME: join("/tmp/claude", "xdg-data"),
TMPDIR: "/tmp/claude",
TEMP: "/tmp/claude",
TMP: "/tmp/claude",
Expand Down Expand Up @@ -886,10 +890,10 @@ describe("Claude provider helpers", () => {

expect(claudeEnv(false, "/tmp/claude", "isolated")).toEqual({
PATH: "/bin",
HOME: "/tmp/claude/home",
XDG_CONFIG_HOME: "/tmp/claude/xdg-config",
XDG_CACHE_HOME: "/tmp/claude/xdg-cache",
XDG_DATA_HOME: "/tmp/claude/xdg-data",
HOME: join("/tmp/claude", "home"),
XDG_CONFIG_HOME: join("/tmp/claude", "xdg-config"),
XDG_CACHE_HOME: join("/tmp/claude", "xdg-cache"),
XDG_DATA_HOME: join("/tmp/claude", "xdg-data"),
TMPDIR: "/tmp/claude",
TEMP: "/tmp/claude",
TMP: "/tmp/claude",
Expand Down Expand Up @@ -1742,6 +1746,7 @@ describe("providerByName", () => {
it("returns provider instances for optional CLI-backed providers", () => {
expect(providerByName("acpx").name).toBe("acpx");
expect(providerByName("claude").name).toBe("claude");
expect(providerByName("devin").name).toBe("devin");
expect(providerByName("grok").name).toBe("grok");
expect(providerByName("opencode").name).toBe("opencode");
expect(providerByName("pi").name).toBe("pi");
Expand Down Expand Up @@ -1833,6 +1838,48 @@ describe("evidenceRefSchema tolerance", () => {
});
});

describe("Devin provider helpers", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add Devin command construction tests

These tests cover prompt text, timeouts, and failure messages but never invoke the provider or assert its constructed CLI arguments. Consequently, regressions in the safety-critical auto versus accept-edits selection, prompt-file delivery, or model forwarding can ship while this suite remains green; add focused read-only and fix argument tests, preferably around an extracted argument builder.

AGENTS.md reference: AGENTS.md:L34-L40

Useful? React with 👍 / 👎.

it("injects reasoning effort into the prompt and appends the JSON schema", () => {
const schema = { type: "object", properties: { ok: { type: "boolean" } } };
const withoutEffort = devinPrompt("review this", schema, null);
expect(withoutEffort).toContain("review this");
expect(withoutEffort).toContain("Return ONLY a JSON object");
expect(withoutEffort).toContain(JSON.stringify(schema));
expect(withoutEffort).not.toContain("REASONING EFFORT");

const withEffort = devinPrompt("review this", schema, "xhigh");
expect(withEffort).toContain("REASONING EFFORT: XHIGH");
expect(withEffort).toContain("Apply xhigh reasoning depth");
expect(withEffort).toContain("review this");
expect(withEffort).toContain(JSON.stringify(schema));

// "none" effort is treated like no effort (no preamble)
const noneEffort = devinPrompt("review this", schema, "none");
expect(noneEffort).not.toContain("REASONING EFFORT");
});

it("uses Devin-specific timeout before generic provider timeout", () => {
delete process.env["CLAWPATCH_DEVIN_TIMEOUT_MS"];
delete process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"];
expect(devinTimeoutMs()).toBe(300_000);

process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"] = "2000";
expect(devinTimeoutMs()).toBe(2000);

process.env["CLAWPATCH_DEVIN_TIMEOUT_MS"] = "3000";
expect(devinTimeoutMs()).toBe(3000);

process.env["CLAWPATCH_DEVIN_TIMEOUT_MS"] = "bad";
expect(devinTimeoutMs()).toBe(300_000);
});

it("uses bounded failure previews from stderr or stdout", () => {
expect(devinFailureMessage("", "auth required")).toContain("auth required");
expect(devinFailureMessage("stdout failure", "")).toContain("stdout failure");
expect(devinFailureMessage("", "")).toBe("devin provider failed");
});
});

describe("acpxPromptRetries", () => {
afterEach(() => {
delete process.env["CLAWPATCH_ACPX_PROMPT_RETRIES"];
Expand Down
3 changes: 3 additions & 0 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { acpxProvider, acpxTesting } from "./providers/acpx.js";
import { claudeProvider, claudeTesting } from "./providers/claude.js";
import { codexProvider, codexTesting } from "./providers/codex.js";
import { cursorProvider, cursorTesting } from "./providers/cursor.js";
import { devinProvider, devinTesting } from "./providers/devin.js";
import { grokProvider } from "./providers/grok.js";
import { mockFailProvider, mockProvider } from "./providers/mock.js";
import { opencodeProvider, opencodeTesting } from "./providers/opencode.js";
Expand All @@ -18,6 +19,7 @@ const providers: Readonly<Record<string, Provider>> = {
claude: claudeProvider,
codex: codexProvider,
cursor: cursorProvider,
devin: devinProvider,
grok: grokProvider,
mock: mockProvider,
"mock-fail": mockFailProvider,
Expand All @@ -39,6 +41,7 @@ export const __testing = {
...claudeTesting,
...codexTesting,
...cursorTesting,
...devinTesting,
...opencodeTesting,
...piTesting,
providerExitCode,
Expand Down
158 changes: 158 additions & 0 deletions src/providers/devin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCommandArgs } from "../exec.js";
import { ClawpatchError } from "../errors.js";
import { providerExitCode } from "../provider-errors.js";
import { extractJson, safeProviderPreview } from "../provider-json.js";
import { parseOrThrow, parseReviewOutput } from "../provider-output.js";
import { providerCheckTimeoutMs, providerTimeoutMs } from "../provider-runtime.js";
import {
agentMapJsonSchema,
fixPlanJsonSchema,
reviewJsonSchema,
revalidateJsonSchema,
} from "../provider-schema.js";
import type { PartitionedReviewOutput, Provider, ProviderOptions } from "../provider-types.js";
import {
AgentMapOutput,
FixPlanOutput,
RevalidateOutput,
agentMapOutputSchema,
fixPlanOutputSchema,
revalidateOutputSchema,
} from "../types.js";

const DEVIN_DEFAULT_TIMEOUT_MS = 300_000;

// Devin CLI permission modes (verified against `devin --help`, v3000.2.17):
// auto — auto-approves read-only tools only; prompts for writes (print mode never
// grants, so this is effective read-only enforcement for review/revalidate)
// accept-edits — auto-approves workspace edits in addition to read-only tools (used for fix)
const DEVIN_READ_PERMISSION_MODE = "auto";
const DEVIN_WRITE_PERMISSION_MODE = "accept-edits";

export const devinProvider: Provider = {
name: "devin",
async check(root: string): Promise<string> {
const result = await runCommandArgs("devin", ["--version"], root, undefined, {
timeoutMs: providerCheckTimeoutMs(),
});
if (result.exitCode !== 0) {
throw new ClawpatchError(
"devin CLI not available. Install from https://docs.devin.ai/cli",
4,
"provider-auth",
);
}
return result.stdout.trim() || result.stderr.trim();
},
async map(root: string, prompt: string, options: ProviderOptions): Promise<AgentMapOutput> {
const output = await runDevinJson(root, prompt, options, agentMapJsonSchema, true);
return parseOrThrow(agentMapOutputSchema, output, "devin agent-map");
},
async review(
root: string,
prompt: string,
options: ProviderOptions,
): Promise<PartitionedReviewOutput> {
const output = await runDevinJson(root, prompt, options, reviewJsonSchema, true);
return parseReviewOutput(output);
},
async fix(root: string, prompt: string, options: ProviderOptions): Promise<FixPlanOutput> {
const output = await runDevinJson(root, prompt, options, fixPlanJsonSchema, false);
return parseOrThrow(fixPlanOutputSchema, output, "devin fix-plan");
},
async revalidate(
root: string,
prompt: string,
options: ProviderOptions,
): Promise<RevalidateOutput> {
const output = await runDevinJson(root, prompt, options, revalidateJsonSchema, true);
return parseOrThrow(revalidateOutputSchema, output, "devin revalidate");
},
};

async function runDevinJson(
root: string,
prompt: string,
options: ProviderOptions,
schema: object,
readOnly: boolean,
): Promise<unknown> {
const dir = await mkdtemp(join(tmpdir(), "clawpatch-devin-"));
const promptPath = join(dir, "prompt.txt");
try {
await writeFile(promptPath, devinPrompt(prompt, schema, options.reasoningEffort), "utf8");
const args = [
"--print",
"--prompt-file",
promptPath,
"--permission-mode",
readOnly ? DEVIN_READ_PERMISSION_MODE : DEVIN_WRITE_PERMISSION_MODE,
];
if (options.model !== null) {
args.push("--model", options.model);
}
const result = await runCommandArgs("devin", args, root, undefined, {
trimOutput: false,
timeoutMs: devinTimeoutMs(),
});
if (result.exitCode !== 0) {
throw new ClawpatchError(
devinFailureMessage(result.stdout, result.stderr),
providerExitCode(result.stdout, result.stderr),
"provider-failure",
);
}
const json = extractJson(result.stdout);
if (json === null) {
throw new ClawpatchError(
`devin provider produced unparseable JSON output (preview: ${safeProviderPreview(result.stdout)})`,
8,
"malformed-output",
);
}
return json;
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}

function devinPrompt(prompt: string, schema: object, reasoningEffort: string | null): string {
const effortPreamble =
reasoningEffort !== null && reasoningEffort !== "none"
? `REASONING EFFORT: ${reasoningEffort.toUpperCase()}\n` +
`Apply ${reasoningEffort} reasoning depth to this review. ` +
`At "high" or "xhigh", exhaustively analyze every file, trace data flows, ` +
`and consider edge cases that lower-effort passes would miss.\n\n`
: "";
return (
`${effortPreamble}${prompt}\n\n` +
"Return ONLY a JSON object matching this schema. No prose preamble, no markdown fences, " +
"no thinking-out-loud text before the JSON. " +
`Schema:\n${JSON.stringify(schema)}\n`
);
}

function devinFailureMessage(stdout: string, stderr: string): string {
const stderrPreview = safeProviderPreview(stderr);
if (stderrPreview.length > 0) {
return `devin provider failed: ${stderrPreview}`;
}
const stdoutPreview = safeProviderPreview(stdout);
if (stdoutPreview.length > 0) {
return `devin provider failed: ${stdoutPreview}`;
}
return "devin provider failed";
}

function devinTimeoutMs(): number {
return providerTimeoutMs("CLAWPATCH_DEVIN_TIMEOUT_MS", DEVIN_DEFAULT_TIMEOUT_MS);
}

export const devinTesting = {
devinFailureMessage,
devinPrompt,
devinTimeoutMs,
};