feat(design): receipted fetch for OpenAI calls

design/src/receipted-fetch.ts wraps every api.openai.com call: a
content-free egress receipt (sink design-openai, sha256 of the JSON
body — hash only, never the body) is written BEFORE the send. Polarity
is FAIL-OPEN: user-facing generation must not die because an audit log
hiccuped, so a receipt failure warns on stderr and the call proceeds.
Streams pass through untouched (response bodies returned as-is;
non-string request bodies receipted as sha256:null rather than drained
to hash).

All ten call sites converted with per-command payload classes:
generate, variants (injected fetchFn passes through), iterate (both
threaded and fresh paths), evolve (image + screenshot analysis), check,
diff, design-to-code, memory.

Unit-tested with injected fetch: receipt-before-send ordering, stream
passthrough, and fail-open on an unwritable ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c0e5ff6639414ac2fd98e8ac3affb51401746b55)
This commit is contained in:
Garry Tan 2026-08-12 11:23:53 -07:00
parent a1282c78d4
commit 52288947ec
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
11 changed files with 218 additions and 11 deletions

View File

@ -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<Che
const timeout = setTimeout(() => 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}`,

View File

@ -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}`,

View File

@ -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}`,

View File

@ -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<void> {
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<s
const timeout = setTimeout(() => 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}`,

View File

@ -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}`,

View File

@ -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}`,

View File

@ -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<Extracte
const timeout = setTimeout(() => 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}`,

View File

@ -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<Response> {
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);
}

View File

@ -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);

View File

@ -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<Uint8Array>({
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<Uint8Array>({
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");
});
});

View File

@ -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 });
});