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-..." }
|
* 1. ~/.gstack/openai.json → { "api_key": "sk-..." }
|
||||||
* 2. OPENAI_API_KEY environment variable
|
* 2. OPENAI_API_KEY environment variable
|
||||||
* 3. null (caller handles guided setup or fallback)
|
* 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 fs from "fs";
|
||||||
import path from "path";
|
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
|
// 1. Check ~/.gstack/openai.json
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(CONFIG_PATH)) {
|
const authPath = configPath();
|
||||||
const content = fs.readFileSync(CONFIG_PATH, "utf-8");
|
if (fs.existsSync(authPath)) {
|
||||||
|
const content = fs.readFileSync(authPath, "utf-8");
|
||||||
const config = JSON.parse(content);
|
const config = JSON.parse(content);
|
||||||
if (config.api_key && typeof config.api_key === "string") {
|
if (config.api_key && typeof config.api_key === "string") {
|
||||||
return config.api_key;
|
return { key: config.api_key, source: "config" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -28,28 +85,42 @@ export function resolveApiKey(): string | null {
|
||||||
|
|
||||||
// 2. Check environment variable
|
// 2. Check environment variable
|
||||||
if (process.env.OPENAI_API_KEY) {
|
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;
|
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.
|
* Save an API key to ~/.gstack/openai.json with 0600 permissions.
|
||||||
*/
|
*/
|
||||||
export function saveApiKey(key: string): void {
|
export function saveApiKey(key: string): void {
|
||||||
const dir = path.dirname(CONFIG_PATH);
|
const dir = path.dirname(configPath());
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ api_key: key }, null, 2));
|
fs.writeFileSync(configPath(), JSON.stringify({ api_key: key }, null, 2));
|
||||||
fs.chmodSync(CONFIG_PATH, 0o600);
|
fs.chmodSync(configPath(), 0o600);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get API key or exit with setup instructions.
|
* Get API key or exit with setup instructions.
|
||||||
*/
|
*/
|
||||||
export function requireApiKey(): string {
|
export function requireApiKey(): string {
|
||||||
const key = resolveApiKey();
|
const resolution = resolveApiKeyInfo();
|
||||||
if (!key) {
|
if (!resolution) {
|
||||||
console.error("No OpenAI API key found.");
|
console.error("No OpenAI API key found.");
|
||||||
console.error("");
|
console.error("");
|
||||||
console.error("Run: $D setup");
|
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");
|
console.error("Get a key at: https://platform.openai.com/api-keys");
|
||||||
process.exit(1);
|
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(` ${name.padEnd(12)} ${info.description}`);
|
||||||
console.log(` ${"".padEnd(12)} ${info.usage}`);
|
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");
|
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