mirror of https://github.com/garrytan/gstack.git
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:
parent
a1282c78d4
commit
52288947ec
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue