Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 0.5.2 - Unreleased

- Added trusted Codex CLI config passthrough for explicit config files while rejecting repository-controlled passthrough config, thanks @brad-ai-agent.
- Added a MiniMax HTTP provider for `map`, `review`, and `revalidate`, with local schema validation and explicit unsupported `fix` handling, thanks @ferminquant.

## 0.5.1 - 2026-06-10
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ Clawpatch. Use any Codex sandbox mode, or `bypass`/`none` to pass
`--dangerously-bypass-approvals-and-sandbox` when the host environment already
provides isolation.

Trusted config loaded with `--config` or `CLAWPATCH_CONFIG` can pass primitive
Codex CLI config through `provider.codexConfig`. Repository-discovered config
files cannot set this field because Codex config can affect provider routing
and credential lookup.

Supported provider names today:

- `codex`: local Codex CLI
Expand Down
10 changes: 9 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ Default shape:
"provider": {
"name": "codex",
"model": null,
"reasoningEffort": null
"reasoningEffort": null,
"codexConfig": {}
},
"commands": {
"typecheck": null,
Expand Down Expand Up @@ -72,5 +73,12 @@ Environment overrides:
- `CLAWPATCH_MODEL`
- `CLAWPATCH_REASONING_EFFORT`

`provider.codexConfig` passes primitive values to Codex as `-c key=value`.
Only config loaded by `--config` or `CLAWPATCH_CONFIG` may set non-empty
Codex passthrough config. Auto-discovered repository and state config files
are rejected if they set it, because Codex config can change provider routing
and credential lookup. Keep secrets out of config files; use Codex provider
settings such as `env_key` to read an already-exported environment variable.

`git.commit` and `git.openPr` are reserved config fields. The current CLI does
not commit or open PRs.
26 changes: 26 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,32 @@ When `reasoningEffort` is unset, Clawpatch does not pass a reasoning override
and Codex uses its own configured default. Explicit values are passed to Codex
as `model_reasoning_effort`.

Trusted Codex CLI config passthrough:

```json
{
"provider": {
"name": "codex",
"model": null,
"reasoningEffort": null,
"codexConfig": {
"model_provider": "local",
"model_providers.local.base_url": "https://example.invalid/v1",
"model_providers.local.env_key": "CLAWPATCH_CODEX_API_KEY"
}
}
}
```

Load a config like this with `clawpatch --config trusted-config.json ...` or
`CLAWPATCH_CONFIG=trusted-config.json`. Clawpatch rejects non-empty
`provider.codexConfig` from auto-discovered repository or state config files so
a checkout cannot silently redirect Codex provider routing or credential lookup.
Values are limited to strings, finite numbers, booleans, and `null`, then passed
as repeated `-c key=value` arguments before `--model` and reasoning overrides.
Do not place raw secrets in `codexConfig`; point Codex at an explicit env var
instead.

## OpenCode

The `opencode` provider shells out to the local [OpenCode CLI](https://opencode.ai/docs/cli/).
Expand Down
21 changes: 19 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,14 @@ export async function initCommand(
const paths = statePaths(stateDir);
await ensureStateDirs(paths);
const project = await detectProject(context.root);
const detectedConfig = { ...config, commands: project.detected.commands };
const detectedConfig = {
...config,
provider: {
...config.provider,
codexConfig: {},
},
commands: project.detected.commands,
};
const previous = await readProject(paths);
if (previous !== null && flags["force"] !== true) {
throw new ClawpatchError("project already initialized; use --force", 2, "already-initialized");
Expand Down Expand Up @@ -1348,7 +1355,16 @@ export async function doctorCommand(
context: AppContext,
flags: Record<string, string | boolean> = {},
): Promise<unknown> {
const loaded = await loadProjectState(context).catch(() => null);
let loaded: Awaited<ReturnType<typeof loadProjectState>> | null;
try {
loaded = await loadProjectState(context);
} catch (error) {
if (error instanceof ClawpatchError && error.code === "not-initialized") {
loaded = null;
} else {
throw error;
}
}
const root = loaded?.root ?? context.root;
const providerName =
stringFlag(flags, "provider") ??
Expand Down Expand Up @@ -1959,6 +1975,7 @@ function providerOptions(config: ReturnType<typeof applyProviderFlags>) {
return {
model: config.provider.model,
reasoningEffort: config.provider.reasoningEffort,
codexConfig: config.provider.codexConfig,
skipGitRepoCheck: config.provider.skipGitRepoCheck,
};
}
Expand Down
94 changes: 94 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { join } from "node:path";
import { defaultConfig, loadConfig } from "./config.js";
import { fixtureRoot, testOptions, writeFixture } from "./test-helpers.js";

const originalConfig = process.env["CLAWPATCH_CONFIG"];
const originalStateDir = process.env["CLAWPATCH_STATE_DIR"];

beforeEach(() => {
delete process.env["CLAWPATCH_CONFIG"];
delete process.env["CLAWPATCH_STATE_DIR"];
});

afterEach(() => {
if (originalConfig === undefined) {
delete process.env["CLAWPATCH_CONFIG"];
} else {
process.env["CLAWPATCH_CONFIG"] = originalConfig;
}
if (originalStateDir === undefined) {
delete process.env["CLAWPATCH_STATE_DIR"];
} else {
process.env["CLAWPATCH_STATE_DIR"] = originalStateDir;
}
});

function configWithCodexPassthrough() {
return {
...defaultConfig(),
provider: {
...defaultConfig().provider,
codexConfig: {
model_provider: "openai",
"model_providers.openai.env_key": "OPENAI_API_KEY",
},
},
};
}

describe("loadConfig", () => {
it("defaults Codex passthrough config to an empty object", async () => {
const root = await fixtureRoot("clawpatch-default-config-");

const config = await loadConfig(root, testOptions(root));

expect(config.provider.codexConfig).toEqual({});
});

it("rejects Codex passthrough config from project config", async () => {
const root = await fixtureRoot("clawpatch-project-codex-config-");
await writeFixture(root, "clawpatch.config.json", JSON.stringify(configWithCodexPassthrough()));

await expect(loadConfig(root, testOptions(root))).rejects.toThrow(
/provider\.codexConfig may only be set/u,
);
});

it("rejects Codex passthrough config from state-dir config", async () => {
const root = await fixtureRoot("clawpatch-state-codex-config-root-");
const stateDir = await fixtureRoot("clawpatch-state-codex-config-");
await writeFixture(stateDir, "config.json", JSON.stringify(configWithCodexPassthrough()));

await expect(loadConfig(root, { ...testOptions(root), stateDir })).rejects.toThrow(
/provider\.codexConfig may only be set/u,
);
});

it("accepts Codex passthrough config from --config", async () => {
const root = await fixtureRoot("clawpatch-explicit-codex-config-");
const configPath = join(root, "trusted-config.json");
await writeFixture(root, "trusted-config.json", JSON.stringify(configWithCodexPassthrough()));

const config = await loadConfig(root, { ...testOptions(root), config: configPath });

expect(config.provider.codexConfig).toEqual({
model_provider: "openai",
"model_providers.openai.env_key": "OPENAI_API_KEY",
});
});

it("accepts Codex passthrough config from CLAWPATCH_CONFIG", async () => {
const root = await fixtureRoot("clawpatch-env-codex-config-");
const configPath = join(root, "trusted-config.json");
await writeFixture(root, "trusted-config.json", JSON.stringify(configWithCodexPassthrough()));
process.env["CLAWPATCH_CONFIG"] = configPath;

const config = await loadConfig(root, testOptions(root));

expect(config.provider.codexConfig).toEqual({
model_provider: "openai",
"model_providers.openai.env_key": "OPENAI_API_KEY",
});
});
});
51 changes: 41 additions & 10 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ export type GlobalOptions = {
noInput: boolean;
};

type ConfigSource = "option" | "env" | "state-dir" | "project" | "state";

type ConfigDiscovery = {
path: string;
source: ConfigSource;
};

export const defaultCommands: ProjectCommands = {
typecheck: null,
lint: null,
Expand All @@ -47,6 +54,7 @@ export function defaultConfig(): ClawpatchConfig {
name: "codex",
model: null,
reasoningEffort: null,
codexConfig: {},
},
commands: defaultCommands,
review: {
Expand All @@ -67,8 +75,9 @@ export function defaultConfig(): ClawpatchConfig {
}

export async function loadConfig(root: string, options: GlobalOptions): Promise<ClawpatchConfig> {
const configPath = await discoverConfigPath(root, options);
const base = configPath === null ? defaultConfig() : await readJson(configPath, configSchema);
const discovery = await discoverConfigPath(root, options);
const base = discovery === null ? defaultConfig() : await readJson(discovery.path, configSchema);
assertTrustedCodexConfig(base, discovery?.source ?? null);
return {
...base,
stateDir: options.stateDir ?? process.env["CLAWPATCH_STATE_DIR"] ?? base.stateDir,
Expand Down Expand Up @@ -102,23 +111,45 @@ function parseReasoningEffort(value: string | undefined) {
);
}

async function discoverConfigPath(root: string, options: GlobalOptions): Promise<string | null> {
function assertTrustedCodexConfig(config: ClawpatchConfig, source: ConfigSource | null): void {
if (Object.keys(config.provider.codexConfig).length === 0) {
return;
}
if (source === "option" || source === "env") {
return;
}
throw new ClawpatchError(
"provider.codexConfig may only be set from --config or CLAWPATCH_CONFIG; repository and state config cannot control Codex provider settings",
2,
"invalid-usage",
);
}

async function discoverConfigPath(
root: string,
options: GlobalOptions,
): Promise<ConfigDiscovery | null> {
if (options.config !== undefined) {
return resolve(options.config);
return { path: resolve(options.config), source: "option" };
}
if (process.env["CLAWPATCH_CONFIG"] !== undefined) {
return resolve(process.env["CLAWPATCH_CONFIG"]);
return { path: resolve(process.env["CLAWPATCH_CONFIG"]), source: "env" };
}
const configuredStateDir = options.stateDir ?? process.env["CLAWPATCH_STATE_DIR"];
const candidates = [
const candidates: ConfigDiscovery[] = [
...(configuredStateDir === undefined
? []
: [join(resolve(root, configuredStateDir), "config.json")]),
join(root, "clawpatch.config.json"),
join(root, ".clawpatch", "config.json"),
: [
{
path: join(resolve(root, configuredStateDir), "config.json"),
source: "state-dir" as const,
},
]),
{ path: join(root, "clawpatch.config.json"), source: "project" },
{ path: join(root, ".clawpatch", "config.json"), source: "state" },
];
for (const candidate of candidates) {
if (await pathExists(candidate)) {
if (await pathExists(candidate.path)) {
return candidate;
}
}
Expand Down
56 changes: 55 additions & 1 deletion src/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
acpxFailureMessage,
assertCursorRuntimeVersionAllowed,
acpxPromptRetries,
addCodexConfigArgs,
addCodexModelArgs,
addCodexSandboxArgs,
assertClaudeVersionAllowed,
Expand Down Expand Up @@ -295,10 +296,63 @@ describe("Codex provider args", () => {
addCodexModelArgs(args, {
model: "gpt-5.5",
reasoningEffort: "xhigh",
codexConfig: {
model_provider: "local",
"model_providers.local.base_url": "https://example.invalid/v1",
},
skipGitRepoCheck: false,
});

expect(args).toEqual(["exec", "--model", "gpt-5.5", "-c", 'model_reasoning_effort="xhigh"']);
expect(args).toEqual([
"exec",
"-c",
'model_provider="local"',
"-c",
'model_providers.local.base_url="https://example.invalid/v1"',
"--model",
"gpt-5.5",
"-c",
'model_reasoning_effort="xhigh"',
]);
});

it("renders primitive Codex passthrough values in stable key order", () => {
const args = ["exec"];

addCodexConfigArgs(args, {
z_flag: true,
model_provider: "local",
"model_providers.local.max_retries": 2,
"model_providers.local.optional": null,
});

expect(args).toEqual([
"exec",
"-c",
'model_provider="local"',
"-c",
"model_providers.local.max_retries=2",
"-c",
"model_providers.local.optional=null",
"-c",
"z_flag=true",
]);
});

it("rejects unsafe Codex passthrough keys", () => {
const args = ["exec"];

expect(() => addCodexConfigArgs(args, { "model provider": "local" })).toThrow(
/invalid Codex config key/u,
);
});

it("rejects non-finite Codex passthrough numbers", () => {
const args = ["exec"];

expect(() => addCodexConfigArgs(args, { retries: Number.NaN })).toThrow(
/finite number required/u,
);
});

it("passes the Git repo check bypass to Codex when requested", () => {
Expand Down
Loading