diff --git a/design/src/check.ts b/design/src/check.ts index 8f4aee9ae..992d606ca 100644 --- a/design/src/check.ts +++ b/design/src/check.ts @@ -5,6 +5,7 @@ import fs from "fs"; import { requireApiKey } from "./auth"; +import { receiptedFetch } from "./receipted-fetch"; export interface CheckResult { pass: boolean; @@ -22,7 +23,7 @@ export async function checkMockup(imagePath: string, brief: string): Promise controller.abort(), 60_000); try { - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await receiptedFetch("check-screenshot-request", "https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, diff --git a/design/src/design-to-code.ts b/design/src/design-to-code.ts index 358a6b4e9..67ba95c2a 100644 --- a/design/src/design-to-code.ts +++ b/design/src/design-to-code.ts @@ -6,6 +6,7 @@ import fs from "fs"; import { requireApiKey } from "./auth"; +import { receiptedFetch } from "./receipted-fetch"; import { readDesignConstraints } from "./memory"; export interface DesignToCodeResult { @@ -37,7 +38,7 @@ export async function generateDesignToCodePrompt( ? `\n\nExisting DESIGN.md (use these as constraints):\n${designConstraints}` : ""; - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await receiptedFetch("design-to-code-request", "https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, diff --git a/design/src/diff.ts b/design/src/diff.ts index 2d2e1ca19..b96dad652 100644 --- a/design/src/diff.ts +++ b/design/src/diff.ts @@ -6,6 +6,7 @@ import fs from "fs"; import { requireApiKey } from "./auth"; +import { receiptedFetch } from "./receipted-fetch"; export interface DiffResult { differences: { area: string; description: string; severity: string }[]; @@ -28,7 +29,7 @@ export async function diffMockups( const timeout = setTimeout(() => controller.abort(), 60_000); try { - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await receiptedFetch("diff-screenshots-request", "https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, diff --git a/design/src/evolve.ts b/design/src/evolve.ts index 58e88ce16..3ecba39ad 100644 --- a/design/src/evolve.ts +++ b/design/src/evolve.ts @@ -8,6 +8,7 @@ import fs from "fs"; import path from "path"; import { requireApiKey } from "./auth"; +import { receiptedFetch } from "./receipted-fetch"; export interface EvolveOptions { screenshot: string; // Path to current site screenshot @@ -55,7 +56,7 @@ export async function evolve(options: EvolveOptions): Promise { const timeout = setTimeout(() => controller.abort(), 240_000); try { - const response = await fetch("https://api.openai.com/v1/responses", { + const response = await receiptedFetch("evolve-image-request", "https://api.openai.com/v1/responses", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, @@ -113,7 +114,7 @@ async function analyzeScreenshot(apiKey: string, imageBase64: string): Promise controller.abort(), 30_000); try { - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await receiptedFetch("evolve-screenshot-analysis-request", "https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, diff --git a/design/src/generate.ts b/design/src/generate.ts index 3689aa710..e88f888aa 100644 --- a/design/src/generate.ts +++ b/design/src/generate.ts @@ -5,6 +5,7 @@ import fs from "fs"; import path from "path"; import { requireApiKey } from "./auth"; +import { receiptedFetch } from "./receipted-fetch"; import { parseBrief } from "./brief"; import { createSession, sessionPath } from "./session"; import { checkMockup } from "./check"; @@ -40,7 +41,7 @@ async function callImageGeneration( const timeout = setTimeout(() => controller.abort(), 240_000); try { - const response = await fetch("https://api.openai.com/v1/responses", { + const response = await receiptedFetch("generate-image-request", "https://api.openai.com/v1/responses", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, diff --git a/design/src/iterate.ts b/design/src/iterate.ts index 485944dd0..a2247d042 100644 --- a/design/src/iterate.ts +++ b/design/src/iterate.ts @@ -9,6 +9,7 @@ import fs from "fs"; import path from "path"; import { requireApiKey } from "./auth"; +import { receiptedFetch } from "./receipted-fetch"; import { readSession, updateSession } from "./session"; export interface IterateOptions { @@ -85,7 +86,7 @@ async function callWithThreading( const timeout = setTimeout(() => controller.abort(), 240_000); try { - const response = await fetch("https://api.openai.com/v1/responses", { + const response = await receiptedFetch("iterate-threaded-image-request", "https://api.openai.com/v1/responses", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, @@ -133,7 +134,7 @@ async function callFresh( const timeout = setTimeout(() => controller.abort(), 240_000); try { - const response = await fetch("https://api.openai.com/v1/responses", { + const response = await receiptedFetch("iterate-fresh-image-request", "https://api.openai.com/v1/responses", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, diff --git a/design/src/memory.ts b/design/src/memory.ts index 2fa7c5e8c..513e25d5b 100644 --- a/design/src/memory.ts +++ b/design/src/memory.ts @@ -14,6 +14,7 @@ import fs from "fs"; import path from "path"; import { requireApiKey } from "./auth"; +import { receiptedFetch } from "./receipted-fetch"; export interface ExtractedDesign { colors: { name: string; hex: string; usage: string }[]; @@ -34,7 +35,7 @@ export async function extractDesignLanguage(imagePath: string): Promise controller.abort(), 60_000); try { - const response = await fetch("https://api.openai.com/v1/chat/completions", { + const response = await receiptedFetch("memory-distill-request", "https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, diff --git a/design/src/receipted-fetch.ts b/design/src/receipted-fetch.ts new file mode 100644 index 000000000..39c4fa036 --- /dev/null +++ b/design/src/receipted-fetch.ts @@ -0,0 +1,57 @@ +/** + * receipted-fetch — egress-receipted wrapper for the design binary's OpenAI + * calls (sink 'design-openai'). + * + * Writes a content-free receipt BEFORE the send: sha256 of the JSON body + * plus a byte count — the hash only, never the body itself. FAIL-OPEN: a + * receipt hiccup warns on stderr and the call proceeds. User-facing image + * generation must not die because an audit log could not be written (the + * ledger records ATTEMPTED egress for auditing; it is not a send gate here). + * + * Streams pass through untouched: the response is returned as-is, and a + * non-string request body (e.g. a ReadableStream) is receipted as + * sha256:null rather than being consumed to hash it. + */ + +import { sha256Hex, writeReceipt } from "../../lib/egress-receipt"; + +export type FetchLike = typeof globalThis.fetch; + +/** + * Drop-in fetch replacement for api.openai.com calls. + * + * @param payloadClass content-free description of what is being sent + * (e.g. 'generate-image-request', 'check-screenshot-request') + * @param fetchImpl injectable fetch for tests / callers with their own + * fetch (variants.ts passes its stubbed fetchFn through) + */ +export async function receiptedFetch( + payloadClass: string, + url: string, + init?: RequestInit, + fetchImpl: FetchLike = globalThis.fetch, +): Promise { + try { + const body = init?.body; + let bytes = 0; + let sha256: string | null = null; + if (typeof body === "string") { + bytes = Buffer.byteLength(body); + sha256 = sha256Hex(body); + } + writeReceipt({ + sink: "design-openai", + host: new URL(url).host, + payloadClass, + bytes, + sha256, + consent: "user ran design command (OPENAI_API_KEY configured)", + }); + } catch (err) { + process.stderr.write( + `[design] egress receipt could not be written (${(err as Error).message}) — proceeding (fail-open). ` + + `gstack records what it ATTEMPTS to send off-machine; see gstack-egress.\n`, + ); + } + return fetchImpl(url, init); +} diff --git a/design/src/variants.ts b/design/src/variants.ts index 15be75e5c..ca1c37bfc 100644 --- a/design/src/variants.ts +++ b/design/src/variants.ts @@ -7,6 +7,7 @@ import fs from "fs"; import path from "path"; import { requireApiKey } from "./auth"; +import { receiptedFetch } from "./receipted-fetch"; import { parseBrief } from "./brief"; import { normalizeIntFlag } from "./flag-utils"; @@ -67,7 +68,7 @@ export async function generateVariant( const timeout = setTimeout(() => controller.abort(), 240_000); try { - const response = await fetchFn("https://api.openai.com/v1/responses", { + const response = await receiptedFetch("variants-image-request", "https://api.openai.com/v1/responses", { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, @@ -79,7 +80,7 @@ export async function generateVariant( tools: [{ type: "image_generation", model: "gpt-image-2", size, quality }], }), signal: controller.signal, - }); + }, fetchFn); clearTimeout(timeout); diff --git a/design/test/receipted-fetch.test.ts b/design/test/receipted-fetch.test.ts new file mode 100644 index 000000000..177f760ce --- /dev/null +++ b/design/test/receipted-fetch.test.ts @@ -0,0 +1,136 @@ +/** + * receipted-fetch — egress receipts for the design binary's OpenAI calls. + * + * Pins the sink contract (fail-OPEN polarity, amendment T3/C8): + * - receipt is written BEFORE the send (ordering observable via the + * ledger's existence at fetch time) + * - the sha256 recorded is the hash of the JSON body; the body itself is + * never stored + * - streaming response bodies pass through the wrapper intact + * - an unwritable ledger warns on stderr and the call still proceeds + */ + +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { receiptedFetch } from "../src/receipted-fetch"; +import { egressLedgerPath, listReceipts, sha256Hex } from "../../lib/egress-receipt"; + +let home: string; +let savedHome: string | undefined; + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "design-receipt-")); + savedHome = process.env.GSTACK_HOME; + process.env.GSTACK_HOME = home; +}); + +afterEach(() => { + if (savedHome === undefined) delete process.env.GSTACK_HOME; + else process.env.GSTACK_HOME = savedHome; + try { fs.chmodSync(path.join(home, "security"), 0o700); } catch {} + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe("receiptedFetch", () => { + test("writes the receipt BEFORE the send; sha256 is the hash of the JSON body", async () => { + const body = JSON.stringify({ model: "gpt-4o", input: "a prompt" }); + let receiptsAtFetchTime = -1; + const stub = (async (_url: any, init?: any) => { + // Receipt-before-send: by the time fetch runs, the receipt exists. + receiptsAtFetchTime = listReceipts(home).length; + expect(init.body).toBe(body); // body passes through untouched + return new Response("{}", { status: 200 }); + }) as typeof globalThis.fetch; + + const response = await receiptedFetch("generate-image-request", "https://api.openai.com/v1/responses", { + method: "POST", + body, + }, stub); + + expect(response.status).toBe(200); + expect(receiptsAtFetchTime).toBe(1); + const receipts = listReceipts(home); + expect(receipts.length).toBe(1); + expect(receipts[0].sink).toBe("design-openai"); + expect(receipts[0].host).toBe("api.openai.com"); + expect(receipts[0].payload_class).toBe("generate-image-request"); + expect(receipts[0].sha256).toBe(sha256Hex(body)); + expect(receipts[0].bytes).toBe(Buffer.byteLength(body)); + // Hash only — the ledger never contains the body text. + const raw = fs.readFileSync(egressLedgerPath(home), "utf-8"); + expect(raw).not.toContain("a prompt"); + }); + + test("streaming response body arrives intact through the wrapper", async () => { + const chunks = ["data: one\n", "data: two\n", "data: [DONE]\n"]; + const stream = new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(new TextEncoder().encode(c)); + controller.close(); + }, + }); + const stub = (async () => new Response(stream, { status: 200 })) as typeof globalThis.fetch; + + const response = await receiptedFetch("evolve-image-request", "https://api.openai.com/v1/responses", { + method: "POST", + body: JSON.stringify({ stream: true }), + }, stub); + + expect(response.body).toBeInstanceOf(ReadableStream); + expect(await response.text()).toBe(chunks.join("")); + }); + + test("non-string request body (ReadableStream) is receipted as sha256:null, not consumed", async () => { + const requestStream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("streamed-bytes")); + controller.close(); + }, + }); + let receivedBody: any = null; + const stub = (async (_url: any, init?: any) => { + receivedBody = init.body; + return new Response("{}", { status: 200 }); + }) as typeof globalThis.fetch; + + await receiptedFetch("stream-upload", "https://api.openai.com/v1/responses", { + method: "POST", + body: requestStream, + }, stub); + + expect(receivedBody).toBe(requestStream); // same stream object, untouched + const receipts = listReceipts(home); + expect(receipts[0].sha256).toBeNull(); + // The stream is still readable by the consumer (was not drained to hash). + expect(await new Response(receivedBody).text()).toBe("streamed-bytes"); + }); + + test("fail-open: unwritable ledger warns on stderr and the call proceeds", async () => { + if (process.platform === "win32" || process.getuid?.() === 0) return; + fs.mkdirSync(path.join(home, "security"), { recursive: true, mode: 0o500 }); + let fetched = false; + const stub = (async () => { fetched = true; return new Response("{}", { status: 200 }); }) as typeof globalThis.fetch; + + const captured: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + (process.stderr as any).write = (chunk: string) => { captured.push(String(chunk)); return true; }; + let response: Response; + try { + response = await receiptedFetch("check-screenshot-request", "https://api.openai.com/v1/chat/completions", { + method: "POST", + body: "{}", + }, stub); + } finally { + (process.stderr as any).write = originalWrite; + } + + expect(fetched).toBe(true); // the call proceeded + expect(response.status).toBe(200); + const warning = captured.join(""); + expect(warning).toContain("egress receipt could not be written"); + expect(warning).toContain("fail-open"); + expect(warning).toContain("gstack-egress"); + }); +}); diff --git a/design/test/variants-retry-after.test.ts b/design/test/variants-retry-after.test.ts index 2060791d5..8d84557b7 100644 --- a/design/test/variants-retry-after.test.ts +++ b/design/test/variants-retry-after.test.ts @@ -44,13 +44,19 @@ function makeStubFetch( describe("generateVariant Retry-After handling", () => { let tmpDir: string; let outputPath: string; + let savedHome: string | undefined; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "variants-retry-after-")); outputPath = path.join(tmpDir, "variant.png"); + // The fetch path now writes egress receipts — keep them in the temp home. + savedHome = process.env.GSTACK_HOME; + process.env.GSTACK_HOME = tmpDir; }); afterEach(() => { + if (savedHome === undefined) delete process.env.GSTACK_HOME; + else process.env.GSTACK_HOME = savedHome; fs.rmSync(tmpDir, { recursive: true, force: true }); });