diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index f2d358d7..76d4b7e9 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -25,6 +25,39 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version import type { KnownProvider } from "./types.js"; +let _procEnvCache: Map | null = null; + +/** + * Fallback for https://github.com/oven-sh/bun/issues/27802 + * Bun compiled binaries have an empty `process.env` inside sandbox + * environments on Linux. We can recover the env from `/proc/self/environ`. + */ +function getProcEnv(key: string): string | undefined { + if (!process.versions?.bun) return undefined; + if (typeof process === "undefined") return undefined; + + // If process.env already has entries, the bug is not triggered. + if (Object.keys(process.env).length > 0) return undefined; + + if (_procEnvCache === null) { + _procEnvCache = new Map(); + try { + const { readFileSync } = require("node:fs") as typeof import("node:fs"); + const data = readFileSync("/proc/self/environ", "utf-8"); + for (const entry of data.split("\0")) { + const idx = entry.indexOf("="); + if (idx > 0) { + _procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1)); + } + } + } catch { + // /proc/self/environ may not be readable. + } + } + + return _procEnvCache.get(key); +} + let cachedVertexAdcCredentialsExists: boolean | null = null; function hasVertexAdcCredentials(): boolean { @@ -42,7 +75,7 @@ function hasVertexAdcCredentials(): boolean { } // Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way) - const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; + const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS || getProcEnv("GOOGLE_APPLICATION_CREDENTIALS"); if (gacPath) { cachedVertexAdcCredentialsExists = _existsSync(gacPath); } else { @@ -104,7 +137,7 @@ export function findEnvKeys(provider: string): string[] | undefined { const envVars = getApiKeyEnvVars(provider); if (!envVars) return undefined; - const found = envVars.filter((envVar) => !!process.env[envVar]); + const found = envVars.filter((envVar) => !!process.env[envVar] || !!getProcEnv(envVar)); return found.length > 0 ? found : undefined; } @@ -117,14 +150,21 @@ export function getEnvApiKey(provider: KnownProvider): string | undefined; export function getEnvApiKey(provider: string): string | undefined; export function getEnvApiKey(provider: string): string | undefined { const envKeys = findEnvKeys(provider); - if (envKeys?.[0]) return process.env[envKeys[0]]; + if (envKeys?.[0]) { + return process.env[envKeys[0]] || getProcEnv(envKeys[0]); + } // Vertex AI supports either an explicit API key or Application Default Credentials. // Auth is configured via `gcloud auth application-default login`. if (provider === "google-vertex") { const hasCredentials = hasVertexAdcCredentials(); - const hasProject = !!(process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT); - const hasLocation = !!process.env.GOOGLE_CLOUD_LOCATION; + const hasProject = !!( + process.env.GOOGLE_CLOUD_PROJECT || + process.env.GCLOUD_PROJECT || + getProcEnv("GOOGLE_CLOUD_PROJECT") || + getProcEnv("GCLOUD_PROJECT") + ); + const hasLocation = !!(process.env.GOOGLE_CLOUD_LOCATION || getProcEnv("GOOGLE_CLOUD_LOCATION")); if (hasCredentials && hasProject && hasLocation) { return ""; @@ -145,7 +185,13 @@ export function getEnvApiKey(provider: string): string | undefined { process.env.AWS_BEARER_TOKEN_BEDROCK || process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI || - process.env.AWS_WEB_IDENTITY_TOKEN_FILE + process.env.AWS_WEB_IDENTITY_TOKEN_FILE || + getProcEnv("AWS_PROFILE") || + (getProcEnv("AWS_ACCESS_KEY_ID") && getProcEnv("AWS_SECRET_ACCESS_KEY")) || + getProcEnv("AWS_BEARER_TOKEN_BEDROCK") || + getProcEnv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") || + getProcEnv("AWS_CONTAINER_CREDENTIALS_FULL_URI") || + getProcEnv("AWS_WEB_IDENTITY_TOKEN_FILE") ) { return ""; } diff --git a/packages/coding-agent/src/bun/cli.ts b/packages/coding-agent/src/bun/cli.ts index 641a7d78..0aacb95b 100644 --- a/packages/coding-agent/src/bun/cli.ts +++ b/packages/coding-agent/src/bun/cli.ts @@ -4,5 +4,9 @@ import { APP_NAME } from "../config.js"; process.title = APP_NAME; process.emitWarning = (() => {}) as typeof process.emitWarning; +import { restoreSandboxEnv } from "./restore-sandbox-env.js"; + +restoreSandboxEnv(); + await import("./register-bedrock.js"); await import("../cli.js"); diff --git a/packages/coding-agent/src/bun/restore-sandbox-env.ts b/packages/coding-agent/src/bun/restore-sandbox-env.ts new file mode 100644 index 00000000..b175a494 --- /dev/null +++ b/packages/coding-agent/src/bun/restore-sandbox-env.ts @@ -0,0 +1,32 @@ +/** + * Workaround for https://github.com/oven-sh/bun/issues/27802 + * + * Bun compiled binaries have an empty `process.env` when running inside + * sandbox environments (e.g. nono on Linux/macOS). On Linux we can recover + * the environment from `/proc/self/environ`. + */ + +import { readFileSync } from "node:fs"; + +/** + * Restore environment variables from `/proc/self/environ` when running + * inside a sandbox where Bun's `process.env` is empty. + */ +export function restoreSandboxEnv(): void { + if (!process.versions?.bun) return; + + // If process.env already has entries, nothing to fix. + if (Object.keys(process.env).length > 0) return; + + try { + const data = readFileSync("/proc/self/environ", "utf-8"); + for (const entry of data.split("\0")) { + const idx = entry.indexOf("="); + if (idx > 0) { + process.env[entry.slice(0, idx)] = entry.slice(idx + 1); + } + } + } catch { + // /proc/self/environ may not be readable; ignore. + } +} diff --git a/packages/coding-agent/test/restore-sandbox-env.test.ts b/packages/coding-agent/test/restore-sandbox-env.test.ts new file mode 100644 index 00000000..fa9036eb --- /dev/null +++ b/packages/coding-agent/test/restore-sandbox-env.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; + +const readFileSync = vi.fn(); + +vi.mock("node:fs", () => ({ + readFileSync, +})); + +const { restoreSandboxEnv } = await import("../src/bun/restore-sandbox-env.js"); + +describe("restoreSandboxEnv", () => { + it("does nothing when not running under bun", () => { + const originalVersions = Object.getOwnPropertyDescriptor(process, "versions"); + Object.defineProperty(process, "versions", { + value: { node: "20.0.0" }, + }); + const envBefore = { ...process.env }; + + restoreSandboxEnv(); + + expect(process.env).toEqual(envBefore); + + if (originalVersions) { + Object.defineProperty(process, "versions", originalVersions); + } + }); + + it("does nothing when process.env already has entries", () => { + const originalVersions = Object.getOwnPropertyDescriptor(process, "versions"); + Object.defineProperty(process, "versions", { + value: { bun: "1.2.0", node: "20.0.0" }, + }); + process.env.RESTORE_SANDBOX_ENV_TEST = "1"; + const envBefore = { ...process.env }; + + restoreSandboxEnv(); + + expect(process.env).toEqual(envBefore); + delete process.env.RESTORE_SANDBOX_ENV_TEST; + + if (originalVersions) { + Object.defineProperty(process, "versions", originalVersions); + } + }); + + it("restores environment from /proc/self/environ when bun env is empty", () => { + const originalVersions = Object.getOwnPropertyDescriptor(process, "versions"); + Object.defineProperty(process, "versions", { + value: { bun: "1.2.0", node: "20.0.0" }, + }); + + // Clear env to simulate the bun sandbox bug. + const envBackup = { ...process.env }; + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + + readFileSync.mockReturnValue("FOO=bar\0BAZ=qux\0"); + + restoreSandboxEnv(); + + expect(readFileSync).toHaveBeenCalledWith("/proc/self/environ", "utf-8"); + expect(process.env.FOO).toBe("bar"); + expect(process.env.BAZ).toBe("qux"); + + // Restore. + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, envBackup); + + if (originalVersions) { + Object.defineProperty(process, "versions", originalVersions); + } + readFileSync.mockReset(); + }); +});