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
20 changes: 19 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,11 @@ const agentTaskRecoverySchema = z.object({
cacheEntries: z.number().int().min(1).max(512).optional(),
}).strict();

const noProxySchema = z.union([
z.string(),
z.array(z.string()),
]);

const configSchema = z.object({
port: z.number().int().min(0).max(65535).default(10100),
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
Expand Down Expand Up @@ -879,6 +884,9 @@ const configSchema = z.object({
providers: z.record(z.string(), providerConfigSchema),
defaultProvider: z.string().min(1).default("openai"),
defaultModelAliases: z.boolean().optional(),
// A malformed hand edit must not make startup fail before the listener binds.
// Live writes remain strict through noProxyError().
noProxy: noProxySchema.optional().catch(undefined),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve valid bypasses in mixed persisted arrays

When a hand-edited noProxy array contains both a valid bypass and one malformed element (for example, ["internal.example", 1]), z.array(z.string()) fails and .catch(undefined) discards the entire field. Consequently, loadConfig() removes internal.example before applyProxyEnv() can perform its new element-level filtering, so requests intended to bypass the proxy are routed through it. Salvage string elements individually on the read path while retaining noProxyError() for strict live writes, and cover the mixed persisted-array case.

Useful? React with 👍 / 👎.

// Future versions remain opaque through passthrough-compatible whole-config saves.
// Only version 1 grants deletion authority in the rebase path.
configRebaseProvenance: z.unknown().optional(),
Expand Down Expand Up @@ -2132,6 +2140,13 @@ function oauthOpenBrowserError(value: unknown): string | null {
return "schema_invalid: oauthOpenBrowser: must be a boolean or omitted";
}

function noProxyError(value: unknown): string | null {
const raw = rawConfigRecord(value);
if (!raw || !Object.hasOwn(raw, "noProxy") || raw.noProxy === undefined) return null;
if (noProxySchema.safeParse(raw.noProxy).success) return null;
return "schema_invalid: noProxy: must be a string, an array of strings, or omitted";
}

/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
/**
* Reject a loopback-listener port that collides with the proxy port (#1102).
Expand Down Expand Up @@ -2182,6 +2197,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
?? codexAccountPickerEnabledError(value)
?? emptyCompletionRetryError(value)
?? oauthOpenBrowserError(value)
?? noProxyError(value)
?? loopbackListenerPortError(value);
if (boundaryError) return { ok: false, error: boundaryError };
const result = configSchema.safeParse(value);
Expand Down Expand Up @@ -3127,7 +3143,9 @@ export function applyProxyEnv(config: OcxConfig): void {
// Configured entries first, then loopback: loopback is unconditional, so appending it last
// keeps it present even when the operator lists a loopback host themselves.
const raw = config.noProxy;
const configured = (Array.isArray(raw) ? raw : (resolveEnvValue(raw) ?? "").split(","))
const configured = (Array.isArray(raw)
? raw.filter((entry): entry is string => typeof entry === "string")
: (typeof raw === "string" ? (resolveEnvValue(raw) ?? "").split(",") : []))
.map(entry => entry.trim())
.filter(Boolean);
for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) {
Expand Down
22 changes: 22 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,28 @@ describe("opencodex config defaults", () => {
});
});

test("noProxy accepts only strings and string arrays in live writes", () => {
const defaults = getDefaultConfig();
expect(validateConfigCandidate({ ...defaults, noProxy: "internal.example" }).ok).toBe(true);
expect(validateConfigCandidate({ ...defaults, noProxy: ["internal.example"] }).ok).toBe(true);
for (const noProxy of [[1], { host: "internal.example" }, 1, null]) {
const result = validateConfigCandidate({ ...defaults, noProxy });
expect(result).toMatchObject({
ok: false,
error: expect.stringContaining("noProxy"),
});
}
});

test("a malformed persisted noProxy value is ignored without discarding the config", () => {
const config = { ...getDefaultConfig(), proxy: "http://proxy.corp:8080", noProxy: [1] };
writeFileSync(getConfigPath(), JSON.stringify(config));
const loaded = loadConfig();
expect(loaded.proxy).toBe("http://proxy.corp:8080");
expect(loaded.noProxy).toBeUndefined();
expect(loaded.providers.openai).toBeDefined();
});

test("usage and MCP config overrides change the effective bound while defaults remain compatible", () => {
const defaults = getDefaultConfig();
expect(defaults.managementUsageMaxReadBytes).toBe(64 * 1024 * 1024);
Expand Down
7 changes: 7 additions & 0 deletions tests/proxy-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ describe("applyProxyEnv", () => {
expect(process.env.NO_PROXY).toBe("internal.example,10.0.0.0/8,localhost,127.0.0.1,::1,[::1]");
});

test("ignores malformed noProxy values at the runtime boundary", () => {
const config = configWithProxy("http://proxy.corp:8080") as unknown as Record<string, unknown>;
config.noProxy = [1, "internal.example"];
applyProxyEnv(config as unknown as OcxConfig);
expect(process.env.NO_PROXY).toBe("internal.example,localhost,127.0.0.1,::1,[::1]");
});

test("mirrors config.proxy into HTTP(S)_PROXY and excludes loopback (IPv4 + IPv6)", () => {
applyProxyEnv(configWithProxy("http://proxy.corp:8080"));
expect(process.env.HTTP_PROXY).toBe("http://proxy.corp:8080");
Expand Down
Loading