mirror of https://github.com/garrytan/gstack.git
The design binary previously called process.env.OPENAI_API_KEY without checking where the key came from. If a user ran $D inside someone else's project that had OPENAI_API_KEY in its .env, the resulting generation billed that project's account. Silent and irreversible. Fix: resolveApiKeyInfo() returns both the key and its source. When the env-var path matches an OPENAI_API_KEY entry in the current directory's .env, .env.<NODE_ENV>, or .env.local file, we set a warning. requireApiKey() prints "Using OpenAI key from <source>" plus the warning before the run — never the key itself. Adds 6 unit tests covering: config-vs-env precedence, env-only (no match), env+cwd .env match, quoted/exported values, value-mismatch (no false positive), and the no-leak invariant for requireApiKey stderr output. Contributed by @jbetala7 via #1278. Closes #1248. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bf7a31e427
commit
16fca84d04
|
|
@ -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.<NODE_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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue