diff --git a/design/src/auth.ts b/design/src/auth.ts index a6bdc0cb4..c3d8d7e5e 100644 --- a/design/src/auth.ts +++ b/design/src/auth.ts @@ -5,21 +5,78 @@ * 1. ~/.gstack/openai.json → { "api_key": "sk-..." } * 2. OPENAI_API_KEY environment variable * 3. null (caller handles guided setup or fallback) + * + * When OPENAI_API_KEY is in use AND its value matches an OPENAI_API_KEY entry + * in the current directory's .env / .env. / .env.local, we disclose + * the source on stderr before the run. Catches the silent-billing surface + * reported in #1248: design generation inside someone else's project would + * silently bill their OpenAI account if their .env was loaded into the shell. */ import fs from "fs"; import path from "path"; -const CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json"); +type ApiKeySource = "config" | "env"; -export function resolveApiKey(): string | null { +export interface ApiKeyResolution { + key: string; + source: ApiKeySource; + envFile?: string; + warning?: string; +} + +function configPath(): string { + return path.join(process.env.HOME || "~", ".gstack", "openai.json"); +} + +function readEnvValue(filePath: string, key: string): string | null { + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + return null; + } + + for (const line of content.split(/\r?\n/)) { + const match = line.match(new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(.*)\\s*$`)); + if (!match) continue; + + let value = match[1].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + return value; + } + + return null; +} + +function matchingCwdEnvFile(key: string, value: string): string | null { + const candidates = [".env"]; + const nodeEnv = process.env.NODE_ENV; + if (nodeEnv) candidates.push(`.env.${nodeEnv}`); + candidates.push(".env.local"); + + for (const fileName of candidates) { + const fileValue = readEnvValue(path.join(process.cwd(), fileName), key); + if (fileValue === value) return fileName; + } + + return null; +} + +export function resolveApiKeyInfo(): ApiKeyResolution | null { // 1. Check ~/.gstack/openai.json try { - if (fs.existsSync(CONFIG_PATH)) { - const content = fs.readFileSync(CONFIG_PATH, "utf-8"); + const authPath = configPath(); + if (fs.existsSync(authPath)) { + const content = fs.readFileSync(authPath, "utf-8"); const config = JSON.parse(content); if (config.api_key && typeof config.api_key === "string") { - return config.api_key; + return { key: config.api_key, source: "config" }; } } } catch { @@ -28,28 +85,42 @@ export function resolveApiKey(): string | null { // 2. Check environment variable if (process.env.OPENAI_API_KEY) { - return process.env.OPENAI_API_KEY; + const envFile = matchingCwdEnvFile("OPENAI_API_KEY", process.env.OPENAI_API_KEY); + const warning = envFile + ? `Warning: OPENAI_API_KEY matches ${envFile} in the current directory. Design generation may bill that project's OpenAI account. Run $D setup to store a gstack-specific key in ~/.gstack/openai.json.` + : undefined; + return { key: process.env.OPENAI_API_KEY, source: "env", envFile: envFile ?? undefined, warning }; } return null; } +export function resolveApiKey(): string | null { + return resolveApiKeyInfo()?.key ?? null; +} + +export function describeApiKeySource(resolution: ApiKeyResolution): string { + if (resolution.source === "config") return "~/.gstack/openai.json"; + if (resolution.envFile) return `OPENAI_API_KEY environment variable (matches ${resolution.envFile} in current directory)`; + return "OPENAI_API_KEY environment variable"; +} + /** * Save an API key to ~/.gstack/openai.json with 0600 permissions. */ export function saveApiKey(key: string): void { - const dir = path.dirname(CONFIG_PATH); + const dir = path.dirname(configPath()); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(CONFIG_PATH, JSON.stringify({ api_key: key }, null, 2)); - fs.chmodSync(CONFIG_PATH, 0o600); + fs.writeFileSync(configPath(), JSON.stringify({ api_key: key }, null, 2)); + fs.chmodSync(configPath(), 0o600); } /** * Get API key or exit with setup instructions. */ export function requireApiKey(): string { - const key = resolveApiKey(); - if (!key) { + const resolution = resolveApiKeyInfo(); + if (!resolution) { console.error("No OpenAI API key found."); console.error(""); console.error("Run: $D setup"); @@ -59,5 +130,7 @@ export function requireApiKey(): string { console.error("Get a key at: https://platform.openai.com/api-keys"); process.exit(1); } - return key; + console.error(`Using OpenAI key from ${describeApiKeySource(resolution)}.`); + if (resolution.warning) console.error(resolution.warning); + return resolution.key; } diff --git a/design/src/cli.ts b/design/src/cli.ts index 481eb29d4..7432c3c2c 100644 --- a/design/src/cli.ts +++ b/design/src/cli.ts @@ -60,7 +60,8 @@ function printUsage(): void { console.log(` ${name.padEnd(12)} ${info.description}`); console.log(` ${"".padEnd(12)} ${info.usage}`); } - console.log("\nAuth: ~/.gstack/openai.json or OPENAI_API_KEY env var"); + console.log("\nAuth: ~/.gstack/openai.json, then OPENAI_API_KEY env var"); + console.log("If OPENAI_API_KEY matches a current-directory .env file, the source is reported before billing."); console.log("Setup: $D setup"); } diff --git a/design/test/auth.test.ts b/design/test/auth.test.ts new file mode 100644 index 000000000..4cb1058f1 --- /dev/null +++ b/design/test/auth.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for $D OpenAI auth source reporting (#1278, closes #1248). + * + * Verifies that resolveApiKey + requireApiKey: + * - prefer ~/.gstack/openai.json over OPENAI_API_KEY + * - report when the env-var key matches a cwd .env / .env.local + * - never echo the key itself to stderr (only the source label) + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + describeApiKeySource, + requireApiKey, + resolveApiKey, + resolveApiKeyInfo, + saveApiKey, +} from "../src/auth"; + +let tmpDir: string; +let tmpHome: string; +let originalHome: string | undefined; +let originalKey: string | undefined; +let originalNodeEnv: string | undefined; +let originalCwd: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-design-auth-")); + tmpHome = path.join(tmpDir, "home"); + fs.mkdirSync(tmpHome, { recursive: true }); + + originalHome = process.env.HOME; + originalKey = process.env.OPENAI_API_KEY; + originalNodeEnv = process.env.NODE_ENV; + originalCwd = process.cwd(); + + process.env.HOME = tmpHome; + delete process.env.OPENAI_API_KEY; + delete process.env.NODE_ENV; + process.chdir(tmpDir); +}); + +afterEach(() => { + process.chdir(originalCwd); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalKey === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = originalKey; + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("resolveApiKeyInfo", () => { + test("uses ~/.gstack/openai.json before OPENAI_API_KEY", () => { + saveApiKey("sk-config"); + process.env.OPENAI_API_KEY = "sk-env"; + + const resolution = resolveApiKeyInfo(); + + expect(resolution?.key).toBe("sk-config"); + expect(resolution?.source).toBe("config"); + expect(describeApiKeySource(resolution!)).toBe("~/.gstack/openai.json"); + expect(resolveApiKey()).toBe("sk-config"); + }); + + test("uses OPENAI_API_KEY when no config file exists", () => { + process.env.OPENAI_API_KEY = "sk-env"; + + const resolution = resolveApiKeyInfo(); + + expect(resolution?.key).toBe("sk-env"); + expect(resolution?.source).toBe("env"); + expect(resolution?.envFile).toBeUndefined(); + expect(describeApiKeySource(resolution!)).toBe("OPENAI_API_KEY environment variable"); + }); + + test("reports when OPENAI_API_KEY matches current-directory .env", () => { + fs.writeFileSync(path.join(tmpDir, ".env"), "OPENAI_API_KEY=sk-project\n"); + process.env.OPENAI_API_KEY = "sk-project"; + + const resolution = resolveApiKeyInfo(); + + expect(resolution?.key).toBe("sk-project"); + expect(resolution?.envFile).toBe(".env"); + expect(describeApiKeySource(resolution!)).toBe("OPENAI_API_KEY environment variable (matches .env in current directory)"); + expect(resolution?.warning).toContain("may bill that project's OpenAI account"); + }); + + test("detects quoted and exported env-file values", () => { + fs.writeFileSync(path.join(tmpDir, ".env.local"), "export OPENAI_API_KEY=\"sk-local\"\n"); + process.env.OPENAI_API_KEY = "sk-local"; + + const resolution = resolveApiKeyInfo(); + + expect(resolution?.envFile).toBe(".env.local"); + expect(resolution?.warning).toContain(".env.local"); + }); + + test("does not claim env-file source when values differ", () => { + fs.writeFileSync(path.join(tmpDir, ".env"), "OPENAI_API_KEY=sk-other\n"); + process.env.OPENAI_API_KEY = "sk-shell"; + + const resolution = resolveApiKeyInfo(); + + expect(resolution?.key).toBe("sk-shell"); + expect(resolution?.envFile).toBeUndefined(); + expect(resolution?.warning).toBeUndefined(); + }); +}); + +describe("requireApiKey", () => { + test("prints source disclosure without leaking the key", () => { + process.env.OPENAI_API_KEY = "sk-secret-value"; + const messages: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + messages.push(args.map(String).join(" ")); + }; + + try { + expect(requireApiKey()).toBe("sk-secret-value"); + } finally { + console.error = originalError; + } + + const stderr = messages.join("\n"); + expect(stderr).toContain("Using OpenAI key from OPENAI_API_KEY environment variable."); + expect(stderr).not.toContain("sk-secret-value"); + }); +});