diff --git a/src/config.ts b/src/config.ts index 1308b3a64b..e7dc929958 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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), @@ -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), // Future versions remain opaque through passthrough-compatible whole-config saves. // Only version 1 grants deletion authority in the rebase path. configRebaseProvenance: z.unknown().optional(), @@ -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). @@ -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); @@ -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]"]) { diff --git a/tests/config.test.ts b/tests/config.test.ts index 6ebae31319..8a30774ad9 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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); diff --git a/tests/proxy-env.test.ts b/tests/proxy-env.test.ts index f58ba4055d..8044b2ad95 100644 --- a/tests/proxy-env.test.ts +++ b/tests/proxy-env.test.ts @@ -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; + 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");