From b86ae196d27c21d013343a2f7f65af131f2b5d63 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 15 Jul 2026 12:51:16 -0600 Subject: [PATCH 1/5] fix(chat-store): make bulk-delete workspace access checks deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bulkDeleteThreads iterated workspaces in DB row order of random hex thread ids, so which workspace's denial surfaced first varied per run — the unit test asserting the check sequence was flaky (caught by the vertical composition suite runs). Sort the distinct workspace ids before checking; fail-closed semantics are unchanged. --- src/chat-store/store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/chat-store/store.ts b/src/chat-store/store.ts index ba4e9ef..5c1a5b3 100644 --- a/src/chat-store/store.ts +++ b/src/chat-store/store.ts @@ -230,7 +230,9 @@ export function createChatStore( // Access is verified once per workspace the ids touch. Fail-closed: one // inaccessible workspace rejects the whole request before any delete. - const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))] + // Sorted so the check order (and therefore which denial surfaces) is + // deterministic — row order follows random hex ids and varies per run. + const workspaceIds = [...new Set(rows.map((row) => row.workspaceId))].sort() for (const workspaceId of workspaceIds) { await assertAccess(workspaceId) } From ee5e29e7e58a6f057f6e4a66ec91ea6a27ce25c5 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 15 Jul 2026 12:51:17 -0600 Subject: [PATCH 2/5] fix(interactions): type the persisted-part codec returns interactionToPersistedPart/noticePart returned Record, so every product pushing them into a ChatMessagePart[] transcript needed a cast, and the 'byte-matches' claim between the codecs and chat-store's ChatInteractionPart/ChatNoticePart was prose only. Return the precise InteractionPersistedPart/NoticePersistedPart shapes (type aliases, so Record consumers stay source-compatible) and enforce mutual assignability with the stored part types at compile time in chat-store/parts.ts. --- src/chat-store/parts.ts | 11 +++++++++++ src/interactions/contract.ts | 30 ++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/chat-store/parts.ts b/src/chat-store/parts.ts index e780796..fe8dc01 100644 --- a/src/chat-store/parts.ts +++ b/src/chat-store/parts.ts @@ -44,7 +44,9 @@ import type { Part as HarnessWirePart } from '@tangle-network/agent-interface' import type { ChatInteractionField, ChatInteractionStatus, + InteractionPersistedPart, NoticeKind, + NoticePersistedPart, } from '../web-react/chat-interactions' /** Start/end wall-clock millis, as normalized by `/stream`'s `normalizeTime`. */ @@ -168,6 +170,15 @@ export interface ChatNoticePart { text: string } +// The "byte-matches" claims above, enforced at compile time: the interaction +// contract's codec output types and the stored part types must stay mutually +// assignable, so a codec field added on one side without the other fails here. +type MutuallyAssignable = A +type _CodecEmitsStorableInteractionPart = MutuallyAssignable +type _StoredInteractionPartFeedsCodec = MutuallyAssignable +type _CodecEmitsStorableNoticePart = MutuallyAssignable +type _StoredNoticePartFeedsCodec = MutuallyAssignable + export type ChatMessagePart = | ChatTextPart | ChatReasoningPart diff --git a/src/interactions/contract.ts b/src/interactions/contract.ts index 521bd55..3f24038 100644 --- a/src/interactions/contract.ts +++ b/src/interactions/contract.ts @@ -226,9 +226,35 @@ export function noticePartKey(id: string): string { export type NoticeKind = 'warning' | 'auto-declined' +/** + * Persisted-part shapes the codecs below produce — the SAME rows + * `/chat-store`'s `ChatInteractionPart`/`ChatNoticePart` store, typed at the + * source so a product pushing them into a `ChatMessagePart[]` transcript needs + * no cast. Type aliases (not interfaces) on purpose: the implicit index + * signature keeps them assignable to the `Record` these + * codecs previously returned, so existing consumers stay source-compatible. + */ +export type InteractionPersistedPart = { + type: 'interaction' + id: string + kind: string + title: string + body?: string + answerSpec: { fields: ChatInteractionField[] } + status: ChatInteractionStatus + cancelReason?: string +} + +export type NoticePersistedPart = { + type: 'notice' + id: string + noticeKind: NoticeKind + text: string +} + /** Builds the persisted/streamed `notice` part — a one-line transcript notice * explaining an out-of-band event (warning, auto-declined interaction). */ -export function noticePart(noticeKind: NoticeKind, id: string, text: string): Record { +export function noticePart(noticeKind: NoticeKind, id: string, text: string): NoticePersistedPart { return { type: 'notice', id, noticeKind, text } } @@ -249,7 +275,7 @@ export function interactionToPersistedPart( request: InteractionRequestWire, status: ChatInteractionStatus, cancelReason?: string, -): Record { +): InteractionPersistedPart { return { type: 'interaction', id: request.id, From 5adf004c7be75f8a9b950091329cb4129f03535e Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 15 Jul 2026 12:51:17 -0600 Subject: [PATCH 3/5] fix(sandbox): make the provision-gate profile section structural ProvisionPayloadSections claimed to be structural but typed backend.profile as the SDK's AgentProfile, so even this module's own call site cast into its own gate type, and the SDK's string profile ref was rejected. Type the slice the gate actually reads (ProvisionProfileSection: resources.files) plus the string ref arm, and drop the internal cast. --- src/sandbox/index.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts index 2b2382b..0bec651 100644 --- a/src/sandbox/index.ts +++ b/src/sandbox/index.ts @@ -1035,12 +1035,22 @@ function utf8ByteLength(value: unknown): number { .byteLength } +/** Structural slice of the profile the payload gate reads: it only measures + * the profile's serialized size and names `resources.files` in the breakdown, + * so callers composing a payload outside the SDK (products, tests) can pass a + * plain object without casting through `AgentProfile`. */ +export interface ProvisionProfileSection { + resources?: { files?: readonly unknown[] } +} + /** The provision-payload sections the size gates need to see. Structural so * the gate is testable without the SDK's (unexported) create-payload type. */ export interface ProvisionPayloadSections { env?: Record secrets?: readonly string[] - backend?: { profile?: AgentProfile } + /** `profile` may also be a named-profile string ref (the SDK's + * `BackendConfig` union) — a string ref is tiny and has no files channel. */ + backend?: { profile?: string | ProvisionProfileSection } } /** @@ -1053,7 +1063,7 @@ export function assertProvisionPayloadWithinCap(payload: ProvisionPayloadSection const total = utf8ByteLength(payload) if (total <= PROVISION_PAYLOAD_MAX_BYTES) return const profile = payload.backend?.profile - const files = profile?.resources?.files ?? [] + const files = (typeof profile === 'string' ? undefined : profile?.resources?.files) ?? [] const breakdown = `profile=${utf8ByteLength(profile ?? null)}B ` + `(files=${utf8ByteLength(files)}B), ` + @@ -1549,7 +1559,9 @@ export async function ensureWorkspaceSandbox( // provision body can never produce a working sandbox — fail loud here, // before the POST, with the offending section named. assertEnvWithinLimits(env) - assertProvisionPayloadWithinCap(payload as ProvisionPayloadSections) + // `?? {}` only narrows the SDK parameter's `| undefined`; the literal above + // is always defined. The structural sections type needs no cast. + assertProvisionPayloadWithinCap(payload ?? {}) let box = await client.create(payload) From f12bea436962b3799befdbdd008861f7417d0676 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 15 Jul 2026 12:51:17 -0600 Subject: [PATCH 4/5] test(vertical): composition eval suite over the assembled chat modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mini chat app assembled ONLY from the shipped subpaths — createAppAuth (better-auth memoryAdapter) + createChatTables/createChatStore (:memory: better-sqlite3) + the #190 turn-boundary gates + a fake sandbox producer emitting canonical sidecar part streams pumped through /stream's normalizer into both the NDJSON client lane and the persisted parts lane + createInteractionAnswerRoute over a fake sidecar broker the producer genuinely blocks on. 15 scenario evals assert user-visible outcomes only: full turn persists parts + token receipts; failed turn keeps the user message and surfaces the error; interaction ask blocks the turn, rejects invalid answers fail-closed, unblocks on answer, and persists the answered card; re-emitted duplicate questions render one card and one answer resolves both; cross-workspace reads/answers/lists/deletes are denied with zero side effects; the three #190 gates fire client-visible 400s with their breakdown messages before any row is written. This suite is the composition gate for #189/#190/#191/#192: a subpath change that passes its unit tests but breaks assembly fails here. --- tests/vertical/fake-sidecar.ts | 137 ++++++++ tests/vertical/mini-app.ts | 449 ++++++++++++++++++++++++++ tests/vertical/vertical.test.ts | 548 ++++++++++++++++++++++++++++++++ 3 files changed, 1134 insertions(+) create mode 100644 tests/vertical/fake-sidecar.ts create mode 100644 tests/vertical/mini-app.ts create mode 100644 tests/vertical/vertical.test.ts diff --git a/tests/vertical/fake-sidecar.ts b/tests/vertical/fake-sidecar.ts new file mode 100644 index 0000000..9b70497 --- /dev/null +++ b/tests/vertical/fake-sidecar.ts @@ -0,0 +1,137 @@ +/** + * Fake sandbox sidecar for the vertical composition suite: ONE in-memory + * interaction registry that BOTH halves of `/interactions` talk to — + * + * - the producer half: the fake sandbox turn registers an ask via `ask()` + * and blocks on the returned promise (the broker semantics the real + * sidecar's InteractionBroker has); + * - the answer half: `createInteractionAnswerRoute` reaches the same + * registry through `connection.fetchImpl` (GET list / POST respond), so + * answering through the route genuinely releases the blocked turn. + * + * Answer validation is fail-closed like the real sidecar: an `accepted` + * outcome must satisfy the ask's `answerSpec` (a select without `allowCustom` + * only accepts listed option values; required fields must be present) or the + * POST fails 400 INVALID_INTERACTION_ANSWER. Unknown ids fail 404. + */ + +import type { + ChatSelectField, + InteractionData, + InteractionRequestWire, + SidecarInteractionsConnection, +} from '../../src/interactions/index' + +export interface InteractionResolutionRecord { + outcome: 'accepted' | 'declined' + data?: InteractionData +} + +interface Waiter { + resolve: (resolution: InteractionResolutionRecord) => void + promise: Promise +} + +function validateAcceptedAnswer(request: InteractionRequestWire, data: InteractionData | undefined): string | null { + for (const field of request.answerSpec.fields) { + const value = data?.[field.name] + if (value === undefined) { + if (field.required) return `missing required field ${field.name}` + continue + } + if (field.type === 'select') { + const select = field as ChatSelectField + if (select.allowCustom === true) continue + const values = Array.isArray(value) ? value : [String(value)] + const allowed = new Set(select.options.map((option) => option.value)) + for (const candidate of values) { + if (!allowed.has(String(candidate))) return `value ${String(candidate)} is not a listed option for ${field.name}` + } + } + } + return null +} + +export interface FakeSidecarSession { + /** Producer half: register an ask and get the broker promise the turn + * blocks on. */ + ask(request: InteractionRequestWire): Promise + /** Resolves when every currently-outstanding ask has been answered. */ + waitAll(): Promise> + /** Asks still waiting for an answer (the sidecar registry view). */ + outstandingIds(): string[] + /** How the answer route reaches this session. */ + connection: SidecarInteractionsConnection + /** Every HTTP call the answer route made, for hostile-integrator asserts. */ + calls: Array<{ method: string; body?: Record }> +} + +export function createFakeSidecarSession(sessionId = 'session-vertical'): FakeSidecarSession { + const outstanding = new Map() + const waiters = new Map() + const resolutions = new Map() + const calls: FakeSidecarSession['calls'] = [] + + function ask(request: InteractionRequestWire): Promise { + outstanding.set(request.id, request) + let resolve!: Waiter['resolve'] + const promise = new Promise((res) => { + resolve = res + }) + waiters.set(request.id, { resolve, promise }) + return promise + } + + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const method = init?.method ?? 'GET' + const body = init?.body ? (JSON.parse(String(init.body)) as Record) : undefined + calls.push({ method, ...(body ? { body } : {}) }) + void input + + if (method === 'GET') { + return Response.json({ data: { interactions: [...outstanding.values()] } }) + } + + const id = String(body?.id ?? '') + const request = outstanding.get(id) + if (!request) { + return new Response( + JSON.stringify({ error: { code: 'NOT_FOUND', message: 'interaction not found' } }), + { status: 404 }, + ) + } + const outcome = body?.outcome === 'declined' ? 'declined' : 'accepted' + const data = body?.data as InteractionData | undefined + if (outcome === 'accepted') { + const invalid = validateAcceptedAnswer(request, data) + if (invalid) { + return new Response( + JSON.stringify({ error: { code: 'INVALID_INTERACTION_ANSWER', message: invalid } }), + { status: 400 }, + ) + } + } + outstanding.delete(id) + const resolution: InteractionResolutionRecord = { outcome, ...(data ? { data } : {}) } + resolutions.set(id, resolution) + waiters.get(id)?.resolve(resolution) + waiters.delete(id) + return Response.json({ data: { ok: true } }) + }) as typeof fetch + + return { + ask, + async waitAll() { + await Promise.all([...waiters.values()].map((waiter) => waiter.promise)) + return new Map(resolutions) + }, + outstandingIds: () => [...outstanding.keys()], + connection: { + runtimeUrl: 'https://box.vertical.test/runtime', + authToken: 'tc_vertical-sidecar-bearer', + sessionId, + fetchImpl, + }, + calls, + } +} diff --git a/tests/vertical/mini-app.ts b/tests/vertical/mini-app.ts new file mode 100644 index 0000000..119e1ad --- /dev/null +++ b/tests/vertical/mini-app.ts @@ -0,0 +1,449 @@ +/** + * The assembled mini chat app for the vertical composition suite — a product + * built ONLY from the shipped subpaths, wired the way a real consumer wires + * them (no test doubles between the modules themselves): + * + * auth `createAppAuth` (better-auth memoryAdapter) + its guard quartet + * storage `createChatTables` + `createChatStore` over :memory: better-sqlite3 + * gates `assertSystemPromptWithinBudget` + `assertEnvWithinLimits` + + * `assertProvisionPayloadWithinCap` at the turn boundary (#190) + * turn a fake sandbox producer emitting canonical sidecar part events + * (`message.part.updated` snapshots + deltas, step-finish usage + * receipts, interaction asks, error) pumped through `/stream`'s + * normalizer into BOTH lanes: the NDJSON client stream + * (`/web-react` chat-stream line shapes) and the persisted + * assistant message parts + * asks `/interactions`: the producer blocks on the fake sidecar broker; + * `createInteractionAnswerRoute` answers through the same registry + * + * Access control composes app-auth with the store's injected seams: guards + * throw Responses (the router convention), the product membership check turns + * a session into per-workspace allow/deny, and `bulkDeleteThreads` receives it + * as `assertAccess`. + */ + +import { + interactionToPersistedPart, + noticePart, + createInteractionAnswerRoute, + type ChatInteractionStatus, + type InteractionRequestWire, + type InteractionAnswerRoute, +} from '../../src/interactions/index' +import { createAppAuth, type AppAuth, type AppAuthSession } from '../../src/app-auth/index' +import { memoryAdapter } from 'better-auth/adapters/memory' +import { createChatTables, type ChatTables } from '../../src/chat-store/schema' +import { createChatStore, type ChatDatabase, type ChatStore } from '../../src/chat-store/store' +import { ChatStoreInputError, type ChatMessagePart } from '../../src/chat-store/index' +import { + finalizeAssistantParts, + getPartKey, + mergePersistedPart, + normalizePersistedPart, + type JsonRecord, +} from '../../src/stream/index' +import { assertEnvWithinLimits, assertProvisionPayloadWithinCap } from '../../src/sandbox/index' +import { assertSystemPromptWithinBudget, type ComposeProfileBudget } from '../../src/profile/index' +import { openDatabase, workspacesTable } from '../teams/db-helper' +import { createFakeSidecarSession, type FakeSidecarSession, type InteractionResolutionRecord } from './fake-sidecar' + +export const MINI_APP_MODEL = 'mini/model-1' +const BASE_URL = 'http://localhost:3000' +const AUTH_SECRET = 'vertical-suite-secret' + +// --------------------------------------------------------------------------- +// Fake sandbox producer vocabulary — canonical sidecar shapes (mirrors the +// `message.part.updated` envelope the ADC sidecar emits, minus the +// session/message ids the normalizer strips anyway). + +export type ProducerEvent = + /** A canonical part snapshot; `delta` is the increment for text/reasoning. */ + | { type: 'message.part.updated'; part: JsonRecord; delta?: string } + /** An ask. `block: false` registers + emits without waiting (a re-emitted + * duplicate); the default blocks the turn until EVERY outstanding ask is + * resolved — the broker semantics. */ + | { type: 'interaction'; request: InteractionRequestWire; block?: boolean } + /** The turn failed server-side. */ + | { type: 'error'; message: string } + +interface MiniAppOptions { + /** The scripted turn every chat POST runs. */ + script?: ProducerEvent[] + /** #190 gate knobs. */ + budget?: ComposeProfileBudget + sandboxEnv?: Record + /** Extra bytes staged into the provision profile (files channel). */ + profileFileContent?: string + /** Product prompt composition; default is a small static prompt. */ + systemPromptFor?: (message: string) => string +} + +export interface MiniApp { + appAuth: AppAuth + store: ChatStore + tables: ChatTables + db: ChatDatabase + sidecar: FakeSidecarSession + routes: { + /** POST `{ workspaceId, threadId?, message }` → NDJSON turn stream. */ + chat(request: Request): Promise + /** GET `?workspaceId=` → `{ threads, total }`. */ + listThreads(request: Request): Promise + /** GET `?threadId=` → `{ thread, messages }`; inaccessible reads 404. */ + getThread(request: Request): Promise + /** POST `{ ids }` → `{ deleted }`; any denied workspace rejects all. */ + bulkDeleteThreads(request: Request): Promise + interactions: InteractionAnswerRoute + } + /** Sign up a user and return the Cookie header a browser would replay. */ + signUp(email: string): Promise + grantMembership(email: string, workspaceId: string): void + createWorkspace(id: string, name?: string): Promise +} + +function deniedWorkspaceResponse(): Response { + return Response.json({ error: 'Forbidden', code: 'workspace.forbidden' }, { status: 403 }) +} + +/** Route-boundary convention: guards and access checks THROW Responses. */ +async function handle(fn: () => Promise): Promise { + try { + return await fn() + } catch (err) { + if (err instanceof Response) return err + if (err instanceof ChatStoreInputError) { + return Response.json({ error: err.message }, { status: 400 }) + } + throw err + } +} + +export async function createMiniApp(options: MiniAppOptions = {}): Promise { + const tables = createChatTables({ workspaceTable: workspacesTable }) + const db = openDatabase([workspacesTable, tables.threads, tables.messages]) as unknown as ChatDatabase + const store = createChatStore(db, tables) + const sidecar = createFakeSidecarSession() + + const appAuth = createAppAuth({ + appName: 'Vertical Mini App', + baseURL: BASE_URL, + secret: AUTH_SECRET, + database: memoryAdapter({ user: [], session: [], account: [], verification: [] }), + }) + + const memberships = new Map>() + const isMember = (email: string, workspaceId: string) => memberships.get(email)?.has(workspaceId) ?? false + const assertMember = (session: AppAuthSession, workspaceId: string) => { + if (!isMember(session.user.email, workspaceId)) throw deniedWorkspaceResponse() + } + + const systemPromptFor = options.systemPromptFor ?? (() => 'You are the vertical mini app agent.') + let turnCounter = 0 + + function runGates(message: string): void { + assertSystemPromptWithinBudget(systemPromptFor(message), options.budget) + const env = options.sandboxEnv ?? {} + assertEnvWithinLimits(env) + const profile = { + name: 'vertical-mini', + prompt: { systemPrompt: systemPromptFor(message) }, + resources: { files: options.profileFileContent ? [{ path: '/knowledge.md', content: options.profileFileContent }] : [] }, + } + // `ProvisionProfileSection` is structural, so a product-composed profile + // passes without casting through the SDK's AgentProfile. + assertProvisionPayloadWithinCap({ env, secrets: [], backend: { profile } }) + } + + interface TurnState { + partOrder: string[] + partMap: Map + announcedTools: Set + asked: InteractionRequestWire[] + resolutions: Map + tailParts: JsonRecord[] + receipt: { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number; cost: number; seen: boolean } + failed: string | null + } + + function interactionStatusOf(state: TurnState, request: InteractionRequestWire): ChatInteractionStatus { + const resolution = state.resolutions.get(request.id) + if (!resolution) return 'pending' + return resolution.outcome === 'accepted' ? 'answered' : 'declined' + } + + /** Drives the scripted producer: encodes the client NDJSON lane and + * accumulates the persistence lane, then persists the assistant message. */ + function runTurn(threadId: string, turnId: string): ReadableStream { + const encoder = new TextEncoder() + const script = options.script ?? [] + return new ReadableStream({ + async start(controller) { + const emit = (line: unknown) => controller.enqueue(encoder.encode(`${JSON.stringify(line)}\n`)) + const state: TurnState = { + partOrder: [], + partMap: new Map(), + announcedTools: new Set(), + asked: [], + resolutions: new Map(), + tailParts: [], + receipt: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, cost: 0, seen: false }, + failed: null, + } + emit({ type: 'turn', turnId }) + + for (const event of script) { + if (event.type === 'error') { + state.failed = event.message + emit({ type: 'error', error: 'Chat loop failed', details: event.message }) + break + } + + if (event.type === 'interaction') { + state.asked.push(event.request) + void sidecar.ask(event.request) + emit({ type: 'interaction', data: { request: event.request } }) + if (event.block !== false) { + const resolved = await sidecar.waitAll() + for (const [id, resolution] of resolved) state.resolutions.set(id, resolution) + } + continue + } + + const raw = event.part + const normalized = normalizePersistedPart(raw) + if (!normalized) { + // The normalizer owns only text/reasoning/tool; other canonical + // kinds (step-finish et al) persist verbatim. + if (String(raw.type ?? '') === 'step-finish') { + const tokens = (raw.tokens ?? {}) as { + input?: number; output?: number; reasoning?: number; cache?: { read?: number; write?: number } + } + state.receipt.seen = true + state.receipt.input += tokens.input ?? 0 + state.receipt.output += tokens.output ?? 0 + state.receipt.reasoning += tokens.reasoning ?? 0 + state.receipt.cacheRead += tokens.cache?.read ?? 0 + state.receipt.cacheWrite += tokens.cache?.write ?? 0 + state.receipt.cost += typeof raw.cost === 'number' ? raw.cost : 0 + state.tailParts.push(raw) + emit({ + kind: 'event', + event: { + type: 'usage', + usage: { promptTokens: tokens.input ?? 0, completionTokens: tokens.output ?? 0 }, + }, + }) + } + continue + } + + const key = getPartKey(normalized) + const existing = state.partMap.get(key) + if (!existing) state.partOrder.push(key) + state.partMap.set(key, mergePersistedPart(existing, normalized, event.delta)) + + const type = String(normalized.type ?? '') + if (type === 'text') { + emit({ kind: 'event', event: { type: 'text', text: event.delta ?? String(normalized.text ?? '') } }) + } else if (type === 'reasoning') { + emit({ kind: 'event', event: { type: 'reasoning', text: event.delta ?? String(normalized.text ?? '') } }) + } else if (type === 'tool') { + const merged = state.partMap.get(key)! + const toolState = (merged.state ?? {}) as { status?: string; input?: unknown; output?: unknown; error?: string } + const toolCallId = String(merged.id ?? '') + const toolName = String(merged.tool ?? 'tool') + if (!state.announcedTools.has(toolCallId)) { + state.announcedTools.add(toolCallId) + emit({ + kind: 'event', + event: { type: 'tool_call', call: { toolCallId, toolName, args: (toolState.input ?? {}) as Record } }, + }) + } + if (toolState.status === 'completed' || toolState.status === 'error') { + emit({ + kind: 'tool_result', + toolCallId, + toolName, + label: toolName, + outcome: toolState.status === 'completed' + ? { ok: true, result: toolState.output } + : { ok: false, message: toolState.error ?? 'tool failed' }, + }) + } + } + } + + // Persistence lane: same accumulated parts, one assistant row. + const orderedParts = state.partOrder + .map((key) => state.partMap.get(key)) + .filter((part): part is JsonRecord => Boolean(part)) + const finalText = orderedParts + .filter((part) => String(part.type ?? '') === 'text') + .map((part) => String(part.text ?? '')) + .join('') + const parts: JsonRecord[] = finalizeAssistantParts(state.partOrder, state.partMap, finalText) + for (const tail of state.tailParts) parts.push(tail) + for (const request of state.asked) { + parts.push(interactionToPersistedPart(request, interactionStatusOf(state, request))) + } + if (state.failed) { + parts.push(noticePart('warning', turnId, `The agent hit an error and this turn stopped: ${state.failed}`)) + } + await store.appendMessage({ + threadId, + role: 'assistant', + content: finalText, + parts: parts as unknown as ChatMessagePart[], + model: MINI_APP_MODEL, + ...(state.receipt.seen + ? { + inputTokens: state.receipt.input, + outputTokens: state.receipt.output, + reasoningTokens: state.receipt.reasoning, + cacheReadTokens: state.receipt.cacheRead, + cacheWriteTokens: state.receipt.cacheWrite, + costUsd: state.receipt.cost, + } + : {}), + }) + + emit({ type: 'metadata', data: { modelUsed: MINI_APP_MODEL } }) + emit({ type: 'turn_status', status: state.failed ? 'error' : 'complete' }) + controller.close() + }, + }) + } + + const chat = (request: Request) => + handle(async () => { + const session = await appAuth.requireApiUser(request) + const body = (await request.json().catch(() => null)) as + | { workspaceId?: string; threadId?: string; message?: string } + | null + const message = body?.message?.trim() + if (!body?.workspaceId || !message) { + return Response.json({ error: 'workspaceId and message are required' }, { status: 400 }) + } + assertMember(session, body.workspaceId) + + try { + runGates(message) + } catch (err) { + // The #190 gates throw Errors with the actionable breakdown — surface + // them as a client-visible 400 BEFORE any row is written. + return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: 400 }) + } + + let threadId = body.threadId + if (threadId) { + const thread = await store.getThread(threadId) + if (!thread || thread.workspaceId !== body.workspaceId) { + return Response.json({ error: 'Thread not found' }, { status: 404 }) + } + } else { + const thread = await store.createThread({ workspaceId: body.workspaceId, firstMessage: message }) + threadId = thread.id + } + await store.appendMessage({ threadId, role: 'user', content: message }) + + turnCounter += 1 + const turnId = `turn-${turnCounter}` + return new Response(runTurn(threadId, turnId), { + headers: { + 'content-type': 'application/x-ndjson', + 'x-thread-id': threadId, + 'x-turn-id': turnId, + }, + }) + }) + + const listThreads = (request: Request) => + handle(async () => { + const session = await appAuth.requireApiUser(request) + const workspaceId = new URL(request.url).searchParams.get('workspaceId') ?? '' + assertMember(session, workspaceId) + const result = await store.listThreads({ workspaceId }) + return Response.json(result) + }) + + const getThread = (request: Request) => + handle(async () => { + const session = await appAuth.requireApiUser(request) + const threadId = new URL(request.url).searchParams.get('threadId') ?? '' + const thread = await store.getThread(threadId) + // Inaccessible reads are indistinguishable from missing ones — a + // cross-workspace probe must not learn the thread exists. + if (!thread || !isMember(session.user.email, thread.workspaceId)) { + return Response.json({ error: 'Thread not found' }, { status: 404 }) + } + const messages = await store.listMessages(threadId) + return Response.json({ thread, messages }) + }) + + const bulkDeleteThreads = (request: Request) => + handle(async () => { + const session = await appAuth.requireApiUser(request) + const body = (await request.json().catch(() => null)) as { ids?: string[] } | null + const result = await store.bulkDeleteThreads({ + ids: body?.ids ?? [], + assertAccess: (workspaceId) => { + if (!isMember(session.user.email, workspaceId)) throw deniedWorkspaceResponse() + }, + }) + return Response.json(result) + }) + + const interactions = createInteractionAnswerRoute({ + resolveConnection: async ({ request, intent, body }) => { + let session: AppAuthSession + try { + session = await appAuth.requireApiUser(request) + } catch (err) { + if (err instanceof Response) return { ok: false, response: err } + throw err + } + const threadId = + intent === 'answer' + ? String(body?.threadId ?? '') + : new URL(request.url).searchParams.get('threadId') ?? '' + const thread = await store.getThread(threadId) + if (!thread) return { ok: false, response: Response.json({ error: 'Thread not found' }, { status: 404 }) } + if (!isMember(session.user.email, thread.workspaceId)) { + return { ok: false, response: deniedWorkspaceResponse() } + } + return { ok: true, connection: sidecar.connection } + }, + logger: { warn: () => {}, error: () => {} }, + }) + + return { + appAuth, + store, + tables, + db, + sidecar, + routes: { chat, listThreads, getThread, bulkDeleteThreads, interactions }, + async signUp(email: string) { + const response = await appAuth.auth.handler( + new Request(`${BASE_URL}/api/auth/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE_URL }, + body: JSON.stringify({ email, password: 'correct-horse-battery', name: email.split('@')[0] }), + }), + ) + if (response.status !== 200) throw new Error(`sign-up failed (${response.status})`) + return response.headers + .getSetCookie() + .map((cookie) => cookie.split(';')[0]!) + .join('; ') + }, + grantMembership(email, workspaceId) { + const set = memberships.get(email) ?? new Set() + set.add(workspaceId) + memberships.set(email, set) + }, + async createWorkspace(id, name = id) { + await db.insert(workspacesTable).values({ id, organizationId: 'org-vertical', name }) + }, + } +} diff --git a/tests/vertical/vertical.test.ts b/tests/vertical/vertical.test.ts new file mode 100644 index 0000000..04c5ccd --- /dev/null +++ b/tests/vertical/vertical.test.ts @@ -0,0 +1,548 @@ +/** + * Vertical composition evals — the assembled mini chat app from `./mini-app` + * exercised end-to-end as a hostile integrator would: every assertion is a + * USER-VISIBLE outcome (what the client stream showed, what a later page load + * reads back from the store, what an attacker's request returned), never a + * module internal. + * + * This suite is the composition gate for the merged #189/#190/#191/#192 + * modules: a future subpath change that still passes its unit tests but breaks + * assembly (a seam rename, a contract drift between stream parts and persisted + * parts, a guard/store access mismatch) fails here. + */ + +import { describe, expect, it, vi } from 'vitest' +import { + consumeChatStream, + streamChatTurn, + type ChatStreamCallbacks, +} from '../../src/web-react/chat-stream' +import { + dedupeQuestionInteractionsByContent, + persistedPartToInteraction, + type ChatInteraction, + type InteractionRequestWire, +} from '../../src/interactions/index' +import type { ChatInteractionPart, ChatMessagePart, ChatStepFinishPart, ChatToolPart } from '../../src/chat-store/index' +import { createMiniApp, MINI_APP_MODEL, type MiniApp, type ProducerEvent } from './mini-app' + +const BASE = 'http://localhost:3000' + +// ── request helpers ────────────────────────────────────────────────────────── + +function jsonRequest(path: string, body: unknown, cookie?: string): Request { + return new Request(`${BASE}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, + body: JSON.stringify(body), + }) +} + +function getRequest(path: string, cookie?: string): Request { + return new Request(`${BASE}${path}`, { headers: cookie ? { cookie } : {} }) +} + +function recorder() { + const log: Array<[string, unknown]> = [] + const interactions: ChatInteraction[] = [] + const cb: ChatStreamCallbacks = { + onText: (t) => log.push(['text', t]), + onReasoning: (t) => log.push(['reasoning', t]), + onToolCall: (c) => log.push(['tool_call', c.toolName]), + onToolResult: (r) => log.push(['tool_result', r.outcome?.ok]), + onUsage: (u) => log.push(['usage', u]), + onMetadata: (d) => log.push(['metadata', d.modelUsed]), + onErrorEvent: (m) => log.push(['error', m]), + onInteraction: (i) => { + log.push(['interaction', i.id]) + interactions.push(i) + }, + } + const text = () => log.filter(([k]) => k === 'text').map(([, v]) => v).join('') + return { log, cb, interactions, text } +} + +async function seedUser(app: MiniApp, email: string, workspaceIds: string[]): Promise { + const cookie = await app.signUp(email) + for (const workspaceId of workspaceIds) app.grantMembership(email, workspaceId) + return cookie +} + +async function messagesOf(app: MiniApp, threadId: string) { + return app.store.listMessages(threadId) +} + +function partsOf(message: { parts: ChatMessagePart[] | null }): ChatMessagePart[] { + return message.parts ?? [] +} + +// ── scripts ────────────────────────────────────────────────────────────────── + +const FULL_TURN_SCRIPT: ProducerEvent[] = [ + { type: 'message.part.updated', part: { type: 'reasoning', id: 'r1', text: 'checking the vault' }, delta: 'checking the vault' }, + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Filed ' }, delta: 'Filed ' }, + { + type: 'message.part.updated', + part: { type: 'tool', id: 'call-1', callID: 'call-1', tool: 'vault_search', state: { status: 'running', input: { query: 'lease' } } }, + }, + { + type: 'message.part.updated', + part: { + type: 'tool', + id: 'call-1', + callID: 'call-1', + tool: 'vault_search', + state: { status: 'completed', input: { query: 'lease' }, output: { hits: 2 }, time: { start: 1_000, end: 5_000 } }, + }, + }, + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Filed the lease summary.' }, delta: 'the lease summary.' }, + { + type: 'message.part.updated', + part: { + type: 'step-finish', + reason: 'stop', + tokens: { total: 65, input: 40, output: 20, reasoning: 5, cache: { read: 10, write: 2 } }, + cost: 0.0123, + }, + }, +] + +function question(id: string, overrides: Partial = {}): InteractionRequestWire { + return { + id, + kind: 'question', + title: 'Which tone should the summary use?', + answerSpec: { + fields: [ + { + type: 'select', + name: 'tone', + label: 'Tone', + required: true, + multi: false, + options: [ + { value: 'Formal', label: 'Formal' }, + { value: 'Casual', label: 'Casual' }, + ], + }, + ], + }, + ...overrides, + } as InteractionRequestWire +} + +// ── scenario 1: full turn ──────────────────────────────────────────────────── + +describe('vertical: full turn', () => { + it('streams text/tool/usage to the client and persists the assistant message with parts + token receipt', async () => { + const app = await createMiniApp({ script: FULL_TURN_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const { log, cb, text } = recorder() + const response = await app.routes.chat( + jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Draft the lease summary\nfull details follow' }, cookie), + ) + expect(response.status).toBe(200) + const threadId = response.headers.get('x-thread-id')! + expect(threadId).toBeTruthy() + + const result = await consumeChatStream(response.body!, cb) + expect(result.receivedContent).toBe(true) + expect(result.turnId).toBe(response.headers.get('x-turn-id')) + + // Client lane: the user watched the whole turn happen. + expect(text()).toBe('Filed the lease summary.') + expect(log).toContainEqual(['reasoning', 'checking the vault']) + expect(log).toContainEqual(['tool_call', 'vault_search']) + expect(log).toContainEqual(['tool_result', true]) + expect(log).toContainEqual(['usage', { promptTokens: 40, completionTokens: 20 }]) + expect(log).toContainEqual(['metadata', MINI_APP_MODEL]) + + // Persistence lane: a reload reads the same turn back. + const thread = await app.store.getThread(threadId) + expect(thread?.title).toBe('Draft the lease summary') + + const messages = await messagesOf(app, threadId) + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant']) + expect(messages[0]?.content).toContain('Draft the lease summary') + + const assistant = messages[1]! + expect(assistant.content).toBe('Filed the lease summary.') + expect(assistant.model).toBe(MINI_APP_MODEL) + expect(assistant.inputTokens).toBe(40) + expect(assistant.outputTokens).toBe(20) + expect(assistant.reasoningTokens).toBe(5) + expect(assistant.cacheReadTokens).toBe(10) + expect(assistant.cacheWriteTokens).toBe(2) + expect(assistant.costUsd).toBeCloseTo(0.0123) + + const parts = partsOf(assistant) + const textPart = parts.find((p) => p.type === 'text') + expect(textPart).toMatchObject({ type: 'text', text: 'Filed the lease summary.' }) + + const toolPart = parts.find((p) => p.type === 'tool') as ChatToolPart | undefined + expect(toolPart).toMatchObject({ + type: 'tool', + tool: 'vault_search', + state: { status: 'completed', input: { query: 'lease' }, output: { hits: 2 } }, + }) + + const stepFinish = parts.find((p) => p.type === 'step-finish') as ChatStepFinishPart | undefined + expect(stepFinish?.tokens).toMatchObject({ input: 40, output: 20, reasoning: 5, cache: { read: 10, write: 2 } }) + expect(stepFinish?.cost).toBeCloseTo(0.0123) + }) + + it('rejects an unauthenticated turn with the guard 401 contract', async () => { + const app = await createMiniApp({ script: FULL_TURN_SCRIPT }) + await app.createWorkspace('ws1') + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' })) + expect(response.status).toBe(401) + expect(await response.json()).toMatchObject({ code: 'auth.unauthenticated' }) + }) + + it('rejects a member-of-nothing user with 403 before any row is written', async () => { + const app = await createMiniApp({ script: FULL_TURN_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await app.signUp('lurker@example.com') // no membership granted + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' }, cookie)) + expect(response.status).toBe(403) + const rows = await app.db.select().from(app.tables.threads) + expect(rows).toHaveLength(0) + }) +}) + +// ── scenario 2: failed turn (the incident class) ───────────────────────────── + +describe('vertical: failed turn', () => { + const FAILING_SCRIPT: ProducerEvent[] = [ + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Starting the review… ' }, delta: 'Starting the review… ' }, + { type: 'error', message: 'sandbox exec crashed: boom' }, + ] + + it('shows the error to a client with no onErrorEvent wired (synthesized transcript row) and retains the user message', async () => { + const app = await createMiniApp({ script: FAILING_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const texts: string[] = [] + try { + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Review the contract' }, cookie)) + const threadId = response.headers.get('x-thread-id')! + const result = await consumeChatStream(response.body!, { onText: (t) => texts.push(t) }) + + // User-visible: the failure reached the transcript, not a silent empty answer. + expect(result.receivedContent).toBe(true) + expect(texts.join('')).toContain('sandbox exec crashed: boom') + expect(errorSpy).toHaveBeenCalled() + + // Durability: the user's message survived the failed turn, and the + // partial assistant output + a warning notice are readable on reload. + const messages = await messagesOf(app, threadId) + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant']) + expect(messages[0]?.content).toBe('Review the contract') + + const assistant = messages[1]! + expect(assistant.content).toBe('Starting the review… ') + const notice = partsOf(assistant).find((p) => p.type === 'notice') + expect(notice).toMatchObject({ type: 'notice', noticeKind: 'warning' }) + expect((notice as { text?: string }).text).toContain('sandbox exec crashed: boom') + } finally { + errorSpy.mockRestore() + } + }) + + it('routes the error to onErrorEvent when the consumer wired one', async () => { + const app = await createMiniApp({ script: FAILING_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const { log, cb, text } = recorder() + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Review the contract' }, cookie)) + await consumeChatStream(response.body!, cb) + expect(log).toContainEqual(['error', 'sandbox exec crashed: boom']) + expect(text()).toBe('Starting the review… ') // no synthesized duplicate + }) +}) + +// ── scenario 3: interaction ask → answer → turn continues ──────────────────── + +describe('vertical: interaction round-trip', () => { + const ASKING_SCRIPT: ProducerEvent[] = [ + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Before I draft: ' }, delta: 'Before I draft: ' }, + { type: 'interaction', request: question('ask-1') }, + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Before I draft: got it, proceeding.' }, delta: 'got it, proceeding.' }, + { type: 'message.part.updated', part: { type: 'step-finish', tokens: { input: 10, output: 4 }, cost: 0.002 } }, + ] + + it('blocks the turn on the ask, rejects an invalid answer fail-closed, unblocks on a valid one, and persists the answered card', async () => { + const app = await createMiniApp({ script: ASKING_SCRIPT }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Draft the letter' }, cookie)) + const threadId = response.headers.get('x-thread-id')! + + const { cb, interactions, text } = recorder() + const answerFlow: Promise[] = [] + cb.onInteraction = (interaction) => { + interactions.push(interaction) + answerFlow.push((async () => { + // Hostile first: free text on an option-only select must fail closed + // with the card-actionable 400, and the run must stay blocked. + const bad = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: interaction.id, outcome: 'accepted', data: { tone: 'sarcastic' }, threadId }, cookie), + ) + expect(bad.status).toBe(400) + expect(await bad.json()).toMatchObject({ code: 'INVALID_INTERACTION_ANSWER' }) + expect(app.sidecar.outstandingIds()).toContain(interaction.id) + + const good = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: interaction.id, outcome: 'accepted', data: { tone: ['Formal'] }, threadId }, cookie), + ) + expect(good.status).toBe(200) + expect(await good.json()).toEqual({ ok: true }) + })()) + } + + const result = await consumeChatStream(response.body!, cb) + await Promise.all(answerFlow) + + // The turn genuinely continued past the ask. + expect(result.receivedContent).toBe(true) + expect(text()).toBe('Before I draft: got it, proceeding.') + expect(interactions).toHaveLength(1) + expect(interactions[0]).toMatchObject({ id: 'ask-1', kind: 'question', status: 'pending' }) + expect(app.sidecar.outstandingIds()).toEqual([]) + + // Reload restore: the persisted part reads back as an answered card. + const messages = await messagesOf(app, threadId) + const assistant = messages[1]! + const persisted = partsOf(assistant).find((p) => p.type === 'interaction') as ChatInteractionPart | undefined + expect(persisted).toMatchObject({ id: 'ask-1', kind: 'question', status: 'answered' }) + const card = persistedPartToInteraction(persisted as unknown as Record) + expect(card).toMatchObject({ id: 'ask-1', title: 'Which tone should the summary use?', status: 'answered' }) + + // The receipt still landed after the blocked stretch. + expect(assistant.inputTokens).toBe(10) + expect(assistant.outputTokens).toBe(4) + + // Answering an already-resolved ask reads back 410 (card flips to expired). + const replay = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: 'ask-1', outcome: 'accepted', data: { tone: ['Formal'] }, threadId }, cookie), + ) + expect(replay.status).toBe(410) + expect(await replay.json()).toMatchObject({ code: 'INTERACTION_EXPIRED' }) + }) + + it('a re-emitted duplicate question renders one card and one answer resolves both asks', async () => { + const script: ProducerEvent[] = [ + { type: 'interaction', request: question('ask-1'), block: false }, + { type: 'interaction', request: question('ask-2') }, // same content, new id + { type: 'message.part.updated', part: { type: 'text', id: 'txt1', text: 'Proceeding.' }, delta: 'Proceeding.' }, + ] + const app = await createMiniApp({ script }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Draft it' }, cookie)) + const threadId = response.headers.get('x-thread-id')! + + const { cb, interactions, text } = recorder() + const answered: Promise[] = [] + cb.onInteraction = (interaction) => { + interactions.push(interaction) + if (interaction.id !== 'ask-1') return + answered.push(app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: 'ask-1', outcome: 'accepted', data: { tone: ['Casual'] }, threadId }, cookie), + )) + } + await consumeChatStream(response.body!, cb) + const [answerResponse] = await Promise.all(answered) + + // The user saw ONE card even though the agent asked twice. + expect(interactions).toHaveLength(2) + expect(dedupeQuestionInteractionsByContent(interactions)).toHaveLength(1) + + // One POST resolved both registry entries (content-signature duplicate handling). + expect(answerResponse!.status).toBe(200) + expect(app.sidecar.outstandingIds()).toEqual([]) + expect(app.sidecar.calls.some((call) => call.method === 'POST' && call.body?.id === 'ask-2')).toBe(true) + expect(text()).toBe('Proceeding.') + }) + + it('denies answering another workspace\'s ask and listing its interactions', async () => { + const app = await createMiniApp({ script: [{ type: 'interaction', request: question('ask-1') }] }) + await app.createWorkspace('ws1') + await app.createWorkspace('ws2') + const adaCookie = await seedUser(app, 'ada@example.com', ['ws1']) + const bobCookie = await seedUser(app, 'bob@example.com', ['ws2']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Draft it' }, adaCookie)) + const threadId = response.headers.get('x-thread-id')! + // The turn is parked on the ask; attack it from the other workspace. + const denied = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: 'ask-1', outcome: 'accepted', data: { tone: ['Formal'] }, threadId }, bobCookie), + ) + expect(denied.status).toBe(403) + expect(app.sidecar.outstandingIds()).toContain('ask-1') + + const deniedList = await app.routes.interactions.list(getRequest(`/api/chat/interactions?threadId=${threadId}`, bobCookie)) + expect(deniedList.status).toBe(403) + + const okList = await app.routes.interactions.list(getRequest(`/api/chat/interactions?threadId=${threadId}`, adaCookie)) + expect(okList.status).toBe(200) + expect(await okList.json()).toMatchObject({ interactions: [{ id: 'ask-1' }] }) + + // Release the parked turn so the stream (and the test) can finish. + const ok = await app.routes.interactions.answer( + jsonRequest('/api/chat/interactions', { id: 'ask-1', outcome: 'declined', threadId }, adaCookie), + ) + expect(ok.status).toBe(200) + await consumeChatStream(response.body!, {}) + }) +}) + +// ── scenario 4: workspace isolation + cross-workspace reads ───────────────── + +describe('vertical: workspace isolation', () => { + async function twoWorkspaceApp() { + const app = await createMiniApp({ script: FULL_TURN_SCRIPT }) + await app.createWorkspace('ws1') + await app.createWorkspace('ws2') + const adaCookie = await seedUser(app, 'ada@example.com', ['ws1']) + const bobCookie = await seedUser(app, 'bob@example.com', ['ws2']) + + const adaTurn = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'Ada thread' }, adaCookie)) + await consumeChatStream(adaTurn.body!, {}) + const bobTurn = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws2', message: 'Bob thread' }, bobCookie)) + await consumeChatStream(bobTurn.body!, {}) + + return { app, adaCookie, bobCookie, adaThreadId: adaTurn.headers.get('x-thread-id')!, bobThreadId: bobTurn.headers.get('x-thread-id')! } + } + + it('a cross-workspace thread read is indistinguishable from a missing thread', async () => { + const { app, adaCookie, bobCookie, adaThreadId } = await twoWorkspaceApp() + + const denied = await app.routes.getThread(getRequest(`/api/thread?threadId=${adaThreadId}`, bobCookie)) + expect(denied.status).toBe(404) + const missing = await app.routes.getThread(getRequest('/api/thread?threadId=does-not-exist', bobCookie)) + expect(missing.status).toBe(404) + expect(await denied.json()).toEqual(await missing.json()) + + const unauthenticated = await app.routes.getThread(getRequest(`/api/thread?threadId=${adaThreadId}`)) + expect(unauthenticated.status).toBe(401) + + const owner = await app.routes.getThread(getRequest(`/api/thread?threadId=${adaThreadId}`, adaCookie)) + expect(owner.status).toBe(200) + const payload = await owner.json() as { messages: Array<{ role: string }> } + expect(payload.messages.map((m) => m.role)).toEqual(['user', 'assistant']) + }) + + it('a second workspace never sees the first workspace\'s threads', async () => { + const { app, adaCookie, bobCookie, adaThreadId, bobThreadId } = await twoWorkspaceApp() + + const bobList = await app.routes.listThreads(getRequest('/api/threads?workspaceId=ws2', bobCookie)) + const bobPayload = await bobList.json() as { threads: Array<{ id: string }>; total: number } + expect(bobPayload.total).toBe(1) + expect(bobPayload.threads.map((t) => t.id)).toEqual([bobThreadId]) + expect(bobPayload.threads.map((t) => t.id)).not.toContain(adaThreadId) + + // Listing a workspace you are not a member of is denied outright. + const denied = await app.routes.listThreads(getRequest('/api/threads?workspaceId=ws1', bobCookie)) + expect(denied.status).toBe(403) + + const adaList = await app.routes.listThreads(getRequest('/api/threads?workspaceId=ws1', adaCookie)) + expect((await adaList.json() as { threads: Array<{ id: string }> }).threads.map((t) => t.id)).toEqual([adaThreadId]) + }) + + it('bulk delete with one denied workspace deletes NOTHING (fail-closed)', async () => { + const { app, adaCookie, adaThreadId, bobThreadId } = await twoWorkspaceApp() + + const denied = await app.routes.bulkDeleteThreads( + jsonRequest('/api/threads/bulk-delete', { ids: [adaThreadId, bobThreadId] }, adaCookie), + ) + expect(denied.status).toBe(403) + + // Zero deletion: both threads AND their messages survived the rejected request. + expect(await app.store.getThread(adaThreadId)).not.toBeNull() + expect(await app.store.getThread(bobThreadId)).not.toBeNull() + expect(await messagesOf(app, adaThreadId)).toHaveLength(2) + expect(await messagesOf(app, bobThreadId)).toHaveLength(2) + + // The same caller deleting only their own thread succeeds, messages first. + const ok = await app.routes.bulkDeleteThreads(jsonRequest('/api/threads/bulk-delete', { ids: [adaThreadId] }, adaCookie)) + expect(ok.status).toBe(200) + expect(await ok.json()).toEqual({ deleted: 1 }) + expect(await app.store.getThread(adaThreadId)).toBeNull() + expect(await messagesOf(app, adaThreadId)).toHaveLength(0) + expect(await app.store.getThread(bobThreadId)).not.toBeNull() + }) + + it('maps malformed bulk-delete input to a 400, not a 500', async () => { + const { app, adaCookie } = await twoWorkspaceApp() + const empty = await app.routes.bulkDeleteThreads(jsonRequest('/api/threads/bulk-delete', { ids: [] }, adaCookie)) + expect(empty.status).toBe(400) + expect(await empty.json()).toMatchObject({ error: 'Missing ids' }) + }) +}) + +// ── scenario 5: the #190 gates at the turn boundary ────────────────────────── + +describe('vertical: incident gates', () => { + it('an oversized composed prompt is a client-visible 400 with the section breakdown, before any row is written', async () => { + const knowledgeDump = 'k'.repeat(60_000) + const app = await createMiniApp({ + script: FULL_TURN_SCRIPT, + systemPromptFor: (message) => `# Directives\nAnswer: ${message}\n# Knowledge Dump\n${knowledgeDump}`, + }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + // User-visible path: streamChatTurn surfaces the server's gate message. + await expect( + streamChatTurn({ + start: () => app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' }, cookie)), + callbacks: {}, + }), + ).rejects.toThrow(/over the 40000-byte budget.*Knowledge Dump/s) + + // Fired pre-write: no orphan thread or user message. + expect(await app.db.select().from(app.tables.threads)).toHaveLength(0) + expect(await app.db.select().from(app.tables.messages)).toHaveLength(0) + }) + + it('an oversized env entry fires the E2BIG gate naming the variable', async () => { + const app = await createMiniApp({ + script: FULL_TURN_SCRIPT, + sandboxEnv: { WORKSPACE_CONTEXT: 'x'.repeat(130_000) }, + }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' }, cookie)) + expect(response.status).toBe(400) + const { error } = await response.json() as { error: string } + expect(error).toContain('WORKSPACE_CONTEXT') + expect(error).toContain('131072') + expect(await app.db.select().from(app.tables.messages)).toHaveLength(0) + }) + + it('an oversized provision payload fires the create-cap gate with the per-section breakdown', async () => { + const app = await createMiniApp({ + script: FULL_TURN_SCRIPT, + profileFileContent: 'f'.repeat(250_000), + }) + await app.createWorkspace('ws1') + const cookie = await seedUser(app, 'ada@example.com', ['ws1']) + + const response = await app.routes.chat(jsonRequest('/api/chat', { workspaceId: 'ws1', message: 'hi' }, cookie)) + expect(response.status).toBe(400) + const { error } = await response.json() as { error: string } + expect(error).toContain('over the 240000-byte gate') + expect(error).toContain('Breakdown:') + expect(error).toMatch(/files=\d+B/) + expect(await app.db.select().from(app.tables.threads)).toHaveLength(0) + }) +}) From f9d6530bba24ba98fb0e2b2ccb3c1fb43ed7227d Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 15 Jul 2026 12:55:17 -0600 Subject: [PATCH 5/5] fix(ci): raise the tsup d.ts worker heap to 8192MB The d.ts worker's memory footprint grows with the subpath count; since the #191/#192 merges every main CI run (and the next publish) OOMs at the 6144MB job heap during the install-time prepare build. 8192 matches the documented local build requirement; public-repo runners have 16GB. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/publish.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b063505..751a5bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,9 @@ jobs: env: # tsup generates .d.ts for ~38 entries in a worker thread (the `prepare` # script runs during install, and again in the build step); that worker - # needs ~4.7GB and OOMs at the default limit. The worker inherits + # needed ~4.7GB and has grown with the subpath count (OOMed at 6144 after the #191/#192 merges); 8192 matches the documented local build requirement. The worker inherits # NODE_OPTIONS, so set the heap once at the job level. - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9a0fcf6..7a03bc3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,7 +34,7 @@ jobs: contents: write # push the version-bump commit + tag id-token: write # OIDC trusted publish + Sigstore provenance env: - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 steps: - uses: actions/checkout@v7 with: @@ -93,7 +93,7 @@ jobs: # ERR_WORKER_OUT_OF_MEMORY on the hosted runner. The worker inherits # NODE_OPTIONS, so a job-level heap covers install (prepare), test, and # build in one place. - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 @@ -129,7 +129,7 @@ jobs: env: # Same DTS-worker heap need: this job runs `pnpm install` (prepare → tsup) # and `pnpm run build` before publishing. - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 permissions: contents: read id-token: write