diff --git a/apps/console/src/entry.ts b/apps/console/src/entry.ts index 0ee90294c..a86974d02 100644 --- a/apps/console/src/entry.ts +++ b/apps/console/src/entry.ts @@ -2,6 +2,7 @@ import { createPublicKey } from "node:crypto"; import { pathToFileURL } from "node:url"; import { isAbsolute, resolve } from "node:path"; import { registryFromEnv } from "../../gateway/src/index.ts"; +import { createLiveTurn } from "./live.ts"; import type { ConsoleAdapters, Identity } from "./index.ts"; import { serveConsole } from "./server.ts"; @@ -80,8 +81,11 @@ async function main(): Promise { if (!host || !Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid --http address"); const registry = registryFromEnv(process.env.CONTEXTFUL_STORES_JSON); const stores = registry.entries.map(({ id, label }) => ({ id, label })); - let adapters: Pick & { read?: ConsoleAdapters["read"] } = unavailableAdapters(); const modulePath = process.env.CONTEXTFUL_CONSOLE_ADAPTER_MODULE; + let adapters: Pick & { read?: ConsoleAdapters["read"] } = { + ...unavailableAdapters(), + turn: modulePath ? unavailableAdapters().turn : createLiveTurn({ stores: registry.entries, env: process.env }), + }; if (modulePath) { const absolute = isAbsolute(modulePath) ? modulePath : resolve(modulePath); const module: { createAdapters?: AdapterFactory } = await import(pathToFileURL(absolute).href); diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts index 779ae24da..8691579fc 100644 --- a/apps/console/src/index.ts +++ b/apps/console/src/index.ts @@ -1,6 +1,7 @@ import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify, type KeyObject } from "node:crypto"; +import { ConsoleError } from "./turn.ts"; -export type Operator = { subject: string; grants: ReadonlySet<"query" | "admin"> }; +export type Operator = { subject: string; grants: ReadonlySet<"query" | "admin">; assertion?: string }; export type Store = { id: string; label: string }; export type AccessIdentity = { kind: "access"; @@ -43,7 +44,7 @@ export type ConsoleAdapters = { }; }; -type Session = { subject: string; groups: string[]; expiresAt: number }; +type Session = { subject: string; groups: string[]; expiresAt: number; assertion?: string }; function encoded(value: unknown): string { return Buffer.from(JSON.stringify(value)).toString("base64url"); @@ -83,11 +84,12 @@ function verifyCognitoSession(cookie: string, identity: CognitoIdentity): Operat try { session = decode(parts[0]); } catch { return null; } if (!object(session) || typeof session.subject !== "string" || !session.subject || !Number.isSafeInteger(session.expiresAt) || (session.expiresAt as number) <= Math.floor(Date.now() / 1000) || - !Array.isArray(session.groups) || !session.groups.every((group) => typeof group === "string")) return null; + !Array.isArray(session.groups) || !session.groups.every((group) => typeof group === "string") || + (session.assertion !== undefined && typeof session.assertion !== "string")) return null; const grants = new Set<"query" | "admin">(); if (session.groups.includes(identity.queryGroup)) grants.add("query"); if (session.groups.includes(identity.adminGroup)) grants.add("admin"); - return { subject: session.subject, grants }; + return { subject: session.subject, grants, assertion: typeof session.assertion === "string" ? session.assertion : undefined }; } function verifyAccess(assertion: string, identity: AccessIdentity): Operator | null { @@ -111,7 +113,7 @@ function verifyAccess(assertion: string, identity: AccessIdentity): Operator | n typeof identity.publicKey === "string" ? createPublicKey(identity.publicKey) : identity.publicKey; if (!key) return null; if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, signature)) return null; - return { subject: claims.sub, grants }; + return { subject: claims.sub, grants, assertion }; } function operatorFor(request: Request, identity: Identity): Operator | null { @@ -180,7 +182,7 @@ async function cognitoCallback(request: Request, identity: CognitoIdentity): Pro if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"))) return refusal("ConsolePageForbidden"); const groups = Array.isArray(claims["cognito:groups"]) ? claims["cognito:groups"].filter((group): group is string => typeof group === "string") : []; const lifetime = Math.min(claims.exp as number, Math.floor(Date.now() / 1000) + 3600); - const session = issueCognitoSession({ subject: claims.sub, groups, expiresAt: lifetime }, identity.sessionSecret); + const session = issueCognitoSession({ subject: claims.sub, groups, expiresAt: lifetime, assertion: tokens.id_token }, identity.sessionSecret); const destination = groups.includes(identity.adminGroup) ? "/admin" : "/query"; return new Response(null, { status: 302, headers: { Location: destination, @@ -264,7 +266,12 @@ export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Req try { input = await body(request); } catch (error) { return bodyFailure(error); } if (!object(input) || typeof input.store !== "string" || typeof input.question !== "string" || !input.question.trim() || !adapters.stores.some((store) => store.id === input.store)) return refusal("ConsoleRequestMalformed", 400); - return json(await adapters.turn({ operator, store: input.store, question: input.question })); + try { + return json(await adapters.turn({ operator, store: input.store, question: input.question })); + } catch (error) { + if (error instanceof ConsoleError) return refusal(error.code, error.status); + throw error; + } } } else { const store = url.searchParams.get("store"); diff --git a/apps/console/src/live.ts b/apps/console/src/live.ts new file mode 100644 index 000000000..7f5f3119d --- /dev/null +++ b/apps/console/src/live.ts @@ -0,0 +1,131 @@ +import type { StoreEntry } from "../../gateway/src/index.ts"; +import type { TurnInput, TurnResult } from "./index.ts"; +import { ConsoleError, createTurn, resolveReaderCredential, type Source, type ToolResult } from "./turn.ts"; + +type LiveOptions = { stores: StoreEntry[]; env: NodeJS.ProcessEnv; fetcher?: typeof fetch }; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function strings(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function terms(value: string): Set { + return new Set((value.toLowerCase().match(/[a-z0-9]+/g) ?? []).map((word) => + word.endsWith("ies") && word.length > 4 ? `${word.slice(0, -3)}y` : word.endsWith("s") && word.length > 3 ? word.slice(0, -1) : word)); +} + +function selectTable(question: string, listing: unknown): string | null { + const asked = terms(question); + const entries = Array.isArray(listing) ? listing : []; + const ranked = entries.flatMap((item) => { + if (!record(item) || typeof item.table !== "string" || item.kind !== "data") return []; + const name = terms(item.table.split("/").at(-1) ?? ""); + const description = terms(typeof item.description === "string" ? item.description : ""); + const score = [...asked].reduce((sum, word) => sum + (name.has(word) ? 2 : description.has(word) ? 1 : 0), 0); + return score > 0 ? [{ table: item.table, score }] : []; + }).sort((a, b) => b.score - a.score); + return ranked.length > 0 && (ranked.length === 1 || ranked[0].score > ranked[1].score) ? ranked[0].table : null; +} + +function source(columns: string[], row: unknown[], table: string): Source | null { + const at = (name: string) => row[columns.indexOf(name)]; + const idColumn = columns.find((name) => name.endsWith("_id") || name === "id"); + const urlColumn = columns.find((name) => name === "source_url" || name === "url"); + const id = idColumn && at(idColumn); + const url = urlColumn && at(urlColumn); + if (typeof id !== "string" || !id) return null; + const label = columns.includes("title") ? at("title") : columns.includes("summary") ? at("summary") : id; + return { id, label: typeof label === "string" && label ? label : `${table}: ${id}`, + ...(typeof url === "string" && /^https?:\/\//.test(url) ? { url } : {}) }; +} + +export function createLiveTurn({ stores, env, fetcher = fetch }: LiveOptions): (input: TurnInput) => Promise { + const modelEndpoint = env.CONTEXTFUL_MODEL_ENDPOINT; + const modelId = env.CONTEXTFUL_MODEL_ID; + if (!modelEndpoint || !modelId) throw new Error("ConsoleModelUnconfigured"); + return async (input) => { + const store = stores.find((entry) => entry.id === input.store); + if (!store) throw new ConsoleError("ConsoleRequestMalformed"); + const shared = env[store.credentialName]; + const credential = store.auth === "exchange" ? await resolveReaderCredential({ + shared, + mint: async () => { + if (!input.operator.assertion || !store.exchangeRoute) throw new ConsoleError("ConsoleTokenExchangeUnavailable", "reader assertion or exchange route absent", 503); + const response = await fetcher(new URL(store.exchangeRoute, store.endpoint), { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jwt: input.operator.assertion }), + }); + if (!response.ok) { + const refusal: unknown = await response.json(); + const error = record(refusal) ? refusal.error : undefined; + if ((response.status === 401 || response.status === 403) && record(error) && typeof error.identifier === "string") { + throw new ConsoleError("ConsoleTokenExchangeRefused", error.identifier, 403); + } + throw new ConsoleError("ConsoleTokenExchangeUnavailable", `exchange answered ${response.status}`, 503); + } + const value: unknown = await response.json(); + if (!record(value) || typeof value.token !== "string" || !value.token.trim()) throw new ConsoleError("ConsoleTokenExchangeUnavailable", "exchange returned no credential", 503); + return value.token; + }, + }) : shared; + if (!credential) throw new ConsoleError("ConsoleTokenExchangeRefused"); + + let id = 0; + const call = async (name: string, args: Record, signal?: AbortSignal): Promise> => { + const response = await fetcher(new URL("/mcp", store.endpoint), { + method: "POST", signal, + headers: { "Authorization": `Bearer ${credential}`, "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }), + }); + if (!response.ok) throw new ConsoleError("ConsoleStoreReadRefused", `store answered ${response.status}`, response.status); + const message: unknown = await response.json(); + if (!record(message) || !record(message.result) || message.result.isError === true || !record(message.result.structuredContent)) { + throw new ConsoleError("ConsoleStoreReadRefused"); + } + return message.result.structuredContent; + }; + + const description = await call("context.describe", {}); + const selected = selectTable(input.question, description.tables); + if (!selected) throw new ConsoleError("ConsoleUngroundedAnswer", "No data table matches this question."); + const tables = [selected]; + let remainingSources = 8; + const turn = createTurn({ + tools: tables.map((table) => ({ name: `read:${table}`, pack: "data", kind: "read" as const, table })), + tables: tables.map((name) => ({ name, kind: "data" as const })), + planner: async ({ previous }) => previous.length ? [] : tables.map((table) => ({ tool: `read:${table}`, arguments: {} })), + transport: { call: async ({ tool, maxRows, signal }): Promise => { + const table = tool.slice("read:".length); + const result = await call("context.query", { sql: `SELECT * FROM "${table.replaceAll('"', '""')}"`, limit: Math.min(maxRows, remainingSources) }, signal); + const columns = strings(result.columns); + const rawRows = Array.isArray(result.rows) ? result.rows.filter(Array.isArray) : []; + const sourced = rawRows.flatMap((row) => { const citation = source(columns, row, table); return citation ? [{ row, citation }] : []; }).slice(0, remainingSources); + remainingSources -= sourced.length; + return { + rows: sourced.map(({ row }) => Object.fromEntries(columns.map((column, index) => [column, row[index]]))), + sources: sourced.map(({ citation }) => citation), + }; + } }, + synthesize: async function* ({ question, results, sources }) { + const response = await fetcher(new URL("chat/completions", `${modelEndpoint.replace(/\/$/, "")}/`), { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: modelId, messages: [ + { role: "system", content: "Answer only from the supplied governed rows. Cite source IDs in square brackets. Decline unsupported claims." }, + { role: "user", content: JSON.stringify({ question, rows: results.flatMap((result) => result.rows), sources }) }, + ] }), + }); + if (!response.ok) throw new ConsoleError("ConsoleModelUnavailable", `model answered ${response.status}`, 503); + const body: unknown = await response.json(); + const choices = record(body) && Array.isArray(body.choices) ? body.choices : []; + const message = choices.length && record(choices[0]) ? choices[0].message : undefined; + if (!record(message) || typeof message.content !== "string") throw new ConsoleError("ConsoleModelUnavailable", "model answer absent", 503); + yield message.content; + }, + }); + const answer = await turn.ask({ question: input.question, packs: ["data"], store: store.id }); + return { answer: answer.text, sources: answer.sources, widgets: [] }; + }; +} diff --git a/apps/console/src/turn.ts b/apps/console/src/turn.ts new file mode 100644 index 000000000..32005926c --- /dev/null +++ b/apps/console/src/turn.ts @@ -0,0 +1,244 @@ +export type ToolKind = "read" | "write"; + +type ToolBase = { + name: string; + pack: string; + access?: "governed" | "direct-file"; +}; +export type Tool = ToolBase & ( + | { kind: "read"; leg?: "store"; table: string } + | { kind: "read"; leg: "web"; table?: never } + | { kind: "write"; leg?: "store" | "web"; table?: string } +); + +export type ToolCall = { tool: string; arguments: Record; publicationBound?: string }; +export type Source = { id: string; label: string; url?: string }; +export type ToolResult = { rows: unknown[]; sources: Source[] }; +export type TurnRequest = { question: string; packs: string[]; store?: string; vantage?: string }; +export type DataTable = { name: string; kind: "data" | "memory" }; +export type Pack = { name: string; face: "organization" | "store"; tools: Array<{ name: string; kind: ToolKind }> }; + +export class ConsoleError extends Error { + readonly code: string; + readonly status: number; + + constructor(code: string, message = code, status = 400) { + super(message); + this.name = "ConsoleError"; + this.code = code; + this.status = status; + } +} + +export function validatePack(pack: Pack): void { + if (pack.face === "organization") { + const write = pack.tools.find((tool) => tool.kind === "write"); + if (write) throw new ConsoleError("ConsoleWriteToolOnOrgFace", `${pack.name}: ${write.name}`); + } +} + +export function parseVantage(value: string): string { + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + const validDay = Number.isInteger(year) && Number.isInteger(month) && Number.isInteger(day) + && new Date(Date.UTC(year, month - 1, day)).toISOString().slice(0, 10) === value.slice(0, 10); + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const date = new Date(`${value}T00:00:00Z`); + if (validDay && !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value) return value; + } else if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) { + const date = new Date(value); + if (validDay && !Number.isNaN(date.getTime())) return value; + } + throw new ConsoleError("ConsoleVantageUnparseable", value, 400); +} + +export function parseWebBound(value: string): string { + try { return parseVantage(value); } + catch { throw new ConsoleError("ConsoleWebBoundUnparseable", value); } +} + +export function sampleArrivals(arrivals: Array<{ table: string; labels: string[] }>) { + return arrivals.map((arrival) => ({ table: arrival.table, labels: arrival.labels.slice(0, 3) })); +} + +export async function resolveReaderCredential(options: { + mint: () => Promise; + shared?: string; +}): Promise { + try { + const credential = await options.mint(); + if (credential.trim()) return credential; + throw new ConsoleError("ConsoleTokenExchangeUnavailable", "exchange returned an empty credential", 503); + } catch (error) { + if (!(error instanceof ConsoleError && error.code === "ConsoleTokenExchangeRefused" && error.status === 403)) throw error; + } + if (options.shared) return options.shared; + throw new ConsoleError("ConsoleTokenExchangeRefused", "ConsoleTokenExchangeRefused", 403); +} + +export class OverlayCache { + private entries = new Map(); + private readonly read: (store: string) => Promise; + private readonly clock: () => number; + + constructor( + read: (store: string) => Promise, + clock: () => number = Date.now, + ) { + this.read = read; + this.clock = clock; + } + + async get(store: string): Promise { + const cached = this.entries.get(store); + if (cached && this.clock() < cached.until) return cached.value ?? ""; + const value = (await this.read(store))?.slice(0, 8000) ?? null; + this.entries.set(store, { until: this.clock() + 300_000, value }); + return value ?? ""; + } +} + +export class StreamingRedactor { + private pending = ""; + private readonly denylist: string[]; + + constructor(denylist: string[]) { + if (denylist.some((entry) => entry.length > 128)) throw new RangeError("denylist entry exceeds 128 chars"); + this.denylist = [...new Set(denylist.filter(Boolean))].sort((a, b) => b.length - a.length); + } + + push(chunk: string): string { + this.pending += chunk; + return this.release(Math.max(0, this.pending.length - 128)); + } + + finish(): string { + return this.release(this.pending.length); + } + + private release(until: number): string { + let output = ""; + let index = 0; + while (index < until) { + const denied = this.denylist.find((entry) => this.pending.startsWith(entry, index)); + if (denied) { + output += "[redacted]"; + index += denied.length; + } else { + output += this.pending[index]; + index++; + } + } + this.pending = this.pending.slice(index); + return output; + } +} + +type PlannerInput = { + question: string; + vantage?: string; + tools: Tool[]; + tables: DataTable[]; + previous: ToolResult[]; +}; +type SynthesisInput = { + question: string; + vantage?: string; + results: ToolResult[]; + overlay: string; + sources: Source[]; +}; + +export type TurnOptions = { + tools: Tool[]; + packs?: Pack[]; + tables?: DataTable[]; + planner: (input: PlannerInput) => Promise; + transport: { call: (call: ToolCall & { vantage?: string; maxRows: number; signal: AbortSignal }) => Promise }; + synthesize: (input: SynthesisInput) => AsyncIterable; + overlay?: (store: string) => Promise; + denylist?: string[]; + clock?: () => number; + timeoutMs?: number; +}; + +export function createTurn(options: TurnOptions) { + options.packs?.forEach(validatePack); + const overlay = new OverlayCache(options.overlay ?? (async () => null), options.clock); + + return { + async ask(request: TurnRequest): Promise<{ text: string; sources: Source[] }> { + const vantage = request.vantage === undefined ? undefined : parseVantage(request.vantage); + const admitted = options.tools.filter((tool) => request.packs.includes(tool.pack)); + const results: ToolResult[] = []; + const rowsByTable = new Map(); + const deadline = Date.now() + Math.min(options.timeoutMs ?? 10_000, 10_000); + for (let round = 0; round < 2; round++) { + const memoryTable = (name: string | undefined) => options.tables?.some((table) => table.kind === "memory" && table.name === name); + const calls = await options.planner({ + question: request.question, + vantage, + tools: admitted.filter((tool) => tool.kind === "read" && !memoryTable(tool.table)), + tables: (options.tables ?? []).filter((table) => table.kind === "data"), + previous: results, + }); + for (const call of calls) { + const tool = options.tools.find((candidate) => candidate.name === call.tool); + if (!tool || !request.packs.includes(tool.pack)) throw new ConsoleError("ConsoleToolNotAdmitted", call.tool); + if (tool.kind !== "read") throw new ConsoleError("ConsoleMutatingToolRequested", call.tool); + if (memoryTable(tool.table)) throw new ConsoleError("ConsolePlannerReachedMemory", call.tool); + if (tool.access === "direct-file") throw new ConsoleError("ConsoleFileAccessDirect", call.tool); + if (tool.leg === "web" && call.publicationBound !== undefined) parseWebBound(call.publicationBound); + const table = tool.leg === "web" ? undefined : tool.table; + if (tool.leg !== "web" && (!table || !options.tables?.some((entry) => entry.kind === "data" && entry.name === table))) { + throw new RangeError(`tool ${call.tool} has no registered data table`); + } + const consumed = table === undefined ? 0 : rowsByTable.get(table) ?? 0; + const remaining = 5000 - consumed; + if (remaining <= 0) throw new RangeError("tool exceeded 5000 rows per data table"); + const controller = new AbortController(); + const milliseconds = Math.max(0, deadline - Date.now()); + let timeout: ReturnType | undefined; + try { + const result = await Promise.race([ + options.transport.call({ ...call, vantage, maxRows: remaining, signal: controller.signal }), + new Promise((_, reject) => { timeout = setTimeout(() => { + controller.abort(); + reject(new Error("code path exceeded 10 s")); + }, milliseconds); }), + ]); + if (result.rows.length > remaining) throw new RangeError("tool returned more than 5000 rows per data table"); + if (table !== undefined) rowsByTable.set(table, consumed + result.rows.length); + results.push(result); + } finally { + if (timeout) clearTimeout(timeout); + } + } + if (results.some((result) => result.rows.length > 0)) break; + } + if (!results.some((result) => result.rows.length > 0)) throw new ConsoleError("ConsoleUngroundedAnswer", "The store holds no matching information."); + + const sources = [...new Map(results.flatMap((result) => result.sources).map((source) => [source.id, source])).values()].slice(0, 8); + if (sources.length === 0) throw new ConsoleError("ConsoleUngroundedAnswer", "The store holds no sourced information."); + const redactor = new StreamingRedactor(options.denylist ?? []); + let answer = ""; + for await (const chunk of options.synthesize({ question: request.question, vantage, results, overlay: await overlay.get(request.store ?? "default"), sources })) { + answer += redactor.push(chunk); + } + answer += redactor.finish(); + const citations = new Map(sources.map((source, index) => [source.id, `source-${index + 1}`])); + answer = answer.replace(/\[([^\]\n]+)\]/g, (match, id: string) => id === "redacted" ? match : citations.has(id) ? `[${citations.get(id)}]` : ""); + const safeSources = sources.map((source, index) => { + const labelRedactor = new StreamingRedactor(options.denylist ?? []); + const label = labelRedactor.push(source.label) + labelRedactor.finish(); + const safeLabel = label.replace(/[\r\n]/g, " "); + const url = source.url && /^https?:\/\//.test(source.url) + && !(options.denylist ?? []).some((entry) => entry && source.url?.includes(entry)) ? source.url : undefined; + return { id: `source-${index + 1}`, label: safeLabel, url }; + }); + const sourceLines = safeSources.map((source) => `- ${source.label}${source.url ? ` (${source.url})` : ""}`); + return { text: `${answer.trim()}\n\nSources\n${sourceLines.join("\n")}`, sources: safeSources }; + }, + }; +} diff --git a/apps/console/test/live.test.ts b/apps/console/test/live.test.ts new file mode 100644 index 000000000..5e03361c3 --- /dev/null +++ b/apps/console/test/live.test.ts @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createLiveTurn } from "../src/live.ts"; +import { ConsoleError } from "../src/turn.ts"; +import { createConsole, issueCognitoSession } from "../src/index.ts"; +import type { StoreEntry } from "../../gateway/src/index.ts"; + +const store: StoreEntry = { + id: "field-notes", label: "Field notes", endpoint: "https://store.example", + auth: "exchange", exchangeRoute: "/auth/exchange", credentialName: "FIELD_NOTES_QUERY_TOKEN", + bindingName: "FIELD_NOTES", +}; + +function fixture(options: { exchange?: boolean; exchangeStatus?: number; emptyToken?: boolean; shared?: string; rows?: unknown[][]; tables?: Array<{ table: string; kind: string }> } = {}) { + const calls: Array<{ url: string; authorization: string | null; body: Record }> = []; + let modelCalls = 0; + let modelBody: Record | null = null; + const fetcher: typeof fetch = async (input, init) => { + const url = String(input); + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + calls.push({ url, authorization: new Headers(init?.headers).get("authorization"), body }); + if (url.endsWith("/auth/exchange")) { + const status = options.exchangeStatus ?? (options.exchange === false ? 403 : 200); + return Response.json(status === 200 ? { token: options.emptyToken ? "" : "reader-token" } : { error: { identifier: status === 503 ? "ExchangeMaterialMissing" : "ExchangeAssertionInvalid" } }, { status }); + } + if (url.endsWith("/mcp")) { + const params = body.params as Record; + if (params.name === "context.describe") return Response.json({ jsonrpc: "2.0", id: body.id, result: { structuredContent: { + tables: options.tables ?? [{ table: "filings", kind: "data" }], + } } }); + assert.equal(params.name, "context.query"); + return Response.json({ jsonrpc: "2.0", id: body.id, result: { structuredContent: { + columns: ["filing_id", "summary", "source_url"], + rows: options.rows ?? [["filing-1", "Northwind filed on Monday", "https://example.test/filing-1"]], + } } }); + } + if (url.endsWith("/chat/completions")) { + modelCalls++; + modelBody = body; + return Response.json({ choices: [{ message: { content: "Northwind filed on Monday [filing-1]." } }] }); + } + throw new Error(`unexpected request ${url}`); + }; + const turn = createLiveTurn({ stores: [store], env: { + CONTEXTFUL_MODEL_ENDPOINT: "https://model.example/v1", CONTEXTFUL_MODEL_ID: "fixture", + ...(options.shared ? { FIELD_NOTES_QUERY_TOKEN: options.shared } : {}), + }, fetcher }); + return { turn, calls, modelCalls: () => modelCalls, modelBody: () => modelBody }; +} + +test("Query reads governed rows under the exchanged reader credential and cites their provenance", async () => { + const { turn, calls, modelCalls } = fixture(); + const answer = await turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" }); + assert.match(answer.answer, /Northwind filed on Monday/); + assert.match(JSON.stringify(answer.sources), /filing-1/); + assert.equal(modelCalls(), 1); + assert.equal(calls.find((call) => call.url.endsWith("/auth/exchange"))?.body.jwt, "verified-access-jwt"); + assert.equal(calls.find((call) => (call.body.params as Record | undefined)?.name === "context.query")?.authorization, "Bearer reader-token"); + assert.deepEqual(calls.filter((call) => call.url.endsWith("/mcp")).map((call) => (call.body.params as Record).name), ["context.describe", "context.query"]); +}); + +// spec: surface.ground.mint-refused@1ca62976 +test("Query falls back to a configured shared credential only after exchange refusal", async () => { + const fallback = fixture({ exchange: false, shared: "shared-token" }); + await fallback.turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" }); + assert.equal(fallback.calls.find((call) => (call.body.params as Record | undefined)?.name === "context.query")?.authorization, "Bearer shared-token"); + const refused = fixture({ exchange: false }); + await assert.rejects(refused.turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" }), /ConsoleTokenExchangeRefused/); + assert.equal(refused.modelCalls(), 0); + assert.equal(refused.calls.filter((call) => call.url.endsWith("/mcp")).length, 0); +}); + +test("Query does not use a shared credential after exchange service failure", async () => { + const { turn, calls } = fixture({ exchangeStatus: 503, shared: "shared-token" }); + await assert.rejects(turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" })); + assert.equal(calls.filter((call) => call.url.endsWith("/mcp")).length, 0); +}); + +test("Query refuses an empty minted credential without using a shared credential", async () => { + const { turn, calls } = fixture({ emptyToken: true, shared: "shared-token" }); + await assert.rejects(turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleTokenExchangeUnavailable"); + assert.equal(calls.filter((call) => call.url.endsWith("/mcp")).length, 0); +}); + +test("Query reads only a question-matched data table and refuses an unclear selection", async () => { + const tables = [{ table: "filings", kind: "data" }, { table: "salaries", kind: "data" }]; + const matched = fixture({ tables }); + await matched.turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" }); + assert.deepEqual(matched.calls.filter((call) => (call.body.params as Record | undefined)?.name === "context.query") + .map((call) => (call.body.params as Record).arguments), + [{ sql: 'SELECT * FROM "filings"', limit: 8 }]); + const unclear = fixture({ tables }); + await assert.rejects(unclear.turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "What changed?" }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleUngroundedAnswer"); + assert.equal(unclear.calls.filter((call) => call.url.endsWith("/mcp") && (call.body.params as Record).name === "context.query").length, 0); +}); + +test("Query sends the model only rows represented by its eight visible sources", async () => { + const rows = Array.from({ length: 9 }, (_, index) => [`filing-${index + 1}`, `Filing ${index + 1}`, `https://example.test/filing-${index + 1}`]); + const { turn, modelBody } = fixture({ rows }); + const result = await turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" }); + assert.equal(result.sources.length, 8); + assert.doesNotMatch(JSON.stringify(modelBody()), /filing-9/); +}); + +test("Query never sends rows without provenance to the model", async () => { + const { turn, modelCalls } = fixture({ rows: [["", "Northwind filed on Monday", ""]] }); + await assert.rejects(turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleUngroundedAnswer"); + assert.equal(modelCalls(), 0); +}); + +test("Query excludes an unsourced sibling row from model context", async () => { + const { turn, modelBody } = fixture({ rows: [ + ["filing-1", "Northwind filed on Monday", "https://example.test/filing-1"], + ["", "PRIVATE UNSOURCED ROW", ""], + ] }); + await turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" }); + assert.doesNotMatch(JSON.stringify(modelBody()), /PRIVATE UNSOURCED ROW/); +}); + +test("Query never scaffolds or dispatches a memory relation", async () => { + const { turn, calls } = fixture({ tables: [{ table: "research/insights", kind: "memory" }, { table: "filings", kind: "data" }] }); + await turn({ operator: { subject: "operator-1", grants: new Set(["query"]), assertion: "verified-access-jwt" }, + store: "field-notes", question: "Which filing arrived?" }); + assert.deepEqual(calls.filter((call) => (call.body.params as Record | undefined)?.name === "context.query") + .map((call) => (call.body.params as Record).arguments), + [{ sql: 'SELECT * FROM "filings"', limit: 8 }]); +}); + +test("Query returns a typed exchange refusal through its hosted API", async () => { + const { turn, calls } = fixture({ exchange: false }); + const app = createConsole({ + identity: { kind: "cognito", sessionSecret: "session-secret", queryGroup: "query", adminGroup: "admin" }, + stores: [{ id: store.id, label: store.label }], turn, + read: { list: async () => [{ id: store.id, label: store.label }] }, + control: { workflows: async () => ({}), record: async () => ({}), edit: async () => ({}), apply: async () => ({}) }, + }); + const token = issueCognitoSession({ subject: "reader", groups: ["query"], assertion: "cognito-id-token", + expiresAt: Math.floor(Date.now() / 1000) + 60 }, "session-secret"); + const response = await app.fetch(new Request("https://console.example/query/api/ask", { + method: "POST", headers: { cookie: `console_session=${token}`, origin: "https://console.example" }, + body: JSON.stringify({ store: store.id, question: "Which filing arrived?" }), + })); + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: { identifier: "ConsoleTokenExchangeRefused" } }); + assert.equal(calls.filter((call) => call.url.endsWith("/mcp")).length, 0); +}); + +test("Cognito Query exchanges the signed-in reader's assertion", async () => { + const { turn, calls } = fixture(); + const app = createConsole({ + identity: { kind: "cognito", sessionSecret: "session-secret", queryGroup: "query", adminGroup: "admin" }, + stores: [{ id: store.id, label: store.label }], turn, + read: { list: async () => [{ id: store.id, label: store.label }] }, + control: { workflows: async () => ({}), record: async () => ({}), edit: async () => ({}), apply: async () => ({}) }, + }); + const token = issueCognitoSession({ subject: "reader", groups: ["query"], assertion: "cognito-id-token", + expiresAt: Math.floor(Date.now() / 1000) + 60 }, "session-secret"); + const response = await app.fetch(new Request("https://console.example/query/api/ask", { + method: "POST", headers: { cookie: `console_session=${token}`, origin: "https://console.example" }, + body: JSON.stringify({ store: store.id, question: "Which filing arrived?" }), + })); + assert.equal(response.status, 200); + assert.equal(calls.find((call) => call.url.endsWith("/auth/exchange"))?.body.jwt, "cognito-id-token"); +}); diff --git a/apps/console/test/turn.test.ts b/apps/console/test/turn.test.ts new file mode 100644 index 000000000..75411cae3 --- /dev/null +++ b/apps/console/test/turn.test.ts @@ -0,0 +1,279 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + ConsoleError, + OverlayCache, + StreamingRedactor, + createTurn, + parseVantage, + parseWebBound, + resolveReaderCredential, + sampleArrivals, + validatePack, +} from "../src/turn.ts"; + +const readTool = { name: "query", pack: "data", kind: "read" as const, table: "events" }; + +function harness(overrides: Record = {}) { + const calls: string[] = []; + const turn = createTurn({ + tools: [readTool], + tables: [{ name: "events", kind: "data" }], + planner: async () => [{ tool: "query", arguments: { question: "count" } }], + transport: { + call: async (call: { tool: string }) => { + calls.push(call.tool); + return { rows: [{ count: 3 }], sources: [{ id: "row-1", label: "Count", url: "https://example.test/1" }] }; + }, + }, + synthesize: async function* () { yield "Three rows."; }, + ...overrides, + }); + return { turn, calls }; +} + +test("a grounded turn cites only governed tool results", async () => { + const { turn, calls } = harness({ synthesize: async function* () { yield "Three rows [row-1]."; } }); + const answer = await turn.ask({ question: "How many?", packs: ["data"] }); + assert.deepEqual(calls, ["query"]); + assert.match(answer.text, /Three rows/); + assert.match(answer.text, /\[source-1\]/); + assert.match(answer.text, /Sources\n- Count/); + assert.deepEqual(answer.sources.map((source: { id: string }) => source.id), ["source-1"]); +}); + +test("a turn with no tool result refuses model prose", async () => { + const { turn } = harness({ + transport: { call: async () => ({ rows: [], sources: [] }) }, + }); + await assert.rejects(turn.ask({ question: "Guess", packs: ["data"] }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleUngroundedAnswer"); +}); + +test("unadmitted and mutating calls dispatch nothing", async () => { + const { turn, calls } = harness({ + planner: async () => [{ tool: "query", arguments: {} }], + }); + await assert.rejects(turn.ask({ question: "Read", packs: [] }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleToolNotAdmitted"); + assert.deepEqual(calls, []); + const write = harness({ tools: [{ name: "erase", pack: "data", kind: "write" }], planner: async () => [{ tool: "erase", arguments: {} }] }); + await assert.rejects(write.turn.ask({ question: "Erase", packs: ["data"] }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleMutatingToolRequested"); + assert.deepEqual(write.calls, []); +}); + +// spec: surface.ground.org-face-read-only@030074cb +test("organization packs refuse writes at startup", () => { + assert.throws(() => validatePack({ name: "shared", face: "organization", tools: [{ name: "erase", kind: "write" }] }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleWriteToolOnOrgFace"); +}); + +// spec: surface.ground.direct-file-read@4edf9c49 +test("direct file table functions refuse before dispatch", async () => { + const { turn, calls } = harness({ tools: [{ name: "query", pack: "data", kind: "read", access: "direct-file" }] }); + await assert.rejects(turn.ask({ question: "Read", packs: ["data"] }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleFileAccessDirect"); + assert.deepEqual(calls, []); +}); + +test("source list caps at eight and excludes synthetic citations", async () => { + const { turn } = harness({ + transport: { call: async () => ({ rows: [{ value: 1 }], sources: Array.from({ length: 10 }, (_, i) => ({ id: `s${i}`, label: `Source ${i}` })) }) }, + synthesize: async function* () { yield "Answer [invented]."; }, + }); + const answer = await turn.ask({ question: "List", packs: ["data"] }); + assert.equal(answer.sources.length, 8); + assert.doesNotMatch(answer.text, /invented/); +}); + +test("source identifiers returned to Query never expose denylisted material", async () => { + const { turn } = harness({ + denylist: ["SECRET-ID"], + transport: { call: async () => ({ rows: [{ value: 1 }], sources: [{ id: "SECRET-ID", label: "Public filing" }] }) }, + synthesize: async function* () { yield "Answer [SECRET-ID]."; }, + }); + const answer = await turn.ask({ question: "Explain", packs: ["data"] }); + assert.doesNotMatch(JSON.stringify(answer), /SECRET-ID/); + assert.match(answer.sources[0].id, /^source-\d+$/); +}); + +test("rows without governed provenance cannot become answer prose", async () => { + const { turn } = harness({ transport: { call: async () => ({ rows: [{ value: 1 }], sources: [] }) } }); + await assert.rejects(turn.ask({ question: "Explain", packs: ["data"] }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleUngroundedAnswer"); +}); + +test("a web leg with an invalid publication bound fails before dispatch", async () => { + const { turn, calls } = harness({ + tools: [{ name: "web", pack: "web", kind: "read", leg: "web" }], + planner: async () => [{ tool: "web", arguments: {}, publicationBound: "no-date" }], + }); + await assert.rejects(turn.ask({ question: "Search", packs: ["web"] }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleWebBoundUnparseable"); + assert.deepEqual(calls, []); +}); + +test("a failed first round replans once and the overlay reaches synthesis alone", async () => { + const plannerInputs: unknown[] = []; + const synthesisInputs: unknown[] = []; + let reads = 0; + const { turn } = harness({ + planner: async (input: unknown) => { plannerInputs.push(input); return [{ tool: "query", arguments: {} }]; }, + transport: { call: async () => (++reads === 1 ? { rows: [], sources: [] } : { rows: [{ value: 1 }], sources: [{ id: "one", label: "One" }] }) }, + overlay: async () => "Operator voice", + synthesize: async function* (input: unknown) { synthesisInputs.push(input); yield "One."; }, + }); + await turn.ask({ question: "Find", packs: ["data"] }); + assert.equal(plannerInputs.length, 2); + assert.equal(reads, 2); + assert.doesNotMatch(JSON.stringify(plannerInputs), /Operator voice/); + assert.match(JSON.stringify(synthesisInputs), /Operator voice/); +}); + +test("vantage accepts calendar days and instants, and rejects invalid dates", () => { + assert.equal(parseVantage("2026-10-06"), "2026-10-06"); + assert.equal(parseVantage("2026-10-06T12:30:00Z"), "2026-10-06T12:30:00Z"); + assert.throws(() => parseVantage("2026-02-30"), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleVantageUnparseable" && error.status === 400); + assert.throws(() => parseVantage("2026-02-30T12:30:00Z"), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleVantageUnparseable"); + assert.throws(() => parseWebBound("unknown"), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleWebBoundUnparseable"); +}); + +test("refused per-reader mint falls back only to an installed shared credential", async () => { + const mint = async () => { throw new ConsoleError("ConsoleTokenExchangeRefused", "denied", 403); }; + assert.equal(await resolveReaderCredential({ mint, shared: "shared-token" }), "shared-token"); + await assert.rejects(resolveReaderCredential({ mint }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleTokenExchangeRefused"); +}); + +test("reader credential resolution preserves transport and service failures", async () => { + const outage = new ConsoleError("ConsoleTokenExchangeUnavailable", "outage", 503); + await assert.rejects(resolveReaderCredential({ mint: async () => { throw outage; }, shared: "shared-token" }), + (error: unknown) => error === outage); + const network = new TypeError("network offline"); + await assert.rejects(resolveReaderCredential({ mint: async () => { throw network; }, shared: "shared-token" }), + (error: unknown) => error === network); +}); + +test("an empty minted credential never activates a shared fallback", async () => { + await assert.rejects(resolveReaderCredential({ mint: async () => "", shared: "shared-token" }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsoleTokenExchangeUnavailable"); +}); + +test("planner scaffolding lists data tables while memory relations remain outside it", async () => { + let plannerTables: unknown[] = []; + const { turn } = harness({ + tables: [{ name: "events", kind: "data" }, { name: "memory_entries", kind: "memory" }], + planner: async (input: { tables: unknown[] }) => { plannerTables = input.tables; return [{ tool: "query", arguments: {} }]; }, + }); + await turn.ask({ question: "Count", packs: ["data"] }); + assert.deepEqual(plannerTables, [{ name: "events", kind: "data" }]); +}); + +// spec: surface.plan-turn.planner-reached-memory@a6f057a5 +test("a planner call targeting a memory relation dispatches nothing", async () => { + const { turn, calls } = harness({ + tables: [{ name: "memory_entries", kind: "memory" }], + tools: [{ ...readTool, table: "memory_entries" }], + }); + await assert.rejects(turn.ask({ question: "Recall", packs: ["data"] }), + (error: unknown) => error instanceof ConsoleError && error.code === "ConsolePlannerReachedMemory"); + assert.deepEqual(calls, []); +}); + +test("the code path refuses an unresponsive transport within ten seconds", async () => { + const { turn } = harness({ + transport: { call: async () => new Promise(() => {}) }, + timeoutMs: 5, + }); + await assert.rejects(turn.ask({ question: "Slow", packs: ["data"] }), + /code path exceeded 10 s/); +}); + +test("the transport receives a bounded read and cannot return more than 5000 rows", async () => { + let maxRows = 0; + const { turn } = harness({ transport: { call: async (call: { maxRows: number }) => { + maxRows = call.maxRows; + return { rows: Array.from({ length: 5001 }, () => ({})), sources: [] }; + } } }); + await assert.rejects(turn.ask({ question: "Many", packs: ["data"] }), RangeError); + assert.equal(maxRows, 5000); +}); + +test("one table cannot exceed the row budget through repeated calls", async () => { + const limits: number[] = []; + const { turn } = harness({ + tools: [{ ...readTool, table: "events" }], + planner: async () => [{ tool: "query", arguments: {} }, { tool: "query", arguments: {} }], + transport: { call: async (call: { maxRows: number }) => { + limits.push(call.maxRows); + return { rows: Array.from({ length: 3000 }, () => ({})), sources: [{ id: "events", label: "Events" }] }; + } }, + }); + await assert.rejects(turn.ask({ question: "Many", packs: ["data"] }), RangeError); + assert.deepEqual(limits, [5000, 2000]); +}); + +test("unbound store tools cannot claim a per-table row budget", async () => { + const dispatched: string[] = []; + const { turn } = harness({ + tools: [{ name: "query-a", pack: "data", kind: "read" }, { name: "query-b", pack: "data", kind: "read" }], + planner: async () => [ + { tool: "query-a", arguments: { sql: "SELECT * FROM events" } }, + { tool: "query-b", arguments: { sql: "SELECT * FROM events" } }, + ], + transport: { call: async (call: { tool: string }) => { + dispatched.push(call.tool); + return { rows: Array.from({ length: 5000 }, () => ({})), sources: [{ id: "events", label: "Events" }] }; + } }, + }); + await assert.rejects(turn.ask({ question: "Many", packs: ["data"] }), /registered data table/); + assert.deepEqual(dispatched, []); +}); + +test("distinct registered tools share one data table's row budget", async () => { + const limits: number[] = []; + const { turn } = harness({ + tools: [ + { name: "query-a", pack: "data", kind: "read", table: "events" }, + { name: "query-b", pack: "data", kind: "read", table: "events" }, + ], + planner: async () => [{ tool: "query-a", arguments: {} }, { tool: "query-b", arguments: {} }], + transport: { call: async (call: { maxRows: number }) => { + limits.push(call.maxRows); + return { rows: Array.from({ length: 3000 }, () => ({})), sources: [{ id: "events", label: "Events" }] }; + } }, + }); + await assert.rejects(turn.ask({ question: "Many", packs: ["data"] }), RangeError); + assert.deepEqual(limits, [5000, 2000]); +}); + +// spec: surface.set-vantage.sample-labels@95b1fd75 +test("a table contributes at most three sampled arrival labels", () => { + assert.deepEqual(sampleArrivals([{ table: "events", labels: ["a", "b", "c", "d"] }]), + [{ table: "events", labels: ["a", "b", "c"] }]); +}); + +test("overlay cache includes misses, expires after five minutes and truncates at 8000 chars", async () => { + let now = 0; + let reads = 0; + const cache = new OverlayCache(async () => { reads++; return "x".repeat(9000); }, () => now); + assert.equal((await cache.get("store")).length, 8000); + now = 299_999; + await cache.get("store"); + assert.equal(reads, 1); + now = 300_000; + await cache.get("store"); + assert.equal(reads, 2); +}); + +// spec: surface.speak.redactor-lookahead@2d3a1762 +test("streaming redactor masks identifiers split across chunks", () => { + const redactor = new StreamingRedactor(["SECRET-ID"]); + const output = redactor.push("before SEC") + redactor.push("RET-ID after") + redactor.finish(); + assert.equal(output, "before [redacted] after"); + assert.throws(() => new StreamingRedactor(["x".repeat(129)]), RangeError); +}); diff --git a/crates/contextful-agent/src/http.rs b/crates/contextful-agent/src/http.rs index 37062fc43..27074f4e4 100644 --- a/crates/contextful-agent/src/http.rs +++ b/crates/contextful-agent/src/http.rs @@ -108,7 +108,7 @@ pub struct HttpResponse { } impl HttpResponse { - fn json(status: u16, body: &Value) -> HttpResponse { + pub fn json(status: u16, body: &Value) -> HttpResponse { HttpResponse { status, headers: vec![("Content-Type".into(), "application/json".into())], body: body.to_string().into_bytes() } } @@ -195,6 +195,8 @@ pub struct HttpFace<'a, C> { admitting: Admitting<'a, C>, ceiling: usize, in_flight: AtomicUsize, + exchange: Option<&'a (dyn Fn(&HttpRequest) -> HttpResponse + Sync)>, + exchange_unconfigured: bool, } /// A request slot held while one request is in flight. @@ -241,7 +243,15 @@ impl<'a, C: Clock + Sync> HttpFace<'a, C> { let ceiling = ceiling(max_in_flight)?; audience(Some(admitting.audience))?; let tools = Tools::new(face, clock, record)?; - Ok(HttpFace { tools, admitting, ceiling, in_flight: AtomicUsize::new(0) }) + Ok(HttpFace { tools, admitting, ceiling, in_flight: AtomicUsize::new(0), exchange: None, exchange_unconfigured: false }) + } + + /// The binary's exchange route mints the reader credential without putting issuer + /// signing material in the read-transport package. + pub fn with_exchange(mut self, exchange: &'a (dyn Fn(&HttpRequest) -> HttpResponse + Sync), unconfigured: bool) -> Self { + self.exchange = Some(exchange); + self.exchange_unconfigured = unconfigured; + self } /// Accept connections on `listener` until it fails. Each accepted connection takes a @@ -283,7 +293,9 @@ impl<'a, C: Clock + Sync> HttpFace<'a, C> { fn connection(&self, mut stream: TcpStream, slot: Slot<'_>) -> std::io::Result<()> { stream.set_read_timeout(Some(REQUEST_READ_TIMEOUT))?; stream.set_write_timeout(Some(REQUEST_READ_TIMEOUT))?; - let response = match read_request(&mut stream) { + let response = match read_request_preflight(&mut stream, |head| { + (self.exchange_unconfigured && head.path() == "/auth/exchange").then(|| self.answer(head)) + }) { Ok(request) => self.answer(&request), Err(response) => response, }; @@ -329,6 +341,8 @@ impl<'a, C: Clock + Sync> HttpFace<'a, C> { (MCP_PATH, _) => { HttpResponse::message(405, "`/mcp` answers POST; the face holds no session and opens no server stream").with("Allow", "POST") } + ("/auth/exchange", "POST") if self.exchange.is_some() => self.exchange.expect("checked above")(request), + ("/auth/exchange", _) if self.exchange.is_some() => HttpResponse::message(405, "`/auth/exchange` answers POST").with("Allow", "POST"), (other, _) => HttpResponse::message(404, format!("no route `{other}`; the protocol endpoint is `{MCP_PATH}`")), } } @@ -379,6 +393,10 @@ impl<'a, C: Clock + Sync> HttpFace<'a, C> { /// Read one request: the head, then a `Content-Length` body of at most /// [`REQUEST_BODY_BYTES`]. A malformed or oversized request is answered, not read on. pub fn read_request(stream: &mut impl Read) -> Result { + read_request_preflight(stream, |_| None) +} + +fn read_request_preflight(stream: &mut impl Read, preflight: impl Fn(&HttpRequest) -> Option) -> Result { let mut buf = Vec::with_capacity(1024); let mut chunk = [0u8; 4096]; let (mut request, head_len) = loop { @@ -414,6 +432,9 @@ pub fn read_request(stream: &mut impl Read) -> Result Err(e) => return Err(HttpResponse::message(400, format!("the request head does not parse: {e}"))), } }; + if let Some(response) = preflight(&request) { + return Err(response); + } if request.header("Transfer-Encoding").is_some() { return Err(HttpResponse::message(411, "the face reads a `Content-Length` body")); } diff --git a/crates/contextful-agent/tests/integration/http.rs b/crates/contextful-agent/tests/integration/http.rs index d20bb514f..d3874beee 100644 --- a/crates/contextful-agent/tests/integration/http.rs +++ b/crates/contextful-agent/tests/integration/http.rs @@ -4,7 +4,7 @@ //! per-request admission of short-lived bearers and of holder-bound credentials, each //! request under its own proof, the in-flight ceiling and concurrency. -use contextful_agent::http::{audience, ceiling, read_request, Admitting, HttpFace, HttpRequest, Revocation}; +use contextful_agent::http::{audience, ceiling, read_request, Admitting, HttpFace, HttpRequest, HttpResponse, Revocation}; use contextful_agent::mcp::Server; use contextful_context::land::{land, Batch, RunContext}; use contextful_context::read::Face; @@ -599,3 +599,46 @@ fn post_mcp_reads_a_pinned_build_and_echoes_it() { assert_eq!(unpinned["rows"], json!([["n1"], ["n2"]]), "{unpinned}"); assert_eq!(unpinned["contextful.resolved"]["research/titles"]["build_id"], json!(latest), "{unpinned}"); } + +/// A listener whose face routes `/auth/exchange` to `exchange`, marked unconfigured or not. +fn listen_exchanging(unconfigured: bool) -> SocketAddr { + let f: &'static Fixture = Box::leak(Box::new(fixture())); + let clock: &'static FixedClock = Box::leak(Box::new(FixedClock(at(NOW)))); + let admitting = Admitting { checkpoint: &f.checkpoint, audience: AUD, revocation: &no_revocation }; + let exchange: &'static (dyn Fn(&HttpRequest) -> HttpResponse + Sync) = Box::leak(Box::new(move |request: &HttpRequest| { + if unconfigured { + HttpResponse::json(404, &json!({ "error": { "identifier": "ExchangeUnconfigured" } })) + } else { + HttpResponse::json(200, &json!({ "received": request.body.len() })) + } + })); + let face = Box::leak(Box::new(HttpFace::new(&f.face, clock, &f.audit, admitting, Some(4)).unwrap().with_exchange(exchange, unconfigured))); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || face.serve(listener)); + addr +} + +#[test] +fn exchange_route_answers_post_and_refuses_other_methods() { + let addr = listen_exchanging(false); + let post = HttpRequest { method: "POST".into(), target: "/auth/exchange".into(), headers: vec![("Content-Type".into(), "application/json".into())], body: b"{\"jwt\":\"x\"}".to_vec() }; + let (status, _, answer) = send(addr, &post); + assert_eq!((status, body(&answer)), (200, json!({ "received": 11 }))); + let get = HttpRequest { method: "GET".into(), target: "/auth/exchange".into(), headers: vec![], body: vec![] }; + let (status, head, _) = send(addr, &get); + assert_eq!(status, 405); + assert!(head.contains("Allow: POST"), "{head}"); +} + +#[test] +fn unconfigured_exchange_answers_before_reading_its_body() { + let addr = listen_exchanging(true); + let mut stream = TcpStream::connect(addr).unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); + stream.write_all(b"POST /auth/exchange HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\n").unwrap(); + let mut raw = String::new(); + stream.read_to_string(&mut raw).expect("the unconfigured exchange answers without waiting for its body"); + assert!(raw.starts_with("HTTP/1.1 404"), "{raw}"); + assert!(raw.contains("ExchangeUnconfigured"), "{raw}"); +} diff --git a/crates/contextful-cli/src/serve.rs b/crates/contextful-cli/src/serve.rs index 05953d645..526e6d560 100644 --- a/crates/contextful-cli/src/serve.rs +++ b/crates/contextful-cli/src/serve.rs @@ -13,6 +13,11 @@ use crate::root::root as project_root; use crate::clock::SystemClock; use anyhow::Result; use contextful_agent::http::{audience, ceiling, Admitting, HttpFace}; +use contextful_core::issue::{IssuancePolicy, MintContext, NodeRole}; +use contextful_core::ports::{Clock, SigningPort}; +use contextful_policy::exchange::answer as exchange_answer; +use contextful_policy::issue::{SeedSigner, DEFAULT_SEED_PATH}; +use contextful_policy::possession::ProofChecker; use contextful_policy::audit::AuditLog; use contextful_policy::keyset::{KeyCheckpoint, StaticPins}; use contextful_policy::revoke::RevocationState; @@ -108,7 +113,28 @@ pub fn run(args: ServeArgs) -> Result<()> { None => face, }; let audit = AuditLog::unanchored(located.project.audit_dir())?; - let http = HttpFace::new(&face, &clock, &audit, admitting, Some(ceiling)).map_err(anyhow::Error::msg)?; + let exchange = crate::token::configured_exchange(&root)?; + let mint_material = if exchange.is_some() { + let policy = std::fs::read_to_string(root.join(IssuancePolicy::PATH))?; + let issuance = IssuancePolicy::parse(&policy).map_err(|e| anyhow::anyhow!("{e:?}"))?; + let signer = SeedSigner::resolve(Some(&root.join(DEFAULT_SEED_PATH)))?; + Some((issuance, signer)) + } else { + None + }; + let proofs = ProofChecker::new(SystemClock); + let exchange_route = |request: &contextful_agent::http::HttpRequest| { + let Some((issuance, signer)) = mint_material.as_ref() else { + return contextful_agent::http::HttpResponse::json(404, + &serde_json::json!({ "error": { "http": 404, "identifier": "ExchangeUnconfigured", "message": ".contextful/exchange/policy.toml is absent" } })); + }; + let ctx = MintContext { node: NodeRole::Primary, signer: signer as &dyn SigningPort, clock: &clock as &dyn Clock }; + let result = exchange_answer(exchange.as_ref(), &request.body, request.header("DPoP"), &proofs, issuance, &ctx); + contextful_agent::http::HttpResponse::json(result.status, &result.body) + }; + let http = HttpFace::new(&face, &clock, &audit, admitting, Some(ceiling)) + .map_err(anyhow::Error::msg)? + .with_exchange(&exchange_route, exchange.is_none()); let listener = TcpListener::bind(&args.http)?; eprintln!("listening on http://{}/mcp", listener.local_addr()?); http.serve(listener)?; diff --git a/crates/contextful-cli/tests/integration/serve.rs b/crates/contextful-cli/tests/integration/serve.rs index dafcfb36c..01f436f66 100644 --- a/crates/contextful-cli/tests/integration/serve.rs +++ b/crates/contextful-cli/tests/integration/serve.rs @@ -106,6 +106,65 @@ fn query() -> Value { json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "context.query", "arguments": { "sql": "SELECT note_id FROM \"research/notes\"" } } }) } +#[test] +fn served_exchange_without_policy_returns_the_unconfigured_refusal() { + let (dir, public) = project(); + let (_listener, addr) = serve(dir.path(), &["serve", "--http", "127.0.0.1:0", "--audience", AUD, + "--max-in-flight", "2", "--project", "research", "--public-key", &public]); + let body = "not-json"; + let mut stream = TcpStream::connect(&addr).unwrap(); + write!(stream, "POST /auth/exchange HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap(); + let mut raw = String::new(); + stream.read_to_string(&mut raw).unwrap(); + let (head, data) = raw.split_once("\r\n\r\n").unwrap(); + assert_eq!(head.split(' ').nth(1), Some("404")); + let answer: Value = serde_json::from_str(data).unwrap(); + assert_eq!(answer["error"]["identifier"], "ExchangeUnconfigured"); + let mut stream = TcpStream::connect(&addr).unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); + write!(stream, "POST /auth/exchange HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n").unwrap(); + let mut raw = String::new(); + stream.read_to_string(&mut raw).expect("unconfigured exchange answers before waiting for its body"); + assert!(raw.starts_with("HTTP/1.1 404"), "{raw}"); +} + +#[test] +fn served_exchange_mints_a_reader_credential_for_the_read_face() { + let (dir, public) = project(); + let p = dir.path(); + std::fs::create_dir_all(p.join(".contextful/exchange")).unwrap(); + std::fs::write(p.join(".contextful/exchange/policy.toml"), + "expected_iss = \"https://login.example.test/\"\nexpected_aud = \"console\"\nrole_claim = \"roles\"\n\ + [subject_map]\non_behalf_of = { claim = \"sub\", template = \"user://{}\" }\nzone = { claim = \"zone\" }\n\ + [[role_grants.reader]]\nactions = [\"read\"]\ntables = [\"research/*\"]\n").unwrap(); + std::fs::write(p.join(".contextful/exchange/verify.key"), "exchange-secret").unwrap(); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let jwt = jsonwebtoken::encode(&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256), + &json!({ "iss": "https://login.example.test/", "aud": "console", "exp": now + 300, + "sub": "reader@example.test", "zone": "on-prem:hq", "roles": ["reader"] }), + &jsonwebtoken::EncodingKey::from_secret(b"exchange-secret")).unwrap(); + let (_listener, addr) = serve(p, &["serve", "--http", "127.0.0.1:0", "--audience", AUD, + "--max-in-flight", "2", "--project", "research", "--public-key", &public]); + let exchange = |jwt: &str| { + let body = json!({ "jwt": jwt }).to_string(); + let mut stream = TcpStream::connect(&addr).unwrap(); + write!(stream, "POST /auth/exchange HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}", body.len()).unwrap(); + let mut raw = String::new(); + stream.read_to_string(&mut raw).unwrap(); + let (head, data) = raw.split_once("\r\n\r\n").unwrap(); + (head.split(' ').nth(1).unwrap().parse::().unwrap(), serde_json::from_str::(data).unwrap()) + }; + let (status, minted) = exchange(&jwt); + assert_eq!(status, 200, "{minted}"); + let token = minted["token"].as_str().expect("the exchange returns a credential"); + let (status, answer) = post(&addr, &query(), token, None); + assert_eq!(status, 200, "{answer}"); + assert_eq!(answer["result"]["structuredContent"]["rows"], json!([["n1"]])); + let (status, refused) = exchange("invalid"); + assert_ne!(status, 200); + assert_eq!(refused["error"]["identifier"], "ExchangeAssertionInvalid"); +} + /// The network transport refuses to start without its audience, its ceiling, or an issuer key that resolves and parses. // spec: topology.publish-hostname.issuer-key@a7736a7f #[test] diff --git a/crates/contextful-context/src/read/face.rs b/crates/contextful-context/src/read/face.rs index 01ae8cf70..ced9d44cd 100644 --- a/crates/contextful-context/src/read/face.rs +++ b/crates/contextful-context/src/read/face.rs @@ -602,6 +602,7 @@ impl Face { .map(|r| { json!({ "table": r.name(), + "kind": if self.memory.table(r.name()).is_some() { "memory" } else { "data" }, "description": self.decl(r.name()).agent_description, "zone_admitted": session.zone_admitted(r.name()), }) diff --git a/crates/contextful-context/tests/integration/read/respond.rs b/crates/contextful-context/tests/integration/read/respond.rs index 9dba3ac82..cf117ea0c 100644 --- a/crates/contextful-context/tests/integration/read/respond.rs +++ b/crates/contextful-context/tests/integration/read/respond.rs @@ -261,6 +261,19 @@ fn describe_reports_the_session_zone_and_each_tables_admission() { assert_eq!((vendor["session_zone"].clone(), vendor["zone_admitted"].clone()), (json!("public-cloud:us-east-1"), json!(true))); } +#[test] +fn describe_marks_declared_memory_tables_regardless_of_name() { + let manifest = format!("{MANIFEST}\n[[table]]\nname = \"research/insights\"\nshape = \"memory_facts\"\ncolumns = [\"claim_id\", \"subject\", \"predicate\", \"object\", \"scope\", \"tier\", \"confidence\", \"valid_from\", \"valid_to\", \"evidence\", \"superseded_by\", \"grant_id\", \"agent\"]\n"); + let r = Reads::with_manifest(&manifest); + let s = r.session(&["research/*"], None, None); + let listing = r.face.describe(&s, None, Bounds::default()).unwrap(); + let tables = listing["tables"].as_array().unwrap(); + let insights = tables.iter().find(|item| item["table"] == "research/insights").unwrap(); + assert_eq!(insights["kind"], json!("memory")); + let notes = tables.iter().find(|item| item["table"] == "research/notes").unwrap(); + assert_eq!(notes["kind"], json!("data")); +} + /// An arm whose table the session's zone excludes contributes no candidate, and the ranked response names that table in {{read.respond.restriction-block}}. // spec: read.retrieve.excluded-arm@12f1c8ad #[test] diff --git a/spec/status.md b/spec/status.md index 491e45f3d..2d65ed677 100644 --- a/spec/status.md +++ b/spec/status.md @@ -13,9 +13,9 @@ An unpinned clause is `committed`; a pinned one is `performed` when its Rust, Le | `read` | 2 | 15 | 200 | 31 | 22 | 9 | 178 | 0 | 178 | | `run` | 3 | 26 | 406 | 102 | 39 | 28 | 306 | 0 | 306 | | `store` | 1 | 17 | 252 | 59 | 13 | 19 | 216 | 0 | 216 | -| `surface` | 2 | 20 | 114 | 52 | 22 | 0 | 62 | 0 | 62 | +| `surface` | 2 | 20 | 114 | 52 | 22 | 0 | 68 | 0 | 62 | | `topology` | 1 | 6 | 70 | 24 | 5 | 5 | 38 | 0 | 38 | -| **total** | 19 | 155 | 2120 | 596 | 191 | 103 | 1484 | 0 | | +| **total** | 19 | 155 | 2120 | 596 | 191 | 103 | 1490 | 0 | | Decision records: 18. @@ -37,7 +37,7 @@ A milestone reads `closed` when its acceptance test computes `passing` and every | 9 — Visibility | 6 | 41 | 3 | open | open | | 10 — Cadence and the operator plane | 11 | 76 | 64 | passing | open | | 11 — The derive tier | 7 | 88 | 79 | passing | closed | -| 12 — The operator console | 12 | 49 | 3 | open | open | +| 12 — The operator console | 12 | 49 | 9 | open | open | | 13 — Disclosure | 5 | 33 | 13 | open | open | | 14 — Assurance | 7 | 146 | 90 | passing | closed | @@ -1478,9 +1478,13 @@ Unscheduled operations: 10. | `surface.fire.store-driven-body` | `crates/contextful-core/tests/integration/job.rs::a_store_driven_block_naming_an_unregistered_body_raises_job_body_unregistered` | performed | | `surface.fire.store-driven-concurrency` | `crates/contextful-core/tests/integration/job.rs::a_store_driven_block_without_a_positive_max_in_flight_raises_job_concurrency_unset` | performed | | `surface.fire.target-unbound` | `crates/contextful-cli/tests/integration/job.rs::a_job_target_naming_nothing_produced_is_refused_at_validation` | performed | +| `surface.ground.direct-file-read` | `apps/console/test/turn.test.ts::apps/console/test/turn.test.ts::direct file table functions refuse before dispatch` | performed | +| `surface.ground.mint-refused` | `apps/console/test/live.test.ts::apps/console/test/live.test.ts::Query falls back to a configured shared credential only after exchange refusal` | performed | +| `surface.ground.org-face-read-only` | `apps/console/test/turn.test.ts::apps/console/test/turn.test.ts::organization packs refuse writes at startup` | performed | | `surface.package.owner-flag` | `crates/contextful-cli/tests/integration/mcp.rs::explicit_owner_requires_a_signed_credential_for_the_selected_store` | performed | | `surface.package.stdio-credential` | `crates/contextful-cli/tests/integration/mcp.rs::a_server_with_no_credential_raises_stdio_credential_missing` | performed | | `surface.package.store-selector` | `crates/contextful-cli/tests/integration/mcp.rs::a_spawned_server_without_a_project_manifest_refuses_before_framing` | performed | +| `surface.plan-turn.planner-reached-memory` | `apps/console/test/turn.test.ts::apps/console/test/turn.test.ts::a planner call targeting a memory relation dispatches nothing` | performed | | `surface.reconcile.fail-static` | `crates/contextful-cli/tests/integration/pipeline.rs::an_unreadable_snapshot_leaves_the_armed_set_running` | performed | | `surface.reconcile.issuer-pin` | `crates/contextful-cli/tests/integration/pipeline.rs::a_pulled_receipt_cannot_supply_its_own_trust_pin` | performed | | `surface.reconcile.learns-by-reading` | `crates/contextful-cli/tests/integration/pipeline.rs::a_wake_reads_the_applied_version_from_the_control_source` | performed | @@ -1493,6 +1497,8 @@ Unscheduled operations: 10. | `surface.reside.region-entries` | `crates/contextful-core/tests/integration/surface/reside.rs::an_allow_set_holds_sixteen_entries` | performed | | `surface.reside.region-mismatch` | `crates/contextful-cli/tests/integration/pipeline.rs::a_resource_outside_the_residency_allow_set_serves_nothing` | performed | | `surface.reside.site-regions` | `crates/contextful-cli/tests/integration/sync/mod.rs::two_sites_declaring_different_residency_diverge_at_push` | performed | +| `surface.set-vantage.sample-labels` | `apps/console/test/turn.test.ts::apps/console/test/turn.test.ts::a table contributes at most three sampled arrival labels` | performed | +| `surface.speak.redactor-lookahead` | `apps/console/test/turn.test.ts::apps/console/test/turn.test.ts::streaming redactor masks identifiers split across chunks` | performed | | `topology.compose.script-runtime` | `tools/ci/tests/integration/topology.rs::a_crate_linking_a_javascript_runtime_is_refused` | performed | | `topology.compose.undeclared-crossing` | `tools/ci/tests/integration/topology.rs::a_run_path_crate_reaching_a_read_path_crate_is_refused` | performed | | `topology.compose.vendor-sdk` | `tools/ci/tests/integration/topology.rs::a_crate_declaring_a_model_vendor_sdk_is_refused` | performed |