mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 11:40:51 +01:00
* fix: mark Remaining Work provenance so restore verifies guessed steps first (#3004) /context-save now starts every Remaining Work item with `Open.` and ends it with how this session knows it: `(path run)` with the observed outcome, `(path read)` or `(path assumed)` for concrete paths, and `(target state checked)`, `(code read)` or `(path assumed)` for writing steps. /context-restore keeps saved order and original text but splits the list into Next steps and Verify first (assumed, code-read, failed runs and unmarked concrete or writing items), prints a banner for pre-marker checkpoints, and option A walks saved order so an unverified first item is verified before any later step runs. Adds a prompt-byte contract test for the marker wire format and a periodic behavior E2E (context-restore-provenance-order) that seeds an assumed item ahead of a run item plus a code-read write. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(design): never overwrite a paid design image (#1529) Every image the API returns now goes through one persistence path (design/src/persist.ts): mkdir, exclusive `wx` claim that bumps x.png -> x-2.png ... x-999.png, write, fsync, close. A failure after the bytes arrived removes the claimed file and keeps a 0600 recovery copy in the temp dir; empty image data is a failure, never a 0-byte PNG. The API call is the only thing inside each retry loop, so a local save failure never buys a second image (variants, iterate fallback, generate --retry). generate, iterate, evolve and variants (both branches) print the same run fields (requested, saved, selected, failures, recovered) on every exit, with exit 0 / 2 (nothing saved) / 3 (stopped after saving). Each generate --retry attempt is its own saved image listed in `attempts`. compare accepts --images-file and JSON-array --images. The gallery lists bumped names, orders history by mtime then board-images.json, and marks approvals by approved_path. New bin/gstack-design-claim publishes staged images without clobbering; bin/gstack-design-approved resolves an approval record (legacy letter-only records still resolve; a missing approved_path is an error, never a substitute). Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> * fix(design skills): read the paths $D printed, never fixed variant names (#1529) Every design flow now captures each generating $D command's JSON and exit code without set -e, does round accounting before the first board, check or inline view (stopping when nothing was saved), and carries the printed paths forward. Boards are built from board-images.json via compare --images-file after archiving any earlier feedback.json, and approvals map the confirmed letter through that file and record approved_path. design-shotgun subagents generate into a per-run mktemp staging directory and publish with bin/gstack-design-claim instead of a shared /tmp/variant-X.png plus cp. design-html and taste memory resolve approvals with gstack-design-approved; design-review verifies against the printed target path. test/design-printed-paths.test.ts bans fixed variant names, variant globs, variant-{letter} reads, cp/mv of $D output and literal check paths in the generated skills and resolvers (one content-anchored allowlist entry: the design-html discovery probe), and pins the accounting/board marker order. test/design-approval-flow.test.ts runs the generated board and approval blocks through a stubbed two-round flow. * chore: release v1.91.17.0 (mvanhorn fix wave: checkpoint provenance, never-overwrite design images) * fix(design): keep iterate sessions in the OS temp dir and normalize tripwire paths on Windows * fix(setup-deploy): report failing status commands, specify merge-method, health and workflow checks --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
/**
|
|
* Auth resolution for OpenAI API access.
|
|
*
|
|
* Resolution order:
|
|
* 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";
|
|
|
|
type ApiKeySource = "config" | "env";
|
|
|
|
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 {
|
|
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 { key: config.api_key, source: "config" };
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall through to env var
|
|
}
|
|
|
|
// 2. Check environment variable
|
|
if (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(configPath());
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
// Create the file owner-only up front so the API key is never briefly
|
|
// world/group-readable in the window between write and chmod. The trailing
|
|
// chmodSync is kept as a backstop to tighten a pre-existing loose file.
|
|
fs.writeFileSync(configPath(), JSON.stringify({ api_key: key }, null, 2), { mode: 0o600 });
|
|
fs.chmodSync(configPath(), 0o600);
|
|
}
|
|
|
|
/**
|
|
* Get API key or throw with setup instructions.
|
|
*/
|
|
export function requireApiKey(): string {
|
|
const resolution = resolveApiKeyInfo();
|
|
if (!resolution) {
|
|
throw new Error([
|
|
"No OpenAI API key found.",
|
|
"",
|
|
"Run: $D setup",
|
|
" or save to ~/.gstack/openai.json: { \"api_key\": \"sk-...\" }",
|
|
" or set OPENAI_API_KEY environment variable",
|
|
"",
|
|
"Get a key at: https://platform.openai.com/api-keys",
|
|
].join("\n"));
|
|
}
|
|
console.error(`Using OpenAI key from ${describeApiKeySource(resolution)}.`);
|
|
if (resolution.warning) console.error(resolution.warning);
|
|
return resolution.key;
|
|
}
|