diff --git a/build/story-workflow-buzz-bridge-gateway.ts b/build/story-workflow-buzz-bridge-gateway.ts index 04117a2a5..67472e9b2 100644 --- a/build/story-workflow-buzz-bridge-gateway.ts +++ b/build/story-workflow-buzz-bridge-gateway.ts @@ -10,6 +10,7 @@ import { type StoryBridgeRequest, } from "../core/story-workflow/buzz-story-bridge-core.mjs"; import { agentProfileById } from "../lib/agents/agent-profiles"; +import { prepareStoryBridgeRequest } from "../modules/story-workflow/buzz-story-bridge"; import { ensurePrivateBuzzAgentMembership } from "./story-workflow/buzz-private-room-membership"; const API = "/api/story-workflow/buzz-bridge"; @@ -38,6 +39,7 @@ type ResponsibilityRunSnapshot = { }; }; type LocalResponse = T & { ok?: boolean; message?: string }; +type StoryBridgePreparationInput = Parameters[0]; function requestMatchesLocalBridge(request: IncomingMessage, expectedPath: string) { if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(request.socket.remoteAddress || "")) return false; @@ -111,6 +113,15 @@ async function localJson(request: IncomingMessage, route: string, init?: Requ return value as T; } +function prepareRequest(body: Record) { + return prepareStoryBridgeRequest({ + project: body.project as StoryBridgePreparationInput["project"], + workItem: body.workItem as StoryBridgePreparationInput["workItem"], + run: body.run as StoryBridgePreparationInput["run"], + contextPacket: body.contextPacket as StoryBridgePreparationInput["contextPacket"], + }); +} + function normalizeRequest(value: unknown): StoryBridgeRequest { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Story Bridge requires one bounded request envelope."); const input = value as StoryBridgeRequest; @@ -383,6 +394,11 @@ export function storyWorkflowBuzzBridgeGateway(): Plugin { } const body = await readBridgeAction(request); const action = typeof body.action === "string" ? body.action : ""; + if (action === "prepare") { + const bridge = prepareRequest(body); + writeBridgeResponse(response, 200, { ok: true, request: bridge }, bridge.requestId); + return; + } const bridge = normalizeRequest(body.request); if (action === "dispatch") { writeBridgeResponse(response, 200, await dispatch(request, bridge), bridge.requestId); diff --git a/modules/story-workflow/ui/foundations-buzz-story-live-test.tsx b/modules/story-workflow/ui/foundations-buzz-story-live-test.tsx index 655cd1307..c9a7d9833 100644 --- a/modules/story-workflow/ui/foundations-buzz-story-live-test.tsx +++ b/modules/story-workflow/ui/foundations-buzz-story-live-test.tsx @@ -5,7 +5,6 @@ import type { CurriculumLesson } from "../../../core/contracts/curriculum"; import type { PPFProject } from "../../../core/project/project"; import type { StoryWorkItem } from "../../../core/story-workflow/story-workflow-core.mjs"; import { loadFoundationProject } from "../../../core/storage/foundation-project-browser"; -import { prepareStoryBridgeRequest } from "../buzz-story-bridge"; import { createFoundationsStoryResponsibilityRun } from "../foundations-story-workflow"; type RunResponse = { @@ -21,12 +20,20 @@ type BridgeContribution = { readonly provenance?: { readonly eventId?: string; readonly signatureVerified?: boolean }; }; +type PreparedBridgeRequest = Record & { + readonly requestId?: string; + readonly state?: string; + readonly stateReason?: string; + readonly expectedAgentPubkey?: string; +}; + type BridgeResponse = { readonly ok?: boolean; readonly message?: string; readonly state?: string; readonly executionPath?: string; readonly requestId?: string; + readonly request?: PreparedBridgeRequest; readonly idempotent?: boolean; readonly contributions?: readonly BridgeContribution[]; readonly accepted?: readonly BridgeContribution[]; @@ -104,14 +111,16 @@ export default function FoundationsBuzzStoryLiveTest({ contextCharacters: task.contextPacket.receipt.usedCharacters, }); - const bridge = prepareStoryBridgeRequest({ + const prepared = await bridgeRequest({ + action: "prepare", project, workItem, run: task.run, contextPacket: task.contextPacket, }); - if (bridge.state !== "ready" || !bridge.expectedAgentPubkey) { - throw new Error(bridge.stateReason || "Tamsin does not have a usable local BUZZ signer binding."); + const bridge = prepared.request; + if (!bridge || bridge.state !== "ready" || !bridge.expectedAgentPubkey) { + throw new Error(bridge?.stateReason || "Tamsin does not have a usable local BUZZ signer binding."); } onStatus("BUZZ Story Test · adding Tamsin to the private Story Room and dispatching the bounded task…"); @@ -163,7 +172,7 @@ export default function FoundationsBuzzStoryLiveTest({ action: "proposal-ready", runId: task.run.runId, resultId: contribution.result.resultId, - ref: contribution.provenance?.eventId ? `buzz-event:${contribution.provenance.eventId}` : `buzz-story-bridge:${bridge.requestId}`, + ref: contribution.provenance?.eventId ? `buzz-event:${contribution.provenance.eventId}` : `buzz-story-bridge:${bridge.requestId || "unknown"}`, producedAt: new Date().toISOString(), }); setState("pass"); diff --git a/tests/issue-1422-buzz-story-bridge-hardening.test.mjs b/tests/issue-1422-buzz-story-bridge-hardening.test.mjs index 12f528f98..4179900ae 100644 --- a/tests/issue-1422-buzz-story-bridge-hardening.test.mjs +++ b/tests/issue-1422-buzz-story-bridge-hardening.test.mjs @@ -159,13 +159,15 @@ test("#1422 Story Bridge puts the exact approved BUZZ Agent in the private room }); test("#1422 exposes a one-click Afterglow/Tamsin live proof without writing the PPF", async () => { - const [panel, liveTest] = await Promise.all([ + const [panel, liveTest, gateway] = await Promise.all([ read("modules/story-workflow/ui/foundations-story-workflow-panel.tsx"), read("modules/story-workflow/ui/foundations-buzz-story-live-test.tsx"), + read("build/story-workflow-buzz-bridge-gateway.ts"), ]); assert.ok(panel.includes("FoundationsBuzzStoryLiveTest")); for (const contract of [ "Run BUZZ Story Test", + "action: \"prepare\"", "action: \"dispatch\"", "retry.idempotent !== true", "Waiting for Tamsin Hearthquill’s signed response", @@ -176,5 +178,9 @@ test("#1422 exposes a one-click Afterglow/Tamsin live proof without writing the "BUZZ Story Test PASS", ]) assert.ok(liveTest.includes(contract), `Live Story Bridge UI is missing exit-proof behavior: ${contract}`); + assert.ok(gateway.includes('if (action === "prepare")') && gateway.includes("prepareStoryBridgeRequest"), + "Story Bridge request preparation must execute inside the localhost server gateway."); + assert.doesNotMatch(liveTest, /prepareStoryBridgeRequest|nostr-event-verification|node:crypto/, + "The client live-test component must not import server-only Story Bridge crypto or request preparation."); assert.doesNotMatch(liveTest, /saveFoundationProject|applyStoryCommand|foundations\.proposal\.store|writing-assistant\/chat/); });