mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-15 17:35:29 +02:00
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:
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user