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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,10 @@ Supported provider names today:

- `codex`: local Codex CLI
- `acpx`: any ACP-compatible coding agent (Codex / Claude / Pi / Gemini / ...) via openclaw/acpx
- `claude`: local Claude Code CLI in print mode
- `grok`: local Grok Build CLI
- `opencode`: local OpenCode CLI
- `pi`: local Pi coding agent in print mode
- `mock`: deterministic test provider
- `mock-fail`: failure test provider

Expand Down
62 changes: 62 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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`)
- `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 @@ -105,6 +106,67 @@ 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`.

## Claude

The `claude` provider shells out to the local
[Claude Code CLI](https://code.claude.com/docs/en/cli-usage) in non-interactive
print mode.

Install Claude Code and authenticate with an Anthropic API key:

```bash
export ANTHROPIC_API_KEY=sk-ant-...
claude --version
```

Provider selection:

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

For low-cost smoke checks, pass a smaller model explicitly:

```bash
clawpatch review --provider claude --model claude-haiku-4-5-20251001 --limit 1
```

How the Claude provider works:

- Doctor: `clawpatch doctor --provider claude` only checks that the Claude Code
binary is available, reads `claude --version`, and blocks known vulnerable
versions. It does not validate auth or make a network call; auth failures are
reported on the first provider-backed command.
- Auth/isolation: provider runs use `--bare` with a default-deny environment.
Clawpatch forwards only minimal execution variables and `ANTHROPIC_API_KEY`;
it does not pass host `HOME`, OAuth/keychain state, or whole Claude
config/cache directories.
- Structured output: provider runs use `--output-format json --json-schema`
and parse the returned `structured_output` field.
- Read-only operations (map, review, revalidate): use
`--tools "Read,Grep,Glob" --permission-mode dontAsk`.
- Write operation (fix): uses Claude's default tool set with
`--permission-mode acceptEdits`. Clawpatch still relies on its existing clean
worktree preflight before `fix`.
- Ambient config isolation: runs add `--strict-mcp-config` with an empty MCP
configuration, `--disable-slash-commands`, and `--no-chrome`.
- Model selection: `--model <model>` is passed through to Claude.
- Reasoning effort: `low`, `medium`, `high`, and `xhigh` are passed as
`--effort`. Clawpatch `minimal` maps to Claude `low`; Clawpatch `none` is
treated as no override because Claude does not accept `--effort none`.
- `skipGitRepoCheck`: Claude has no equivalent flag, so this option is a no-op
for the Claude provider.
- Timeout: 180 seconds by default, override with `CLAWPATCH_CLAUDE_TIMEOUT_MS`
or `CLAWPATCH_PROVIDER_TIMEOUT_MS`.

Permission caveat: Claude tool restrictions are enforced by Claude Code. For
write operations during `fix`, Claude may edit the current worktree. For
untrusted code, run `clawpatch fix --provider claude` inside an isolated
checkout.

## Grok

The `grok` provider shells out to the local [Grok Build CLI](https://x.ai/cli).
Expand Down
9 changes: 5 additions & 4 deletions docs/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ Current safety rules:
- `fix` refuses a dirty source worktree by default.
- `.clawpatch/` state changes are allowed during runs.
- review and revalidate provider calls use a read-only sandbox for the `codex`
provider. The `acpx` provider relies on `acpx --approve-reads` plus an explicit
read-only prompt directive; underlying agents that bypass ACP permissions (e.g.
agents running in their own full-access mode) may not be strictly sandboxed.
See docs/providers.md.
provider. The `claude` provider restricts available tools to read/search tools
for read-only operations. The `acpx` provider relies on `acpx --approve-reads`
plus an explicit read-only prompt directive; underlying agents that bypass ACP
permissions (e.g. agents running in their own full-access mode) may not be
strictly sandboxed. See docs/providers.md.
- provider output must pass runtime schema validation.
- feature locks are stored in feature records and `.clawpatch/locks/`; `status`
surfaces both, and `clean-locks` clears both.
Expand Down
36 changes: 35 additions & 1 deletion src/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,41 @@ import { access, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { runCommandArgs } from "./exec.js";
import { runCommand, runCommandArgs } from "./exec.js";

describe("runCommand", () => {
it("runs a shell command and passes stdin", async () => {
const dir = await mkdtemp(join(tmpdir(), "clawpatch-exec-shell-"));
const script = join(dir, "stdin.mjs");
await writeFile(
script,
"process.stdin.setEncoding('utf8'); let input = ''; process.stdin.on('data', (chunk) => { input += chunk; }); process.stdin.on('end', () => process.stdout.write(input.toUpperCase()));",
"utf8",
);

const result = await runCommand(
`${JSON.stringify(process.execPath)} ${JSON.stringify(script)}`,
dir,
"ok",
);

expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("OK");
});

it("trims large output by default and can preserve raw output", async () => {
const dir = await mkdtemp(join(tmpdir(), "clawpatch-exec-shell-"));
const script = join(dir, "large-output.mjs");
await writeFile(script, "process.stdout.write('x'.repeat(9000));", "utf8");
const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(script)}`;

const trimmed = await runCommand(command, dir);
const raw = await runCommand(command, dir, undefined, { trimOutput: false });

expect(trimmed.stdout).toContain("...[trimmed]...");
expect(raw.stdout).toHaveLength(9000);
});
});

describe("runCommandArgs", () => {
it("passes paths with spaces and quotes without shell quoting", async () => {
Expand Down
26 changes: 22 additions & 4 deletions src/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,19 @@ export async function runCommandRaw(
});
let spawnErrorMessage: string | null = null;
const exitCodePromise = new Promise<number | null>((resolve) => {
let settled = false;
const finish = (code: number | null): void => {
if (settled) {
return;
}
settled = true;
resolve(code);
};
child.on("error", (error: Error) => {
spawnErrorMessage = error.message;
resolve(127);
finish(127);
});
child.on("close", resolve);
child.on("close", finish);
});
endChildStdin(child, input);
const exitCode = await exitCodePromise;
Expand All @@ -66,13 +74,23 @@ export async function runCommandArgs(
args: string[],
cwd: string,
input?: string,
options: { trimOutput?: boolean; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
options: {
trimOutput?: boolean;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
replaceEnv?: boolean;
} = {},
): Promise<CommandResult> {
const started = Date.now();
const spawnSpec = commandSpawnSpec(program, args);
const child = spawn(spawnSpec.program, spawnSpec.args, {
cwd,
env: options.env === undefined ? process.env : { ...process.env, ...options.env },
env:
options.env === undefined
? process.env
: options.replaceEnv === true
? options.env
: { ...process.env, ...options.env },
detached: process.platform !== "win32" && options.timeoutMs !== undefined,
shell: false,
stdio: ["pipe", "pipe", "pipe"],
Expand Down
Loading