diff --git a/apps/brunch-agent/README.md b/apps/brunch-agent/README.md new file mode 100644 index 00000000000..e2ca70e74cb --- /dev/null +++ b/apps/brunch-agent/README.md @@ -0,0 +1,17 @@ +# Brunch agent application + +## Run the process-model panel locally + +From the repository root, make `ANTHROPIC_API_KEY` available in the environment and run: + +```sh +yarn dev:brunch +``` + +The command starts the Brunch server at `http://127.0.0.1:4321` and the real Petrinaut website at +`http://127.0.0.1:4915`. The website proxies `/api/chat` to Brunch, where the panel runs the SDCPN +process-model elicitor. + +Conversations persist in `apps/brunch-agent/.data-wipe-me/conversations.db`. Owned target documents +persist as per-document JSON files under `apps/brunch-agent/.data-wipe-me/target-documents/`. +`BRUNCH_DEV_DB_PATH` and `BRUNCH_DEV_TARGET_DOCUMENT_DIR` override those local paths. diff --git a/apps/brunch-agent/petrinaut-local.vite.config.ts b/apps/brunch-agent/petrinaut-local.vite.config.ts index 249686c8498..58b2973bda7 100644 --- a/apps/brunch-agent/petrinaut-local.vite.config.ts +++ b/apps/brunch-agent/petrinaut-local.vite.config.ts @@ -41,9 +41,7 @@ const withoutIncumbentChatHandler = ( export default defineConfig(async (environment) => { const websiteRoot = process.env.PETRINAUT_WEBSITE_ROOT; if (!websiteRoot) { - throw new Error( - "PETRINAUT_WEBSITE_ROOT must point at hash/apps/petrinaut-website for the real-panel run.", - ); + throw new Error("PETRINAUT_WEBSITE_ROOT is required."); } const root = resolve(websiteRoot); // Babel resolves the React compiler plugin from the launched project's cwd, diff --git a/apps/brunch-agent/src/agents/gherkin-elicitor.ts b/apps/brunch-agent/src/agents/gherkin-elicitor.ts index 9aaf8b1e493..d61da9094df 100644 --- a/apps/brunch-agent/src/agents/gherkin-elicitor.ts +++ b/apps/brunch-agent/src/agents/gherkin-elicitor.ts @@ -35,6 +35,7 @@ import { createGherkinElicitationSession } from "../elicitation-session.ts"; export const GHERKIN_MODEL_ID = "claude-haiku-4-5"; const gherkinElicitorInitialData = v.object({ + ownerKey: v.optional(v.pipe(v.string(), v.nonEmpty())), targetDocumentId: v.pipe(v.string(), v.nonEmpty()), }); @@ -44,7 +45,11 @@ export function GherkinElicitor(props: AgentProps) { useInitialData>(); return useElicitation( gherkin, - createGherkinElicitationSession(props.id, initialData.targetDocumentId), + createGherkinElicitationSession( + props.id, + initialData.targetDocumentId, + initialData.ownerKey, + ), ); } diff --git a/apps/brunch-agent/src/agents/sdcpn-elicitor.ts b/apps/brunch-agent/src/agents/sdcpn-elicitor.ts index 9609c98e7ae..fadcbe0cc07 100644 --- a/apps/brunch-agent/src/agents/sdcpn-elicitor.ts +++ b/apps/brunch-agent/src/agents/sdcpn-elicitor.ts @@ -33,6 +33,7 @@ export const SDCPN_MODEL_ID = process.env["BRUNCH_SDCPN_MODEL"] || "claude-haiku-4-5"; const sdcpnElicitorInitialData = v.object({ + ownerKey: v.optional(v.pipe(v.string(), v.nonEmpty())), targetDocumentId: v.pipe(v.string(), v.nonEmpty()), }); @@ -42,7 +43,11 @@ export function SdcpnElicitor(props: AgentProps) { useInitialData>(); return useElicitation( sdcpn, - createSdcpnElicitationSession(props.id, initialData.targetDocumentId), + createSdcpnElicitationSession( + props.id, + initialData.targetDocumentId, + initialData.ownerKey, + ), ); } diff --git a/apps/brunch-agent/src/elicitation-session.ts b/apps/brunch-agent/src/elicitation-session.ts index be16fd92e6c..f99c159f325 100644 --- a/apps/brunch-agent/src/elicitation-session.ts +++ b/apps/brunch-agent/src/elicitation-session.ts @@ -1,4 +1,5 @@ /** Host-owned wiring for the local Flue binding's history transport and store. */ +import { createHash } from "node:crypto"; import { createFlueHistoryReader, @@ -10,6 +11,20 @@ import { import { AGENT_ROUTES, type AgentTarget } from "./routes.ts"; import { targetDocumentPath } from "./target-document-path.ts"; +export const resolvePetrinautSessionIdentity = ( + principalKey: string, + conversationId: string, +) => { + const sessionDigest = createHash("sha256") + .update(JSON.stringify([principalKey, conversationId])) + .digest("hex"); + return { + ownerKey: principalKey, + sessionId: `petrinaut-local:${sessionDigest}`, + targetDocumentId: `petrinaut-local:${principalKey}`, + } as const; +}; + const appTransport: FlueHistoryReaderOptions["transport"] = async ( input, init, @@ -27,9 +42,11 @@ const createElicitationSession = ( target: AgentTarget, sessionId: string, targetDocumentId: string, + ownerKey?: string, ): ElicitationSession => { const captureStore = createLocalCaptureStore( targetDocumentPath(targetDocumentId), + ownerKey === undefined ? {} : { ownerKey }, ); return { sessionId, @@ -46,11 +63,13 @@ const createElicitationSession = ( export const createGherkinElicitationSession = ( sessionId: string, targetDocumentId: string, + ownerKey?: string, ): ElicitationSession => - createElicitationSession("gherkin", sessionId, targetDocumentId); + createElicitationSession("gherkin", sessionId, targetDocumentId, ownerKey); export const createSdcpnElicitationSession = ( sessionId: string, targetDocumentId: string, + ownerKey?: string, ): ElicitationSession => - createElicitationSession("sdcpn", sessionId, targetDocumentId); + createElicitationSession("sdcpn", sessionId, targetDocumentId, ownerKey); diff --git a/apps/brunch-agent/src/petrinaut-chat.ts b/apps/brunch-agent/src/petrinaut-chat.ts index 280fed99ad8..498049d22a2 100644 --- a/apps/brunch-agent/src/petrinaut-chat.ts +++ b/apps/brunch-agent/src/petrinaut-chat.ts @@ -16,8 +16,11 @@ import { type TransportInspectionEvent, } from "@hashintel/brunch-agent-transport-aisdk"; -import { GherkinElicitor } from "./agents/gherkin-elicitor.ts"; -import { createGherkinElicitationSession } from "./elicitation-session.ts"; +import { SdcpnElicitor } from "./agents/sdcpn-elicitor.ts"; +import { + createSdcpnElicitationSession, + resolvePetrinautSessionIdentity, +} from "./elicitation-session.ts"; import { defaultPanelOrigins } from "./local-dev-origins.ts"; const inspect = @@ -29,20 +32,23 @@ const inspect = } : undefined; -// FE-1439 replaces this local one-conversation/one-document identity -// with principal-owned private session lookup. Keep it opaque here. -const targetDocumentIdFor = (conversationId: string): string => - `petrinaut-local:${conversationId}`; - const streamElicitorTurn = async ( + principalKey: string, conversationId: string, dispatch: { readonly message: string; readonly idempotencyKey: string }, emit: (event: HarnessReplyEvent) => void, ): Promise => { - const agent = init(GherkinElicitor, { id: conversationId }); + const identity = resolvePetrinautSessionIdentity( + principalKey, + conversationId, + ); + const agent = init(SdcpnElicitor, { id: identity.sessionId }); const receipt = await agent.dispatch({ ...dispatch, - initialData: { targetDocumentId: targetDocumentIdFor(conversationId) }, + initialData: { + ownerKey: identity.ownerKey, + targetDocumentId: identity.targetDocumentId, + }, }); const projector = createFlueReplyProjector({ submissionId: receipt.submissionId, @@ -61,6 +67,7 @@ export const petrinautChatHandler = createAiSdkChatHandler({ inspect, runTurn: (input, emit) => streamElicitorTurn( + input.principalKey, input.conversationId, { message: input.userMessage.text, idempotencyKey: input.idempotencyKey }, emit, @@ -70,12 +77,17 @@ export const petrinautChatHandler = createAiSdkChatHandler({ // submission resumes the conversation only when its tool-call id // correlates with the one ask still awaiting a reply. async admit(input) { - const session = createGherkinElicitationSession( + const identity = resolvePetrinautSessionIdentity( + input.principalKey, input.conversationId, - targetDocumentIdFor(input.conversationId), + ); + const session = createSdcpnElicitationSession( + identity.sessionId, + identity.targetDocumentId, + identity.ownerKey, ); const entries = projectFlueHistoryForSweep( - await session.historyReader.peek(input.conversationId), + await session.historyReader.peek(identity.sessionId), ); return decideAskReplyAdmission( pendingAskAffordanceId(entries), @@ -86,6 +98,7 @@ export const petrinautChatHandler = createAiSdkChatHandler({ // binds it to the pending affordance, making it the user-affordance reply. run: (input, emit) => streamElicitorTurn( + input.principalKey, input.conversationId, { message: input.ask.answer, idempotencyKey: input.idempotencyKey }, emit, diff --git a/apps/brunch-agent/test/elicitation-session.test.ts b/apps/brunch-agent/test/elicitation-session.test.ts new file mode 100644 index 00000000000..946c3d9596c --- /dev/null +++ b/apps/brunch-agent/test/elicitation-session.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "vitest"; + +import { resolvePetrinautSessionIdentity } from "../src/elicitation-session.ts"; + +test("namespaces panel sessions by principal while keeping one document per principal", () => { + const firstSession = resolvePetrinautSessionIdentity( + "principal-a", + "conversation-shared", + ); + const reloadedSession = resolvePetrinautSessionIdentity( + "principal-a", + "conversation-after-reload", + ); + const otherPrincipal = resolvePetrinautSessionIdentity( + "principal-b", + "conversation-shared", + ); + + expect(reloadedSession.targetDocumentId).toBe(firstSession.targetDocumentId); + expect(reloadedSession.sessionId).not.toBe(firstSession.sessionId); + expect(otherPrincipal.sessionId).not.toBe(firstSession.sessionId); + expect(otherPrincipal.targetDocumentId).not.toBe( + firstSession.targetDocumentId, + ); +}); diff --git a/apps/brunch-agent/test/local-dev-origins.test.ts b/apps/brunch-agent/test/local-dev-origins.test.ts index 26eec099d35..342c794f530 100644 --- a/apps/brunch-agent/test/local-dev-origins.test.ts +++ b/apps/brunch-agent/test/local-dev-origins.test.ts @@ -13,6 +13,26 @@ import { const readAppFile = (relativePath: string): string => readFileSync(new URL(`../${relativePath}`, import.meta.url), "utf8"); +const readRepoFile = (relativePath: string): string => + readFileSync(new URL(`../../../${relativePath}`, import.meta.url), "utf8"); + +test("one documented root command starts the Brunch server and Petrinaut panel", () => { + const rootPackage = JSON.parse(readRepoFile("package.json")) as { + scripts: Record; + }; + + expect(rootPackage.scripts["dev:brunch"]).toBe( + "npm-run-all --parallel dev:brunch:server dev:brunch:panel", + ); + expect(rootPackage.scripts["dev:brunch:server"]).toBe( + "yarn workspace @apps/brunch-agent dev", + ); + expect(rootPackage.scripts["dev:brunch:panel"]).toBe( + 'PETRINAUT_WEBSITE_ROOT="$PWD/apps/petrinaut-website" yarn workspace @apps/brunch-agent petrinaut:dev', + ); + expect(readAppFile("README.md")).toContain("yarn dev:brunch"); +}); + test("dev listens on the chat origin the panel proxy already assumes", () => { expect(defaultChatOrigin).toBe("http://127.0.0.1:4321"); expect(localChatListen).toEqual({ diff --git a/apps/brunch-agent/test/petrinaut-ask.integration.ts b/apps/brunch-agent/test/petrinaut-ask.integration.ts index ae6033c43ec..88c22867e4b 100644 --- a/apps/brunch-agent/test/petrinaut-ask.integration.ts +++ b/apps/brunch-agent/test/petrinaut-ask.integration.ts @@ -19,10 +19,7 @@ import { } from "@earendil-works/pi-ai"; import { start } from "@flue/runtime/node"; -import { - GHERKIN_MODEL_ID, - GherkinElicitor, -} from "../src/agents/gherkin-elicitor.ts"; +import { SDCPN_MODEL_ID, SdcpnElicitor } from "../src/agents/sdcpn-elicitor.ts"; import type { PetrinautAskResult } from "./petrinaut-ask-result"; import type { UIMessageChunk } from "ai"; @@ -33,7 +30,7 @@ process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1"; const faux = fauxProvider({ provider: "anthropic", - models: [{ id: GHERKIN_MODEL_ID, reasoning: true }], + models: [{ id: SDCPN_MODEL_ID, reasoning: true }], }); faux.setResponses([ fauxAssistantMessage( @@ -58,7 +55,7 @@ faux.setResponses([ ]); const flue = await start({ - agents: [GherkinElicitor], + agents: [SdcpnElicitor], providers: [faux.provider], }); @@ -80,6 +77,7 @@ try { method: "POST", headers: { "content-type": "application/json", + "x-brunch-principal": "principal-fe1449", "x-request-id": requestId, }, body: JSON.stringify(body), diff --git a/apps/brunch-agent/test/petrinaut-chat.integration.ts b/apps/brunch-agent/test/petrinaut-chat.integration.ts index 3325a33e151..fc71c397a50 100644 --- a/apps/brunch-agent/test/petrinaut-chat.integration.ts +++ b/apps/brunch-agent/test/petrinaut-chat.integration.ts @@ -11,10 +11,7 @@ import { } from "@earendil-works/pi-ai"; import { start } from "@flue/runtime/node"; -import { - GHERKIN_MODEL_ID, - GherkinElicitor, -} from "../src/agents/gherkin-elicitor.ts"; +import { SDCPN_MODEL_ID, SdcpnElicitor } from "../src/agents/sdcpn-elicitor.ts"; import type { PetrinautChatResult } from "./petrinaut-chat-result"; import type { UIMessageChunk } from "ai"; @@ -25,7 +22,7 @@ process.env.BRUNCH_TRANSPORT_AISDK_INSPECT = "1"; const faux = fauxProvider({ provider: "anthropic", - models: [{ id: GHERKIN_MODEL_ID, reasoning: true }], + models: [{ id: SDCPN_MODEL_ID, reasoning: true }], }); faux.setResponses([ fauxAssistantMessage([ @@ -43,7 +40,7 @@ faux.setResponses([ ]); const flue = await start({ - agents: [GherkinElicitor], + agents: [SdcpnElicitor], providers: [faux.provider], }); @@ -60,6 +57,7 @@ try { method: "POST", headers: { "content-type": "application/json", + "x-brunch-principal": "principal-fe1436-application", "x-request-id": "request-fe1436-application", }, body: await readFile(fixturePath, "utf8"), diff --git a/apps/brunch-agent/test/transport-aisdk-server.test.ts b/apps/brunch-agent/test/transport-aisdk-server.test.ts index cecfb9042e9..e8cdee84f54 100644 --- a/apps/brunch-agent/test/transport-aisdk-server.test.ts +++ b/apps/brunch-agent/test/transport-aisdk-server.test.ts @@ -128,7 +128,10 @@ describe("FE-1436 Petrinaut wire server", () => { const response = await handler( new Request("http://brunch.test/api/petrinaut/chat", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + "x-brunch-principal": "principal-malformed-request", + }, body: JSON.stringify(body), }), ); @@ -177,7 +180,10 @@ describe("FE-1436 Petrinaut wire server", () => { const response = await handler( new Request("http://brunch.test/api/petrinaut/chat", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + "x-brunch-principal": `principal-${terminalState}`, + }, body: JSON.stringify({ id: `conversation-${terminalState}`, trigger: "submit-message", @@ -254,7 +260,10 @@ describe("FE-1436 Petrinaut wire server", () => { const response = await handler( new Request("http://brunch.test/api/petrinaut/chat", { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + "x-brunch-principal": "principal-tool-failed", + }, body: JSON.stringify({ id: "conversation-tool-failed", trigger: "submit-message", @@ -309,6 +318,9 @@ describe("FE-1436 Petrinaut wire server", () => { expect(allowed.headers.get("access-control-allow-methods")).toBe( "POST, OPTIONS", ); + expect(allowed.headers.get("access-control-allow-headers")).toContain( + "x-brunch-principal", + ); const refused = await handler( new Request("http://brunch.test/api/petrinaut/chat", { @@ -328,6 +340,7 @@ describe("FE-1436 Petrinaut wire server", () => { expect(input).toEqual({ conversationId: "m5z0GU9KJPzhOTlx", idempotencyKey: "m5z0GU9KJPzhOTlx:6ddgGkjhSxGjOtiv", + principalKey: "principal-fe1436", userMessage: { id: "6ddgGkjhSxGjOtiv", text: "Run the FE-1435 transport probe.", @@ -342,6 +355,7 @@ describe("FE-1436 Petrinaut wire server", () => { method: "POST", headers: { "content-type": "application/json", + "x-brunch-principal": "principal-fe1436", "x-request-id": "request-fe1436-contract", }, body: fixture("panel-initial.post.json"), @@ -418,6 +432,7 @@ describe("FE-1436 Petrinaut wire server", () => { headers: { "content-type": "application/json", origin: "http://127.0.0.1:4915", + "x-brunch-principal": "principal-fe1436", }, body: fixture("panel-tool-results.post.json"), }), diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-principal.test.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-principal.test.ts new file mode 100644 index 00000000000..f71a3a82591 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-principal.test.ts @@ -0,0 +1,21 @@ +import { expect, test, vi } from "vitest"; + +import { getOrCreateBrunchPrincipal } from "./brunch-principal"; + +test("reuses one UI-shell principal across transport requests and reloads", () => { + const storedPrincipalsByKey = new Map(); + const storage = { + getItem: (key: string) => storedPrincipalsByKey.get(key) ?? null, + setItem: (key: string, value: string) => + storedPrincipalsByKey.set(key, value), + }; + const createPrincipal = vi.fn(() => "principal-created-once"); + + expect(getOrCreateBrunchPrincipal(storage, createPrincipal)).toBe( + "principal-created-once", + ); + expect(getOrCreateBrunchPrincipal(storage, createPrincipal)).toBe( + "principal-created-once", + ); + expect(createPrincipal).toHaveBeenCalledOnce(); +}); diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-principal.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-principal.ts new file mode 100644 index 00000000000..7fc4861b0c2 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-principal.ts @@ -0,0 +1,20 @@ +const principalStorageKey = "brunch-principal-v1"; + +interface PrincipalStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export const getOrCreateBrunchPrincipal = ( + storage: PrincipalStorage = window.localStorage, + createPrincipal: () => string = () => crypto.randomUUID(), +): string => { + const existingPrincipal = storage.getItem(principalStorageKey); + if (existingPrincipal) { + return existingPrincipal; + } + + const principal = createPrincipal(); + storage.setItem(principalStorageKey, principal); + return principal; +}; diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx index 6f770fc9602..81d6b3bbf22 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/local-storage-demo-app.tsx @@ -1,6 +1,7 @@ import { produce } from "immer"; import { useEffect, useMemo, useState } from "react"; +import { BRUNCH_PRINCIPAL_HEADER } from "@hashintel/brunch-agent-transport-aisdk/headers"; import { createJsonDocHandle, type MinimalNetMetadata, @@ -18,6 +19,7 @@ import { import { useSentryFeedbackAction } from "../sentry-feedback-button"; import { brunchAskInteractiveTool } from "./brunch-ask-interactive-tool"; +import { getOrCreateBrunchPrincipal } from "./brunch-principal"; import { useLocalStorageAiMessages } from "./use-local-storage-ai-messages"; import { type SDCPNInLocalStorage, @@ -83,6 +85,9 @@ const createHandle = (net: SDCPNInLocalStorage): PetrinautDocHandle => const petrinautAiChatTransport: PetrinautAiChatTransport = new DefaultChatTransport({ api: "/api/chat", + headers: () => ({ + [BRUNCH_PRINCIPAL_HEADER]: getOrCreateBrunchPrincipal(), + }), }); const getStoredSDCPNsForDisplay = ( diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/src/local-capture-store.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/src/local-capture-store.ts index 4e947830fc8..c9ef95860fd 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/src/local-capture-store.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/src/local-capture-store.ts @@ -25,18 +25,23 @@ import { import { registerArchiveWriter } from "./archive-capability"; -const FORMAT_VERSION = 1 as const; +const FORMAT_VERSION = 2 as const; +const LEGACY_FORMAT_VERSION = 1 as const; interface TargetDocumentRecord { readonly formatVersion: typeof FORMAT_VERSION; + readonly ownerKey: string | null; readonly captureStore: CaptureStoreSnapshot; readonly sessionLogArchive: SessionLogArchive; } const writesByPath = new Map>(); -const createEmptyTargetDocument = (): TargetDocumentRecord => ({ +const createEmptyTargetDocument = ( + ownerKey: string | null, +): TargetDocumentRecord => ({ formatVersion: FORMAT_VERSION, + ownerKey, captureStore: createEmptyCaptureStoreSnapshot(), sessionLogArchive: createEmptySessionLogArchive(), }); @@ -47,20 +52,41 @@ const isRecord = (value: unknown): value is Record => const parseTargetDocument = (input: unknown): TargetDocumentRecord => { if (isRecord(input) && "formatVersion" in input) { const fields = Object.keys(input).sort(); + if (input.formatVersion === FORMAT_VERSION) { + if ( + JSON.stringify(fields) !== + JSON.stringify([ + "captureStore", + "formatVersion", + "ownerKey", + "sessionLogArchive", + ]) || + (typeof input.ownerKey !== "string" && input.ownerKey !== null) + ) { + throw new TypeError("Invalid target-document ownership record."); + } + return { + formatVersion: FORMAT_VERSION, + ownerKey: input.ownerKey, + captureStore: parseCaptureStoreSnapshot(input.captureStore), + sessionLogArchive: parseSessionLogArchive(input.sessionLogArchive), + }; + } if ( - input.formatVersion !== FORMAT_VERSION || - JSON.stringify(fields) !== + input.formatVersion === LEGACY_FORMAT_VERSION && + JSON.stringify(fields) === JSON.stringify(["captureStore", "formatVersion", "sessionLogArchive"]) ) { - throw new TypeError( - `Unsupported target-document format version ${String(input.formatVersion)}.`, - ); + return { + formatVersion: FORMAT_VERSION, + ownerKey: null, + captureStore: parseCaptureStoreSnapshot(input.captureStore), + sessionLogArchive: parseSessionLogArchive(input.sessionLogArchive), + }; } - return { - formatVersion: FORMAT_VERSION, - captureStore: parseCaptureStoreSnapshot(input.captureStore), - sessionLogArchive: parseSessionLogArchive(input.sessionLogArchive), - }; + throw new TypeError( + `Unsupported target-document format version ${String(input.formatVersion)}.`, + ); } // FE-1390 files predate the archive slot. Reading that exact capture-store @@ -68,16 +94,28 @@ const parseTargetDocument = (input: unknown): TargetDocumentRecord => { // rewrites it atomically in the current format. return { formatVersion: FORMAT_VERSION, + ownerKey: null, captureStore: parseCaptureStoreSnapshot(input), sessionLogArchive: createEmptySessionLogArchive(), }; }; +class TargetDocumentOwnerMismatchError extends Error { + readonly code = "target-document-owner-mismatch"; + + constructor() { + super("The target document is owned by a different principal."); + this.name = "TargetDocumentOwnerMismatchError"; + } +} + class LocalCaptureStore implements CaptureStore { + readonly #ownerKey: string | null; readonly #path: string; - constructor(path: string) { + constructor(path: string, ownerKey: string | null) { this.#path = resolve(path); + this.#ownerKey = ownerKey; registerArchiveWriter(this, (read) => this.#archiveSessionLog(read)); } @@ -163,16 +201,20 @@ class LocalCaptureStore implements CaptureStore { async #readFile(): Promise { try { - return parseTargetDocument( + const document = parseTargetDocument( JSON.parse(await readFile(this.#path, "utf8")), ); + if (document.ownerKey !== this.#ownerKey) { + throw new TargetDocumentOwnerMismatchError(); + } + return document; } catch (error) { if ( error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT" ) { - return createEmptyTargetDocument(); + return createEmptyTargetDocument(this.#ownerKey); } throw error; } @@ -193,5 +235,12 @@ class LocalCaptureStore implements CaptureStore { } } -export const createLocalCaptureStore = (path: string): CaptureStore => - new LocalCaptureStore(path); +export const createLocalCaptureStore = ( + path: string, + options: { readonly ownerKey?: string } = {}, +): CaptureStore => { + if (options.ownerKey !== undefined && options.ownerKey.length === 0) { + throw new TypeError("A target-document owner key cannot be empty."); + } + return new LocalCaptureStore(path, options.ownerKey ?? null); +}; diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/test/history-reader.test.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/test/history-reader.test.ts index a46c48a5cba..294fc08da0e 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/test/history-reader.test.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/test/history-reader.test.ts @@ -298,11 +298,13 @@ describe("Flue materialized-history reader", () => { const persisted = JSON.parse(await readFile(path, "utf8")) as { formatVersion: number; + ownerKey: string | null; sessionLogArchive: { sessions: { reads: { substrateConversationId?: string }[] }[]; }; }; - expect(persisted.formatVersion).toBe(1); + expect(persisted.formatVersion).toBe(2); + expect(persisted.ownerKey).toBeNull(); expect(persisted.sessionLogArchive.sessions).toHaveLength(1); expect( persisted.sessionLogArchive.sessions[0]!.reads[0]! diff --git a/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts index 8e7ac59bafe..209025b69b7 100644 --- a/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts +++ b/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts @@ -77,6 +77,49 @@ const storePath = async (): Promise => { }; describe("local capture store", () => { + test("refuses a different opaque owner before reading or writing a target document", async () => { + const path = await storePath(); + const ownerStore = createLocalCaptureStoreAdapter(path, { + ownerKey: "principal-a", + }); + await archiveThroughBinding(ownerStore, { + sessionId: "session-a", + offset: "0", + entries: [], + settlements: [], + }); + await archiveThroughBinding(ownerStore, { + sessionId: "session-c", + offset: "0", + entries: [], + settlements: [], + }); + + const intruderStore = createLocalCaptureStoreAdapter(path, { + ownerKey: "principal-b", + }); + await expect(intruderStore.read()).rejects.toMatchObject({ + code: "target-document-owner-mismatch", + }); + await expect( + archiveThroughBinding(intruderStore, { + sessionId: "session-b", + offset: "0", + entries: [], + settlements: [], + }), + ).rejects.toMatchObject({ + code: "target-document-owner-mismatch", + }); + + expect(JSON.parse(await readFile(path, "utf8"))).toMatchObject({ + ownerKey: "principal-a", + sessionLogArchive: { + sessions: [{ sessionId: "session-a" }, { sessionId: "session-c" }], + }, + }); + }); + test("persists captures through JSON tmp-and-rename without stored statuses", async () => { const path = await storePath(); const first = createLocalCaptureStore(path); @@ -242,7 +285,8 @@ describe("local capture store", () => { }); expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ - formatVersion: 1, + formatVersion: 2, + ownerKey: null, captureStore: legacy, sessionLogArchive: { sessions: [ diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json b/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json index 1c0f37880ff..9124c2db255 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/package.json @@ -13,6 +13,10 @@ "./client-tools": { "types": "./src/client-tools.ts", "import": "./dist/client-tools.js" + }, + "./headers": { + "types": "./src/headers.ts", + "import": "./dist/headers.js" } }, "scripts": { diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/headers.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/headers.ts new file mode 100644 index 00000000000..965e5feef1c --- /dev/null +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/headers.ts @@ -0,0 +1,2 @@ +/** Stable browser-to-Brunch principal header owned by the HTTP transport. */ +export const BRUNCH_PRINCIPAL_HEADER = "x-brunch-principal"; diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts index 2fda5f55683..8060ec11089 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/src/index.ts @@ -19,11 +19,13 @@ import { } from "@hashintel/brunch-agent"; import { ASK_TOOL_NAME, type BrunchAskOutput } from "./client-tools"; +import { BRUNCH_PRINCIPAL_HEADER } from "./headers"; export { type AskReplyAdmission, type HarnessReplyEvent, } from "@hashintel/brunch-agent"; +export { BRUNCH_PRINCIPAL_HEADER } from "./headers"; export { ASK_TOOL_NAME, type BrunchAskInput, @@ -35,6 +37,7 @@ export { export interface HarnessTurnInput { readonly conversationId: string; readonly idempotencyKey: string; + readonly principalKey: string; readonly userMessage: { readonly id: string; readonly text: string; @@ -65,6 +68,7 @@ export interface HarnessAskReplyInput { /** Existing assistant UI message whose pending tool call this continues. */ readonly assistantMessageId: string; readonly idempotencyKey: string; + readonly principalKey: string; readonly ask: BrunchAskOutput & { readonly toolCallId: string; }; @@ -183,6 +187,11 @@ const transportRequestRefusals = { status: 400, error: "invalid_chat_request", }, + invalidPrincipal: { + reason: "invalid-principal", + status: 400, + error: "invalid_principal", + }, toolResultFollowUpNotSupported: { reason: "tool-result-follow-up-not-supported", status: 422, @@ -213,7 +222,7 @@ const corsHeaders = (origin: string): Headers => new Headers({ "access-control-allow-origin": origin, "access-control-allow-methods": "POST, OPTIONS", - "access-control-allow-headers": "content-type, x-request-id", + "access-control-allow-headers": `content-type, x-request-id, ${BRUNCH_PRINCIPAL_HEADER}`, vary: "Origin", }); @@ -258,7 +267,10 @@ const isAnsweredAskPart = ( * Petrinaut mutation outputs, the synthetic diagnostics message — remains * the machine-input protocol this transport still refuses (FE-1438 owns it). */ -const parseAskReplyTurn = (body: PanelPostBody): ParsedTransportRequest => { +const parseAskReplyTurn = ( + body: PanelPostBody, + principalKey: string, +): ParsedTransportRequest => { if ( typeof body.id !== "string" || body.id.length === 0 || @@ -306,12 +318,16 @@ const parseAskReplyTurn = (body: PanelPostBody): ParsedTransportRequest => { // Keyed by the ask itself: concurrent duplicate submissions of the same // pending ask collapse to one dispatch at the substrate. idempotencyKey: `${body.id}:ask:${askPart.toolCallId}`, + principalKey, ask: { toolCallId: askPart.toolCallId, answer: submission.output.answer }, }, }; }; -const parseInitialTurn = (body: PanelPostBody): ParsedTransportRequest => { +const parseInitialTurn = ( + body: PanelPostBody, + principalKey: string, +): ParsedTransportRequest => { if ( typeof body.id !== "string" || body.id.length === 0 || @@ -354,6 +370,7 @@ const parseInitialTurn = (body: PanelPostBody): ParsedTransportRequest => { value: { conversationId: body.id, idempotencyKey: `${body.id}:${message.id}`, + principalKey, userMessage: { id: message.id, text }, }, }; @@ -512,17 +529,30 @@ export const createAiSdkChatHandler = ); } const postBody = validatedBody.output; + const principalKey = request.headers.get(BRUNCH_PRINCIPAL_HEADER)?.trim(); + if ( + principalKey === undefined || + principalKey.length === 0 || + principalKey.length > 256 + ) { + const refusal = transportRequestRefusals.invalidPrincipal; + return jsonResponse( + { error: refusal.error }, + refusal.status, + crossOriginHeaders, + ); + } // The follow-up admits exactly the pending ask's correlated human answer; // absent an application ask-reply seam, every follow-up stays refused. const parsed = postBody.messageId !== undefined && options.askReply !== undefined - ? parseAskReplyTurn(postBody) + ? parseAskReplyTurn(postBody, principalKey) : postBody.messageId !== undefined ? ({ kind: "refused", refusal: transportRequestRefusals.toolResultFollowUpNotSupported, } as const) - : parseInitialTurn(postBody); + : parseInitialTurn(postBody, principalKey); if (parsed.kind === "refused") { return jsonResponse( { error: parsed.refusal.error }, diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts index e66c0674b51..cfa3e5ace5e 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/test/ask-reply.test.ts @@ -42,6 +42,7 @@ const post = (body: unknown): Request => method: "POST", headers: { "content-type": "application/json", + "x-brunch-principal": "principal-fe1449", "x-request-id": "request-fe1449", }, body: typeof body === "string" ? body : JSON.stringify(body), @@ -135,6 +136,37 @@ const resumedTurn: AskReplyHandler["run"] = async (_input, emit) => { }); }; +test("refuses a valid turn without the UI shell principal", async () => { + let dispatched = false; + const handler = createAiSdkChatHandler({ + async runTurn() { + dispatched = true; + }, + }); + + const response = await handler( + new Request("http://brunch.test/api/petrinaut/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + id: "conversation-without-principal", + trigger: "submit-message", + messages: [ + { + id: "user-without-principal", + role: "user", + parts: [{ type: "text", text: "Help me model checkout." }], + }, + ], + }), + }), + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "invalid_principal" }); + expect(dispatched).toBe(false); +}); + describe("FE-1449 ask suspension on the wire", () => { test("translates the ask as an awaiting client tool and withholds the affordance output", async () => { const inspections: TransportInspectionEvent[] = []; @@ -208,6 +240,7 @@ describe("FE-1449 ask return POST", () => { expect(admitted).toEqual([ { conversationId: "conversation-fe1449", + principalKey: "principal-fe1449", assistantMessageId: "assistant-fe1449-1", idempotencyKey: "conversation-fe1449:ask:tool-ask-fe1449", ask: { diff --git a/libs/@hashintel/brunch-agent/packages/transport-aisdk/vite.config.ts b/libs/@hashintel/brunch-agent/packages/transport-aisdk/vite.config.ts index 77a7a9fe479..369dc937a5d 100644 --- a/libs/@hashintel/brunch-agent/packages/transport-aisdk/vite.config.ts +++ b/libs/@hashintel/brunch-agent/packages/transport-aisdk/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "client-tools": fileURLToPath( new URL("src/client-tools.ts", import.meta.url), ), + headers: fileURLToPath(new URL("src/headers.ts", import.meta.url)), index: fileURLToPath(new URL("src/index.ts", import.meta.url)), }, fileName: (_format, entryName) => `${entryName}.js`, diff --git a/package.json b/package.json index 7c07af5d451..f1a9e5ffd88 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "dev": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --filter '@apps/hash-frontend' --", "dev:backend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", "dev:backend:api": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-api' --", + "dev:brunch": "npm-run-all --parallel dev:brunch:server dev:brunch:panel", + "dev:brunch:panel": "PETRINAUT_WEBSITE_ROOT=\"$PWD/apps/petrinaut-website\" yarn workspace @apps/brunch-agent petrinaut:dev", + "dev:brunch:server": "yarn workspace @apps/brunch-agent dev", "dev:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-frontend' --", "dev:petrinaut-optimization": "yarn workspace @apps/petrinaut-website dev:optimization", "fix": "npm-run-all --continue-on-error \"fix:*\"",