From a6521f07ed46bd28d94cd8ff10348e22317f3707 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 2 Aug 2026 06:53:18 +0000 Subject: [PATCH] fix(auth): guarded config-value resolvers against case-insensitive env hijack On Windows process.env/Bun.env lookups are case-insensitive, so the "env var name, else literal" resolvers turned a literal /login key like `public` (OpenCode Zen's free key) into the built-in PUBLIC=C:\Users\Public, sending `Authorization: Bearer C:\Users\Public` and 401ing every request. Added `$envExact` in pi-utils, which trusts an env lookup only when an exact-case key is enumerated (the only case-preserving signal on Windows; the getter and hasOwnProperty/getOwnPropertyDescriptor traps are all case-insensitive there). Wired it into all three resolvers: resolve-config-value.ts, model-registry.ts, and auth-storage.ts. Fixes #7361 --- packages/ai/src/auth-storage.ts | 4 +- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/config/model-registry.ts | 4 +- .../src/config/resolve-config-value.ts | 3 +- packages/utils/src/env.ts | 29 ++++++++ packages/utils/test/env.test.ts | 68 ++++++++++++++++++- 6 files changed, 106 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index f3e5e3283..e1c76bda2 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -12,7 +12,7 @@ import { createHash } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; -import { $env, getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils"; +import { $env, $envExact, getAgentDbPath, getDbBusyTimeoutMs, logger } from "@oh-my-pi/pi-utils"; import type { ApiKeyResolver } from "./auth-retry"; import * as AIError from "./error"; import { isUsageLimitOutcome } from "./error/rate-limit"; @@ -643,7 +643,7 @@ export type AuthStorageOptions = { * Does NOT support "!command" syntax (that requires pi-natives). */ async function defaultConfigValueResolver(config: string): Promise { - const envValue = process.env[config]; + const envValue = $envExact(config); return envValue || config; } diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 11d4830cc..43ebe41c3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed a literal API key configured via `/login` (e.g. OpenCode Zen's free `public` key) being hijacked on Windows by a case-differing system environment variable, causing 401s. `process.env`/`Bun.env` reads are case-insensitive on Windows, so the config-value resolvers' "env var name, else literal" fallback resolved `public` to the built-in `PUBLIC=C:\Users\Public`. Resolution now requires an exact-case env entry (via the new `$envExact` helper) before treating a value as an env-var reference ([#7361](https://github.com/can1357/oh-my-pi/issues/7361)). + ## [17.2.4] - 2026-08-01 ### Added diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 99f65e8de..825fd974e 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -73,7 +73,7 @@ import { inheritReferenceThinking, resolveModelReference, } from "@oh-my-pi/pi-catalog/identity"; -import { isBunTestRuntime, isRecord, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils"; +import { $envExact, isBunTestRuntime, isRecord, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils"; import { parseModelString, resolveProviderModelReference } from "../config/model-resolver"; import { generateCodexAttestation } from "../live/attestation"; import type { AuthStorage, OAuthCredential } from "../session/auth-storage"; @@ -329,7 +329,7 @@ interface CommandApiKeyResolution { */ function resolveConfigValue(valueConfig: string): string | undefined { if (valueConfig.startsWith("!")) return resolveCommandConfig(valueConfig.slice(1).trim()); - const envValue = Bun.env[valueConfig]; + const envValue = $envExact(valueConfig); if (envValue) return envValue; return valueConfig; } diff --git a/packages/coding-agent/src/config/resolve-config-value.ts b/packages/coding-agent/src/config/resolve-config-value.ts index 242f06ddc..d4b2c958d 100644 --- a/packages/coding-agent/src/config/resolve-config-value.ts +++ b/packages/coding-agent/src/config/resolve-config-value.ts @@ -5,6 +5,7 @@ */ import { executeShell } from "@oh-my-pi/pi-natives"; +import { $envExact } from "@oh-my-pi/pi-utils"; /** Cache for successful shell command results (persists for process lifetime). */ const commandResultCache = new Map(); @@ -21,7 +22,7 @@ export async function resolveConfigValue(config: string): Promise = process.env): string | undefined { + const value = env[name]; + if (value === undefined) return undefined; + // Enumeration preserves real key casing on Windows, unlike the getter; the + // value is trusted only when an exact-case entry actually exists. + for (const key in env) { + if (key === name) return value; + } + return undefined; +} + /** * Parses a positive decimal integer from `$env[name]`. * Empty, invalid, NaN, zero, or negative values return `defaultValue`. diff --git a/packages/utils/test/env.test.ts b/packages/utils/test/env.test.ts index 8afe5c108..a61801c4b 100644 --- a/packages/utils/test/env.test.ts +++ b/packages/utils/test/env.test.ts @@ -2,7 +2,13 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { filterProcessEnv, getDbBusyTimeoutMs, parseEnvFile, setInteractiveHost } from "@oh-my-pi/pi-utils/env"; +import { + $envExact, + filterProcessEnv, + getDbBusyTimeoutMs, + parseEnvFile, + setInteractiveHost, +} from "@oh-my-pi/pi-utils/env"; const tempDirs: string[] = []; const runtimeProbePath = path.join(import.meta.dir, "fixtures", "test-runtime-probe.ts"); @@ -188,3 +194,63 @@ describe("isBunTestRuntime", () => { ).toBe(true); }); }); + +/** + * Faithful model of Windows `process.env`: case-insensitive reads, but + * enumeration (`ownKeys`) preserves the real key casing — exactly Node + * (`uv_os_getenv` + `uv_os_environ`) and Bun (`CaseInsensitiveASCIIStringArrayHashMap`). + */ +function windowsLikeEnv(backing: Record): Record { + return new Proxy(backing, { + get(target, prop) { + if (typeof prop !== "string") return Reflect.get(target, prop); + for (const key in target) { + if (key.toLowerCase() === prop.toLowerCase()) return target[key]; + } + return undefined; + }, + has(target, prop) { + if (typeof prop !== "string") return Reflect.has(target, prop); + for (const key in target) { + if (key.toLowerCase() === prop.toLowerCase()) return true; + } + return false; + }, + }) as Record; +} + +describe("$envExact", () => { + it("returns the value for an exact-case key", () => { + const env = { OPENCODE_API_KEY: "sk-live", PATH: "/usr/bin" }; + expect($envExact("OPENCODE_API_KEY", env)).toBe("sk-live"); + }); + + it("returns undefined for an absent name", () => { + expect($envExact("MISSING_VAR", { PATH: "/usr/bin" })).toBeUndefined(); + }); + + it("does not hijack a literal via a case-differing Windows system var", () => { + // Windows ships PUBLIC=C:\Users\Public and reads are case-insensitive, so + // a bare `env["public"]` returns it — the /login #7361 401 root cause. + const env = windowsLikeEnv({ PUBLIC: "C:\\Users\\Public" }); + expect(env.public).toBe("C:\\Users\\Public"); + expect($envExact("public", env)).toBeUndefined(); + }); + + it("still resolves a genuine exact-case reference on a case-insensitive env", () => { + const env = windowsLikeEnv({ MY_KEY: "secret" }); + expect($envExact("MY_KEY", env)).toBe("secret"); + expect($envExact("my_key", env)).toBeUndefined(); + }); + + it("reads process.env by default", () => { + const name = `PI_ENVEXACT_TEST_${Date.now()}`; + process.env[name] = "value"; + try { + expect($envExact(name)).toBe("value"); + } finally { + delete process.env[name]; + } + expect($envExact(name)).toBeUndefined(); + }); +});