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
25 changes: 20 additions & 5 deletions src/cli/config-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-
import type { OcxConfig } from "../types";
import { normalizeVisionReasoningForModel } from "../vision/reasoning";
import type { ServiceApiTokenState } from "../lib/service-secrets";
import { redactUrlForLog } from "../lib/redact";
import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";

const USAGE = `Usage:
Expand All @@ -17,12 +18,13 @@ const USAGE = `Usage:
ocx config import <path|-> --yes [--json]`;

/**
* Keys whose VALUE is a credential and must never be printed or exported.
* Keys whose VALUE is a credential and must never be printed by display commands.
* `config export` writes the raw config so an export can restore credentials; it does
* not call `redact`.
*
* `webhookUrl` is here because for Slack and Discord the URL itself is the authorization:
* anyone holding it can post to the channel. It looks like configuration rather than a secret,
* which is exactly why it needs to be named explicitly — none of the other patterns match it,
* so `ocx config show` printed it and `config export` wrote it to disk in the clear.
* URL-valued credentials must be named explicitly: `webhookUrl` matches none of the
* other patterns, and a proxy URL's userinfo is handled by the `proxy` branch in
* `redact` rather than by masking the whole value.
*/
const SECRET_KEYS = /^(apiKey|key|accessToken|refreshToken|idToken|token|password|clientSecret|webhookUrl)$/i;
const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
Expand Down Expand Up @@ -97,6 +99,19 @@ async function readRemoteHubConfigNote(config: OcxConfig): Promise<ReturnType<ty
}

function redact(value: unknown, key = ""): unknown {
if (key === "proxy" && typeof value === "string") {
// "direct" and credential-less proxy URLs carry no secret and stay readable; only a
// URL with userinfo is masked, and then only its credentials — host and port stay
// visible so the output still says WHERE traffic goes. A non-URL value that is not
// "direct" cannot be proven credential-free, so it is masked whole.
if (!value || value === "direct") return value;
try {
const parsed = new URL(value);
return parsed.username || parsed.password ? redactUrlForLog(value) : value;
} catch {
return "********";
}
}
if (SECRET_KEYS.test(key) && typeof value === "string") return value ? "********" : value;
// `client.priorCatalog` is the base64 catalog snapshot connect took before overwriting the
// local one — up to 64 MB of it (src/config.ts). Printed in full it buried `runtimeRole` and
Expand Down
4 changes: 4 additions & 0 deletions structure/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,3 +552,7 @@ exclusive `--socks5-off`. The start owner persists only an explicitly requested
change; the off flag refuses to erase a non-SOCKS proxy. Invalid-address errors
never echo user-supplied credentials, and status messages redact proxy URLs.
The parser regression cases live in `tests/cli/start-args.test.ts`.
The config CLI masks credential-bearing `proxy` URLs in show, get, and mutation output:
userinfo is stripped while host and port stay visible, `direct` and credential-less values
print unchanged, and a non-URL value that is not `direct` is masked whole. `config export`
keeps the raw file so exports can restore credentials.
42 changes: 42 additions & 0 deletions tests/cli/cli-config-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,22 @@ function freshConfig() {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward",
proxy: "http://route_user:route_password@egress.test:3128",
},
blsc: {
adapter: "openai-chat",
baseUrl: "https://llmapi.blsc.cn",
proxy: "direct",
modelCosts: {
"deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
"sk-abcdef1234567890": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 },
},
},
nocreds: {
adapter: "openai-chat",
baseUrl: "https://nocreds.example",
proxy: "http://127.0.0.1:7890",
},
},
defaultProvider: "openai",
};
Expand All @@ -48,6 +55,41 @@ function freshConfig() {
}

describe("ocx config display redaction", () => {
test("provider proxy credentials stay masked in show, get, and set output", () => {
const dir = freshConfig();
const secret = "route_password";
try {
const show = runCli(["config", "show", "--json"], { OPENCODEX_HOME: dir });
expect(show.status).toBe(0);
expect(show.stdout).not.toContain(secret);
const shown = JSON.parse(show.stdout).providers;
// Credentialed URL: userinfo stripped, host/port kept for diagnostics.
expect(shown.openai.proxy).toBe("http://egress.test:3128/");
expect(show.stdout).not.toContain("route_user");
// "direct" and credential-less URLs carry no secret and stay readable.
expect(shown.blsc.proxy).toBe("direct");
expect(shown.nocreds.proxy).toBe("http://127.0.0.1:7890");

const get = runCli(["config", "get", "providers.openai.proxy"], { OPENCODEX_HOME: dir });
expect(get.status).toBe(0);
expect(get.stdout.trim()).toBe("http://egress.test:3128/");

const getDirect = runCli(["config", "get", "providers.blsc.proxy"], { OPENCODEX_HOME: dir });
expect(getDirect.status).toBe(0);
expect(getDirect.stdout.trim()).toBe("direct");

const set = runCli([
"config", "set", "providers.openai.proxy",
"http://next_user:next_password@egress.test:8080", "--json",
], { OPENCODEX_HOME: dir });
expect(set.status).toBe(0);
expect(set.stdout).not.toContain("next_password");
expect(JSON.parse(set.stdout).value).toBe("http://egress.test:8080/");
} finally {
removeTreeWithRetry(dir);
}
});

test("config show --json never prints secret-shaped modelCosts keys", () => {
const dir = freshConfig();
try {
Expand Down
Loading