From 77859444b9419c9349d6af2912b03771a083fedd Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 26 Aug 2026 15:37:56 +0200 Subject: [PATCH 1/4] Refuse cross-principal target-document access Stamp local target documents with an opaque owner key so multiple sessions can share one document without allowing another principal to read or mutate it. Co-authored-by: Cursor --- .../binding-flue/src/local-capture-store.ts | 83 +++++++++++++++---- .../test/local-capture-store.test.ts | 46 +++++++++- 2 files changed, 111 insertions(+), 18 deletions(-) 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/local-capture-store.test.ts b/libs/@hashintel/brunch-agent/packages/binding-flue/test/local-capture-store.test.ts index 8e7ac59bafe..9488f67f714 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 owner = createLocalCaptureStoreAdapter(path, { + ownerKey: "principal-a", + }); + await archiveThroughBinding(owner, { + sessionId: "session-a", + offset: "0", + entries: [], + settlements: [], + }); + await archiveThroughBinding(owner, { + sessionId: "session-c", + offset: "0", + entries: [], + settlements: [], + }); + + const intruder = createLocalCaptureStoreAdapter(path, { + ownerKey: "principal-b", + }); + await expect(intruder.read()).rejects.toMatchObject({ + code: "target-document-owner-mismatch", + }); + await expect( + archiveThroughBinding(intruder, { + 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: [ From 851ae0471c3dc836379172997459c224546a750f Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 26 Aug 2026 15:44:11 +0200 Subject: [PATCH 2/4] Carry panel principals into owned documents Reuse one UI-shell identity across reloads and require it at the HTTP boundary so Brunch can resolve every panel session to the same owner-scoped document. Co-authored-by: Cursor --- .../src/agents/gherkin-elicitor.ts | 7 +++- .../brunch-agent/src/agents/sdcpn-elicitor.ts | 7 +++- apps/brunch-agent/src/elicitation-session.ts | 8 +++- apps/brunch-agent/src/petrinaut-chat.ts | 17 +++++--- .../test/petrinaut-ask.integration.ts | 1 + .../test/petrinaut-chat.integration.ts | 1 + .../test/transport-aisdk-server.test.ts | 21 ++++++++-- .../brunch-principal.test.ts | 20 ++++++++++ .../local-storage-demo/brunch-principal.ts | 20 ++++++++++ .../local-storage-demo-app.tsx | 5 +++ .../packages/transport-aisdk/package.json | 4 ++ .../packages/transport-aisdk/src/headers.ts | 2 + .../packages/transport-aisdk/src/index.ts | 40 ++++++++++++++++--- .../transport-aisdk/test/ask-reply.test.ts | 33 +++++++++++++++ .../packages/transport-aisdk/vite.config.ts | 1 + 15 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 apps/petrinaut-website/src/main/app/local-storage-demo/brunch-principal.test.ts create mode 100644 apps/petrinaut-website/src/main/app/local-storage-demo/brunch-principal.ts create mode 100644 libs/@hashintel/brunch-agent/packages/transport-aisdk/src/headers.ts 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..9da069b8d6b 100644 --- a/apps/brunch-agent/src/elicitation-session.ts +++ b/apps/brunch-agent/src/elicitation-session.ts @@ -27,9 +27,11 @@ const createElicitationSession = ( target: AgentTarget, sessionId: string, targetDocumentId: string, + ownerKey?: string, ): ElicitationSession => { const captureStore = createLocalCaptureStore( targetDocumentPath(targetDocumentId), + ownerKey === undefined ? {} : { ownerKey }, ); return { sessionId, @@ -46,11 +48,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..42239daed30 100644 --- a/apps/brunch-agent/src/petrinaut-chat.ts +++ b/apps/brunch-agent/src/petrinaut-chat.ts @@ -29,12 +29,11 @@ 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 targetDocumentIdFor = (principalKey: string): string => + `petrinaut-local:${principalKey}`; const streamElicitorTurn = async ( + principalKey: string, conversationId: string, dispatch: { readonly message: string; readonly idempotencyKey: string }, emit: (event: HarnessReplyEvent) => void, @@ -42,7 +41,10 @@ const streamElicitorTurn = async ( const agent = init(GherkinElicitor, { id: conversationId }); const receipt = await agent.dispatch({ ...dispatch, - initialData: { targetDocumentId: targetDocumentIdFor(conversationId) }, + initialData: { + ownerKey: principalKey, + targetDocumentId: targetDocumentIdFor(principalKey), + }, }); const projector = createFlueReplyProjector({ submissionId: receipt.submissionId, @@ -61,6 +63,7 @@ export const petrinautChatHandler = createAiSdkChatHandler({ inspect, runTurn: (input, emit) => streamElicitorTurn( + input.principalKey, input.conversationId, { message: input.userMessage.text, idempotencyKey: input.idempotencyKey }, emit, @@ -72,7 +75,8 @@ export const petrinautChatHandler = createAiSdkChatHandler({ async admit(input) { const session = createGherkinElicitationSession( input.conversationId, - targetDocumentIdFor(input.conversationId), + targetDocumentIdFor(input.principalKey), + input.principalKey, ); const entries = projectFlueHistoryForSweep( await session.historyReader.peek(input.conversationId), @@ -86,6 +90,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/petrinaut-ask.integration.ts b/apps/brunch-agent/test/petrinaut-ask.integration.ts index ae6033c43ec..43ab3892d6e 100644 --- a/apps/brunch-agent/test/petrinaut-ask.integration.ts +++ b/apps/brunch-agent/test/petrinaut-ask.integration.ts @@ -80,6 +80,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..92dd3e988ce 100644 --- a/apps/brunch-agent/test/petrinaut-chat.integration.ts +++ b/apps/brunch-agent/test/petrinaut-chat.integration.ts @@ -60,6 +60,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..c441701bcd9 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/brunch-principal.test.ts @@ -0,0 +1,20 @@ +import { expect, test, vi } from "vitest"; + +import { getOrCreateBrunchPrincipal } from "./brunch-principal"; + +test("reuses one UI-shell principal across transport requests and reloads", () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.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..fb4d7cee1c6 --- /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 existing = storage.getItem(principalStorageKey); + if (existing) { + return existing; + } + + 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/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`, From 2ebe4ec3a8812c1914e624c2dfae4ccd583a3294 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 26 Aug 2026 15:46:10 +0200 Subject: [PATCH 3/4] Run the process-model elicitor from Petrinaut Route panel turns and ask replies through the SDCPN elicitor and provide one documented root command that starts the Brunch server with the real Petrinaut panel. Co-authored-by: Cursor --- apps/brunch-agent/README.md | 17 ++++++++++++++++ .../petrinaut-local.vite.config.ts | 10 ++++------ apps/brunch-agent/src/petrinaut-chat.ts | 8 ++++---- .../test/local-dev-origins.test.ts | 20 +++++++++++++++++++ .../test/petrinaut-ask.integration.ts | 9 +++------ .../test/petrinaut-chat.integration.ts | 9 +++------ package.json | 3 +++ 7 files changed, 54 insertions(+), 22 deletions(-) create mode 100644 apps/brunch-agent/README.md 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..7ddedc6b830 100644 --- a/apps/brunch-agent/petrinaut-local.vite.config.ts +++ b/apps/brunch-agent/petrinaut-local.vite.config.ts @@ -8,6 +8,7 @@ */ import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig, @@ -39,12 +40,9 @@ 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.", - ); - } + const websiteRoot = + process.env.PETRINAUT_WEBSITE_ROOT ?? + fileURLToPath(new URL("../petrinaut-website/", import.meta.url)); const root = resolve(websiteRoot); // Babel resolves the React compiler plugin from the launched project's cwd, // not from the imported config file. Match a native hash launch before the diff --git a/apps/brunch-agent/src/petrinaut-chat.ts b/apps/brunch-agent/src/petrinaut-chat.ts index 42239daed30..a2147d40bbd 100644 --- a/apps/brunch-agent/src/petrinaut-chat.ts +++ b/apps/brunch-agent/src/petrinaut-chat.ts @@ -16,8 +16,8 @@ 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 } from "./elicitation-session.ts"; import { defaultPanelOrigins } from "./local-dev-origins.ts"; const inspect = @@ -38,7 +38,7 @@ const streamElicitorTurn = async ( dispatch: { readonly message: string; readonly idempotencyKey: string }, emit: (event: HarnessReplyEvent) => void, ): Promise => { - const agent = init(GherkinElicitor, { id: conversationId }); + const agent = init(SdcpnElicitor, { id: conversationId }); const receipt = await agent.dispatch({ ...dispatch, initialData: { @@ -73,7 +73,7 @@ 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 session = createSdcpnElicitationSession( input.conversationId, targetDocumentIdFor(input.principalKey), input.principalKey, diff --git a/apps/brunch-agent/test/local-dev-origins.test.ts b/apps/brunch-agent/test/local-dev-origins.test.ts index 26eec099d35..ec26a49b8ef 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( + "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 43ab3892d6e..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], }); diff --git a/apps/brunch-agent/test/petrinaut-chat.integration.ts b/apps/brunch-agent/test/petrinaut-chat.integration.ts index 92dd3e988ce..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], }); diff --git a/package.json b/package.json index 7c07af5d451..399d4f0fce3 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": "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:*\"", From 96b0b9b749d66d4b6c703a38f28eda9f8ba0ef70 Mon Sep 17 00:00:00 2001 From: Lu Nelson Date: Wed, 26 Aug 2026 15:57:18 +0200 Subject: [PATCH 4/4] Bind panel sessions to principals Namespace Flue sessions by the panel principal so client conversation IDs cannot cross ownership boundaries, and keep repository layout knowledge in the root launcher. Co-authored-by: Cursor --- .../petrinaut-local.vite.config.ts | 8 ++--- apps/brunch-agent/src/elicitation-session.ts | 15 ++++++++++ apps/brunch-agent/src/petrinaut-chat.ts | 30 ++++++++++++------- .../test/elicitation-session.test.ts | 25 ++++++++++++++++ .../test/local-dev-origins.test.ts | 2 +- .../brunch-principal.test.ts | 7 +++-- .../local-storage-demo/brunch-principal.ts | 6 ++-- .../binding-flue/test/history-reader.test.ts | 4 ++- .../test/local-capture-store.test.ts | 12 ++++---- package.json | 2 +- 10 files changed, 81 insertions(+), 30 deletions(-) create mode 100644 apps/brunch-agent/test/elicitation-session.test.ts diff --git a/apps/brunch-agent/petrinaut-local.vite.config.ts b/apps/brunch-agent/petrinaut-local.vite.config.ts index 7ddedc6b830..58b2973bda7 100644 --- a/apps/brunch-agent/petrinaut-local.vite.config.ts +++ b/apps/brunch-agent/petrinaut-local.vite.config.ts @@ -8,7 +8,6 @@ */ import { join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { defineConfig, @@ -40,9 +39,10 @@ const withoutIncumbentChatHandler = ( }); export default defineConfig(async (environment) => { - const websiteRoot = - process.env.PETRINAUT_WEBSITE_ROOT ?? - fileURLToPath(new URL("../petrinaut-website/", import.meta.url)); + const websiteRoot = process.env.PETRINAUT_WEBSITE_ROOT; + if (!websiteRoot) { + throw new Error("PETRINAUT_WEBSITE_ROOT is required."); + } const root = resolve(websiteRoot); // Babel resolves the React compiler plugin from the launched project's cwd, // not from the imported config file. Match a native hash launch before the diff --git a/apps/brunch-agent/src/elicitation-session.ts b/apps/brunch-agent/src/elicitation-session.ts index 9da069b8d6b..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, diff --git a/apps/brunch-agent/src/petrinaut-chat.ts b/apps/brunch-agent/src/petrinaut-chat.ts index a2147d40bbd..498049d22a2 100644 --- a/apps/brunch-agent/src/petrinaut-chat.ts +++ b/apps/brunch-agent/src/petrinaut-chat.ts @@ -17,7 +17,10 @@ import { } from "@hashintel/brunch-agent-transport-aisdk"; import { SdcpnElicitor } from "./agents/sdcpn-elicitor.ts"; -import { createSdcpnElicitationSession } from "./elicitation-session.ts"; +import { + createSdcpnElicitationSession, + resolvePetrinautSessionIdentity, +} from "./elicitation-session.ts"; import { defaultPanelOrigins } from "./local-dev-origins.ts"; const inspect = @@ -29,21 +32,22 @@ const inspect = } : undefined; -const targetDocumentIdFor = (principalKey: string): string => - `petrinaut-local:${principalKey}`; - const streamElicitorTurn = async ( principalKey: string, conversationId: string, dispatch: { readonly message: string; readonly idempotencyKey: string }, emit: (event: HarnessReplyEvent) => void, ): Promise => { - const agent = init(SdcpnElicitor, { id: conversationId }); + const identity = resolvePetrinautSessionIdentity( + principalKey, + conversationId, + ); + const agent = init(SdcpnElicitor, { id: identity.sessionId }); const receipt = await agent.dispatch({ ...dispatch, initialData: { - ownerKey: principalKey, - targetDocumentId: targetDocumentIdFor(principalKey), + ownerKey: identity.ownerKey, + targetDocumentId: identity.targetDocumentId, }, }); const projector = createFlueReplyProjector({ @@ -73,13 +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 = createSdcpnElicitationSession( - input.conversationId, - targetDocumentIdFor(input.principalKey), + const identity = resolvePetrinautSessionIdentity( input.principalKey, + 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), 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 ec26a49b8ef..342c794f530 100644 --- a/apps/brunch-agent/test/local-dev-origins.test.ts +++ b/apps/brunch-agent/test/local-dev-origins.test.ts @@ -28,7 +28,7 @@ test("one documented root command starts the Brunch server and Petrinaut panel", "yarn workspace @apps/brunch-agent dev", ); expect(rootPackage.scripts["dev:brunch:panel"]).toBe( - "yarn workspace @apps/brunch-agent petrinaut:dev", + 'PETRINAUT_WEBSITE_ROOT="$PWD/apps/petrinaut-website" yarn workspace @apps/brunch-agent petrinaut:dev', ); expect(readAppFile("README.md")).toContain("yarn dev:brunch"); }); 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 index c441701bcd9..f71a3a82591 100644 --- 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 @@ -3,10 +3,11 @@ import { expect, test, vi } from "vitest"; import { getOrCreateBrunchPrincipal } from "./brunch-principal"; test("reuses one UI-shell principal across transport requests and reloads", () => { - const values = new Map(); + const storedPrincipalsByKey = new Map(); const storage = { - getItem: (key: string) => values.get(key) ?? null, - setItem: (key: string, value: string) => values.set(key, value), + getItem: (key: string) => storedPrincipalsByKey.get(key) ?? null, + setItem: (key: string, value: string) => + storedPrincipalsByKey.set(key, value), }; const createPrincipal = vi.fn(() => "principal-created-once"); 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 index fb4d7cee1c6..7fc4861b0c2 100644 --- 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 @@ -9,9 +9,9 @@ export const getOrCreateBrunchPrincipal = ( storage: PrincipalStorage = window.localStorage, createPrincipal: () => string = () => crypto.randomUUID(), ): string => { - const existing = storage.getItem(principalStorageKey); - if (existing) { - return existing; + const existingPrincipal = storage.getItem(principalStorageKey); + if (existingPrincipal) { + return existingPrincipal; } const principal = createPrincipal(); 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 9488f67f714..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 @@ -79,30 +79,30 @@ 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 owner = createLocalCaptureStoreAdapter(path, { + const ownerStore = createLocalCaptureStoreAdapter(path, { ownerKey: "principal-a", }); - await archiveThroughBinding(owner, { + await archiveThroughBinding(ownerStore, { sessionId: "session-a", offset: "0", entries: [], settlements: [], }); - await archiveThroughBinding(owner, { + await archiveThroughBinding(ownerStore, { sessionId: "session-c", offset: "0", entries: [], settlements: [], }); - const intruder = createLocalCaptureStoreAdapter(path, { + const intruderStore = createLocalCaptureStoreAdapter(path, { ownerKey: "principal-b", }); - await expect(intruder.read()).rejects.toMatchObject({ + await expect(intruderStore.read()).rejects.toMatchObject({ code: "target-document-owner-mismatch", }); await expect( - archiveThroughBinding(intruder, { + archiveThroughBinding(intruderStore, { sessionId: "session-b", offset: "0", entries: [], diff --git a/package.json b/package.json index 399d4f0fce3..f1a9e5ffd88 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "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": "yarn workspace @apps/brunch-agent petrinaut:dev", + "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",