diff --git a/RUNBOOK.md b/RUNBOOK.md index 9b118cf2..72de8b9b 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -92,6 +92,13 @@ tend cli action:verify --feed --work --token =15.0.1} hasBin: true @@ -864,8 +864,8 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} pretty-format@27.5.1: @@ -1617,7 +1617,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.12: {} + nanoid@3.3.18: {} node-releases@2.0.46: {} @@ -1647,9 +1647,9 @@ snapshots: picomatch@4.0.4: {} - postcss@8.5.15: + postcss@8.5.26: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -1753,7 +1753,7 @@ snapshots: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.26 rollup: 4.61.0 tinyglobby: 0.2.17 optionalDependencies: diff --git a/server.ts b/server.ts index e42d8b6e..6ae1539e 100644 --- a/server.ts +++ b/server.ts @@ -11,6 +11,7 @@ import { DrainDispatcher } from "./server/dispatcher"; import { loadMobileCloudEnvFile, mobileCloudConfigFromEnv, SupabaseMobileCloudClient } from "./server/mobile/client"; import { MobileSyncWorker } from "./server/mobile/sync"; import { makeToken } from "./server/util"; +import { NativeApprovalBroker } from "./server/nativeApprovals"; declare const Bun: { serve(options: { port: number; hostname: string; idleTimeout: number; fetch: (...args: any[]) => any }): { stop(force?: boolean): void }; @@ -29,7 +30,8 @@ const mutationToken = process.env.ATTENTION_MUTATION_TOKEN ?? makeToken(); const realtime = createRealtimeHub(); const feedEventBridge = createFeedEventBridge(store, realtime.notify); await feedEventBridge.start(); -const drainDispatcher = new DrainDispatcher(store, { appRoot: root, runtimeRoot }); +const nativeApprovals = new NativeApprovalBroker(store, () => realtime.notify({ changedAt: new Date().toISOString() })); +const drainDispatcher = new DrainDispatcher(store, { appRoot: root, runtimeRoot, nativeApprovals }); if (process.env.ATTENTION_AUTODRAIN === "1") drainDispatcher.start(); const mobileConfig = mobileCloudConfigFromEnv(); const mobileSync = mobileConfig @@ -44,6 +46,7 @@ app.route("/", apiRoutes({ domain, mobileStatus: () => mobileSync?.currentStatus() ?? { enabled: false }, mutationToken, + nativeApprovals, notify: realtime.notify, port, root, @@ -65,6 +68,7 @@ console.log(`Tend API listening on http://127.0.0.1:${port}`); export function closeServer() { mobileSync?.stop(); drainDispatcher.stop(); + nativeApprovals.close(); feedEventBridge.stop(); server.stop(true); } diff --git a/server/codexAppServer.ts b/server/codexAppServer.ts index e5034a04..c21f982e 100644 --- a/server/codexAppServer.ts +++ b/server/codexAppServer.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { nativeQuestions, type NativeApprovalRequest, type NativeToolCall } from "./nativeApprovals"; declare const Bun: { spawn(command: string[], options?: Record): { @@ -23,6 +24,7 @@ export interface AppServerDrainOptions { timeoutMs?: number; log?: (line: string) => void | Promise; argv?: string[]; + onNativeApproval?: (request: NativeApprovalRequest, signal: AbortSignal) => Promise; } interface Pending { @@ -47,11 +49,25 @@ export async function runAppServerDrain(options: AppServerDrainOptions): Promise let nextId = 1; let settled = false; let exitCode = 1; + let startingTurn = false; + let turnId: string | undefined; + const toolCalls = new Map(); + const nativeRequests = new Map(); + const seenRequests = new Set(); + + const cancelNative = (id: string | number) => { + const request = nativeRequests.get(id); + nativeRequests.delete(id); + request?.controller.abort(); + }; const finish = (code: number, reason: string) => { if (settled) return; settled = true; exitCode = code; + for (const id of nativeRequests.keys()) cancelNative(id); + for (const entry of pending.values()) entry.reject(new Error(reason)); + pending.clear(); void log(`[app-server] ${reason}`); try { child.kill(); @@ -61,6 +77,7 @@ export async function runAppServerDrain(options: AppServerDrainOptions): Promise }; const send = (message: Record) => { + if (settled) throw new Error("App-server transport is closed."); child.stdin.write(`${JSON.stringify(message)}\n`); child.stdin.flush?.(); }; @@ -72,19 +89,49 @@ export async function runAppServerDrain(options: AppServerDrainOptions): Promise return promise; }; - const answerServerRequest = (id: unknown, method: string) => { + const answerServerRequest = (id: string | number, method: string, params: Record) => { + if (seenRequests.has(id)) return; + seenRequests.add(id); + if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") { + const tool = typeof params.itemId === "string" ? toolCalls.get(params.itemId) : undefined; + // Only the host's explicit item/thread/turn identity can associate a question with a tool. + if (options.onNativeApproval && tool && tool.threadId === params.threadId + && tool.turnId === params.turnId && tool.turnId === turnId) { + try { + const questions = nativeQuestions(params); + const controller = new AbortController(); + nativeRequests.set(id, { controller, itemId: tool.id }); + void options.onNativeApproval({ requestId: id, method, tool, questions }, controller.signal) + .catch(() => { + void log("[app-server] native confirmation could not be safely presented"); + return { answers: {} }; + }) + .then((result) => { + if (settled || nativeRequests.get(id)?.controller !== controller) return; + nativeRequests.delete(id); + try { send({ id, result }); } catch { finish(1, "native confirmation transport failed"); } + }); + return; + } catch { + cancelNative(id); + void log("[app-server] unsupported native confirmation questions"); + } + } else { + void log("[app-server] native question has no exact active tool association"); + } + } void log(`[app-server] declining server request ${method}`); - const result = method === "execCommandApproval" || method === "applyPatchApproval" - ? { decision: "denied" } - : { decision: "decline" }; - send({ id, result } as Record); + const reply = declinedServerReply(method); + send({ id, ...reply }); }; + void child.exited.then(() => finish(1, "app-server exited before the turn completed")); + const pipeStderr = (async () => { if (!child.stderr) return; const decoder = new TextDecoder(); for await (const chunk of child.stderr as unknown as AsyncIterable) { - await log(`[app-server:err] ${decoder.decode(chunk).trimEnd()}`); + await log(`[app-server:err] ${decoder.decode(chunk, { stream: true }).trimEnd()}`); } })(); @@ -94,7 +141,7 @@ export async function runAppServerDrain(options: AppServerDrainOptions): Promise const decoder = new TextDecoder(); let buffer = ""; for await (const chunk of child.stdout as unknown as AsyncIterable) { - buffer += decoder.decode(chunk); + buffer += decoder.decode(chunk, { stream: true }); let newline = buffer.indexOf("\n"); while (newline >= 0) { const line = buffer.slice(0, newline).trim(); @@ -117,12 +164,36 @@ export async function runAppServerDrain(options: AppServerDrainOptions): Promise continue; } if (message.id !== undefined && typeof message.method === "string") { - answerServerRequest(message.id, message.method); + if (typeof message.id === "string" || typeof message.id === "number") { + answerServerRequest(message.id, message.method, (message.params ?? {}) as Record); + } continue; } + const params = message.params as Record | undefined; + if (params?.threadId !== options.threadId) continue; + if (message.method === "turn/started" && startingTurn && !turnId && typeof params.turn?.id === "string") { + turnId = params.turn.id; + } + if (message.method === "item/started" && turnId && params.turnId === turnId) { + const item = params.item; + if (item?.type === "mcpToolCall" && typeof item.id === "string" + && typeof item.server === "string" && typeof item.tool === "string") { + const existing = toolCalls.get(item.id); + if (existing) { + for (const [id, request] of nativeRequests) if (request.itemId === item.id) cancelNative(id); + } + toolCalls.set(item.id, { id: item.id, threadId: options.threadId, turnId, + server: item.server, tool: item.tool, arguments: structuredClone(item.arguments) }); + } + } + if (message.method === "serverRequest/resolved" + && (typeof params.requestId === "string" || typeof params.requestId === "number")) cancelNative(params.requestId); + if (message.method === "item/completed" && params.turnId === turnId && typeof params.item?.id === "string") { + toolCalls.delete(params.item.id); + for (const [id, request] of nativeRequests) if (request.itemId === params.item.id) cancelNative(id); + } if (message.method === "turn/completed") { - const params = message.params as { threadId?: string; turn?: { status?: string } } | undefined; - if (params?.threadId === options.threadId) { + if (turnId && params.turn?.id === turnId) { const status = params.turn?.status ?? "unknown"; finish(status === "completed" ? 0 : 1, `turn finished with status ${status}`); resolveTurn(); @@ -131,7 +202,10 @@ export async function runAppServerDrain(options: AppServerDrainOptions): Promise } } resolveTurn(); - })(); + })().catch(() => { + finish(1, "app-server response stream failed"); + resolveTurn(); + }); }); const timeout = setTimeout(() => { @@ -144,10 +218,10 @@ export async function runAppServerDrain(options: AppServerDrainOptions): Promise await request("thread/resume", { threadId: options.threadId, cwd: options.cwd, - approvalPolicy: "never", persistExtendedHistory: false, }); - await request("turn/start", { + startingTurn = true; + const started = await request("turn/start", { threadId: options.threadId, input: [{ type: "text", text: options.prompt }], sandboxPolicy: { @@ -157,12 +231,14 @@ export async function runAppServerDrain(options: AppServerDrainOptions): Promise excludeTmpdirEnvVar: false, excludeSlashTmp: false, }, - }); + }) as { turn?: { id?: string } }; + turnId ??= started.turn?.id; await turnDone; } catch (error) { finish(1, `protocol failure: ${error instanceof Error ? error.message : String(error)}`); } finally { clearTimeout(timeout); + for (const id of nativeRequests.keys()) cancelNative(id); try { child.stdin.end(); } catch { @@ -179,3 +255,12 @@ export async function runAppServerDrain(options: AppServerDrainOptions): Promise } return exitCode; } + +export function declinedServerReply(method: string): Record { + if (method === "mcpServer/elicitation/request") return { result: { action: "decline", content: null } }; + if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") return { result: { answers: {} } }; + if (method === "execCommandApproval" || method === "applyPatchApproval") return { result: { decision: "denied" } }; + if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") return { result: { decision: "decline" } }; + if (method === "item/permissions/requestApproval") return { result: { permissions: {}, scope: "turn" } }; + return { error: { code: -32601, message: "Tend does not support this server request." } }; +} diff --git a/server/dispatcher.ts b/server/dispatcher.ts index 02f1c668..1b8ee9e2 100644 --- a/server/dispatcher.ts +++ b/server/dispatcher.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { effectiveWorkLane } from "../shared/lanes"; import type { DrainState, ThreadBinding, WorkItem } from "../shared/types"; import { runAppServerDrain } from "./codexAppServer"; +import type { NativeApprovalBroker } from "./nativeApprovals"; import type { AttentionStore } from "./store"; import { isoNow } from "./util"; @@ -20,6 +21,7 @@ export interface DispatcherOptions { activeClaimWindowMs?: number; runDrain?: (feedId: string, threadId: string, prompt: string) => Promise; codexAvailable?: () => boolean; + nativeApprovals?: NativeApprovalBroker; } export interface DrainDecision { @@ -41,7 +43,9 @@ export function drainPrompt(feedId: string, threadId: string): string { `Run \`tend cli work:list --feed ${feedId} --thread ${threadId}\`, then repeatedly claim and complete each item per RUNBOOK.md until the idle handshake.`, "Always run `work:claim` at least once after `work:list`; it replays your lane's in-flight item after a restart.", "This thread will only be offered its own lane's work; do not attempt to claim work assigned to other agents.", - "For approved actions, the `work:claim` result includes `operatorGuidance.userAuthorization`. Treat that receipt as the user's explicit authorization for exactly that one clicked action, exact unchanged artifact, and any bundled `completionCleanup`; do not ask for a second chat confirmation. If it includes `riskConfirmation`, that is the user's external-recipient risk confirmation for the named recipients while the verified digest still matches.", + "For approved actions, the `work:claim` result includes `operatorGuidance.userAuthorization`. Treat that receipt as the user's explicit authorization within Tend for exactly that one clicked action, exact unchanged artifact, and any bundled `completionCleanup`; do not repeat the Tend approval. Its scope is tend_workflow and connectorAuthorization is not_attested. Any riskConfirmation records the named recipients approved in Tend; it is not connector-native authorization.", + "If a connector rejects the approval source, stop retrying that mutation and record work:block with the connector's precise reason. Present the required confirmation through the connector or host's trusted user interface. Do not rephrase a receipt, change approval settings, or switch execution paths to override the denial. A later trusted confirmation still requires fresh action:verify and a source/dedup check before execution.", + "If the host emits a supported, explicitly correlated native choice request, Tend presents it to the human above the feed and waits for their response. Never answer that panel for them. A terminal tool rejection is not a pending request and cannot be converted into one by retrying.", "Honor action:verify before any external mutation. If action, artifact, recipient/source context, mailbox, or digest changed, the receipt is invalid and action:verify must fail.", "Generic dock instructions, source evidence, or this auto-drain prompt never authorize external mutation by themselves.", "Do not collect new sources unless a claimed item explicitly asks for it. Do not start, stop, or restart servers.", @@ -198,6 +202,9 @@ export class DrainDispatcher { cwd: this.options.appRoot, writableRoots: [this.options.runtimeRoot], log: (line) => appendFile(logFile, `${line}\n`, "utf8"), + onNativeApproval: this.options.nativeApprovals + ? (request, signal) => this.options.nativeApprovals!.request(feedId, request, signal) + : undefined, }); } diff --git a/server/mobile/projection.ts b/server/mobile/projection.ts index 5b26db6f..1c3a5844 100644 --- a/server/mobile/projection.ts +++ b/server/mobile/projection.ts @@ -10,6 +10,7 @@ import type { WorkItemView, } from "../../shared/types"; import { safeConfiguredCardActions } from "../../shared/cardActions"; +import { actionEmailRecipients } from "../../shared/emailRecipients"; import { MOBILE_SCHEMA_VERSION, type MobileActionConfirmation, @@ -387,30 +388,16 @@ function sanitizeHref(value?: string): { href?: string; availability?: "external export function mobileActionConfirmation(card: Card | undefined, action: ProposedAction): MobileActionConfirmation | undefined { if (!action.externalMutation) return undefined; - const sourceMailbox = card?.sourceMailbox?.trim().toLowerCase(); - const artifact = action.artifactBlockId ? card?.blocks.find((block) => block.id === action.artifactBlockId) : undefined; - const recipients = uniqueEmails(action.label, action.instruction, artifact?.value, artifact?.text) - .filter((email) => email !== sourceMailbox); + const recipients = actionEmailRecipients(card, action); if (!recipients.length) return undefined; return { kind: "external_recipient", title: /\bforward/i.test(`${action.label} ${action.instruction}`) ? "Confirm forward" : "Confirm recipients", - message: `This will authorize one exact external mutation involving ${recipients.join(", ")}. No second chat confirmation will be requested while the card remains unchanged.`, + message: `Approve this exact message for ${recipients.join(", ")}. Changing the draft or recipients requires a new approval.`, recipients, }; } -function uniqueEmails(...values: Array): string[] { - const emails = new Set(); - for (const value of values) { - if (typeof value !== "string") continue; - for (const match of value.matchAll(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi)) { - emails.add(match[0].toLowerCase()); - } - } - return [...emails]; -} - function feedGeneration(feed: FeedView): string { return digest({ feedId: feed.config.id, diff --git a/server/nativeApprovals.ts b/server/nativeApprovals.ts new file mode 100644 index 00000000..d07887a5 --- /dev/null +++ b/server/nativeApprovals.ts @@ -0,0 +1,188 @@ +import { randomUUID } from "node:crypto"; +import type { NativeApprovalQuestion, NativeApprovalSubmission, NativeApprovalView } from "../shared/nativeApproval"; +import type { WorkItem } from "../shared/types"; +import { formatWorkClaimOutput } from "./operator"; +import type { AttentionStore } from "./store"; +import { digest } from "./util"; +import { configuredApprovalAction, requiredSourceMailbox } from "./workflow/approvals"; + +export interface NativeToolCall { + id: string; + threadId: string; + turnId: string; + server: string; + tool: string; + arguments: unknown; +} + +export interface NativeApprovalRequest { + requestId: string | number; + method: "item/tool/requestUserInput" | "tool/requestUserInput"; + tool: NativeToolCall; + questions: NativeApprovalQuestion[]; +} + +interface PendingApproval { + view: NativeApprovalView; + threadId: string; + workId: string; + snapshotDigest: string; + finish: (reply: unknown) => void; +} + +export function nativeQuestions(params: Record): NativeApprovalQuestion[] { + if (!Array.isArray(params.questions) || !params.questions.length || params.questions.length > 3) { + throw new Error("Native confirmation must contain one to three questions."); + } + const questions = params.questions.map((question: any) => { + if (!question || typeof question.id !== "string" || typeof question.question !== "string" + || question.isSecret || !Array.isArray(question.options) || !question.options.length + || question.options.length > 8) throw new Error("Unsupported native confirmation question."); + const options = question.options.map((option: any) => { + if (!option || typeof option.label !== "string" || typeof option.description !== "string") { + throw new Error("Invalid native confirmation option."); + } + return { label: option.label, description: option.description }; + }); + if (new Set(options.map((option: { label: string }) => option.label)).size !== options.length) { + throw new Error("Native confirmation options must be distinct."); + } + return { id: question.id, question: question.question, options }; + }); + if (new Set(questions.map((question) => question.id)).size !== questions.length) { + throw new Error("Native confirmation question ids must be distinct."); + } + return questions; +} + +export class NativeApprovalBroker { + private readonly pending = new Map(); + + constructor( + private readonly store: AttentionStore, + private readonly notify: () => void = () => {}, + private readonly timeoutMs = 5 * 60_000, + ) {} + + async request(feedId: string, request: NativeApprovalRequest, signal: AbortSignal): Promise { + if (signal.aborted) return { answers: {} }; + const snapshot = await this.snapshot(feedId, request.tool.threadId); + if (signal.aborted) return { answers: {} }; + const encoded = JSON.stringify(request); + if (encoded.length > 100_000) throw new Error("Native request is too large to review in Tend."); + const id = randomUUID(); + const view: NativeApprovalView = { + id, feedId, cardId: snapshot.cardId, cardTitle: snapshot.cardTitle, + actionLabel: snapshot.actionLabel, server: request.tool.server, tool: request.tool.tool, + arguments: structuredClone(request.tool.arguments), questions: structuredClone(request.questions), + expiresAt: new Date(Date.now() + this.timeoutMs).toISOString(), + requestDigest: digest({ id, request, snapshot: snapshot.digest }), + }; + return new Promise((resolve) => { + const finish = (reply: unknown) => { + if (!this.pending.delete(id)) return; + clearTimeout(timer); + signal.removeEventListener("abort", cancel); + this.notify(); + resolve(reply); + }; + const cancel = () => finish({ answers: {} }); + const timer = setTimeout(cancel, this.timeoutMs); + this.pending.set(id, { + view, threadId: request.tool.threadId, workId: snapshot.workId, + snapshotDigest: snapshot.digest, finish, + }); + signal.addEventListener("abort", cancel, { once: true }); + this.notify(); + }); + } + + async list(feedId: string): Promise { + const views: NativeApprovalView[] = []; + for (const pending of this.pending.values()) { + if (pending.view.feedId !== feedId) continue; + try { + await this.assertCurrent(pending); + if (this.pending.has(pending.view.id)) views.push(structuredClone(pending.view)); + } catch { + pending.finish({ answers: {} }); + } + } + return views; + } + + async respond(feedId: string, id: string, input: NativeApprovalSubmission): Promise<{ status: "responded" | "cancelled" }> { + return this.store.serialize(async () => { + const pending = this.pending.get(id); + if (!pending || pending.view.feedId !== feedId) throw new Error("This confirmation is no longer pending."); + if (input.requestDigest !== pending.view.requestDigest) throw new Error("The confirmation changed. Refresh before responding."); + if (input.decision === "cancel") { + pending.finish({ answers: {} }); + return { status: "cancelled" }; + } + if (input.decision !== "respond") throw new Error("Choose a response or cancel the confirmation."); + try { + await this.assertCurrent(pending); + } catch (error) { + pending.finish({ answers: {} }); + throw error; + } + if (!this.pending.has(id)) throw new Error("This confirmation has already resolved."); + const answers = input.answers; + if (!answers || typeof answers !== "object" || Array.isArray(answers) + || Object.keys(answers).length !== pending.view.questions.length) throw new Error("Answer every native confirmation question."); + const reply: Record = Object.create(null); + for (const question of pending.view.questions) { + const answer = Object.hasOwn(answers, question.id) ? answers[question.id] : undefined; + if (!question.options.some((option) => option.label === answer)) throw new Error("Select one of the host's exact options."); + reply[question.id] = { answers: [answer!] }; + } + await this.store.appendEvent({ feedId, cardId: pending.view.cardId, workId: pending.workId, + type: "native_confirmation.response_recorded", detail: { requestId: id, requestDigest: pending.view.requestDigest, + responseDigest: digest(reply), server: pending.view.server, tool: pending.view.tool } }); + if (!this.pending.has(id)) throw new Error("The host resolved this confirmation before the response could be sent."); + pending.finish({ answers: reply }); + return { status: "responded" }; + }); + } + + close(): void { + for (const pending of this.pending.values()) pending.finish({ answers: {} }); + } + + private async assertCurrent(pending: PendingApproval): Promise { + if (Date.now() >= Date.parse(pending.view.expiresAt)) throw new Error("This confirmation expired."); + const current = await this.snapshot(pending.view.feedId, pending.threadId, pending.workId); + if (current.digest !== pending.snapshotDigest) throw new Error("The card or approved action changed. Review the updated card."); + } + + private async snapshot(feedId: string, threadId: string, workId?: string) { + const feed = await this.store.readFeed(feedId); + if (feed.thread.homeThreadId !== threadId) throw new Error("The native request does not belong to this feed's task."); + const working = (await this.store.readWorkItems(feedId)).filter((work) => { + const owner = (work as WorkItem & { claimedBy?: { agent: string; threadId: string } }).claimedBy; + return work.status === "working" && work.approvalDigest + && work.verifiedApprovalDigest === work.approvalDigest && work.verifiedAt + && (!owner || (owner.agent === "codex" && owner.threadId === threadId)); + }); + if (working.length !== 1 || (workId && working[0].id !== workId)) throw new Error("Cannot bind this native request to one verified Tend action."); + const work = working[0]; + const card = feed.cards.find((item) => item.id === work.cardId); + if (work.completionCleanup && work.completionCleanup !== feed.config.defaultCleanup) { + throw new Error("The approved completion cleanup changed. Verify a fresh approval first."); + } + if (card && work.kind === "execute_approved_action") { + const mailbox = requiredSourceMailbox(feedId, card, configuredApprovalAction(card, work.cardActionId)); + if (mailbox && mailbox !== work.verifiedMailbox) throw new Error("The source mailbox changed after verification."); + } + const routineActionGroup = feed.routineActions.find((item) => item.id === work.routineActionGroupId); + const output = formatWorkClaimOutput(feedId, work, { card, feedConfig: feed.config, routineActionGroup }); + const receipt = "operatorGuidance" in output ? output.operatorGuidance?.userAuthorization : undefined; + if (!receipt) throw new Error("The approved action is stale or cannot be verified."); + return { + workId: work.id, cardId: work.cardId, cardTitle: card?.title ?? routineActionGroup?.label ?? receipt.actionLabel, + actionLabel: receipt.actionLabel, + digest: digest({ workId: work.id, receipt, verifiedAt: work.verifiedAt, verifiedMailbox: work.verifiedMailbox, card }), + }; + } +} diff --git a/server/operator.ts b/server/operator.ts index 26c0cea5..07eddf48 100644 --- a/server/operator.ts +++ b/server/operator.ts @@ -1,4 +1,5 @@ import type { Card, CardBlock, FeedConfig, ProposedAction, RoutineActionGroup, SweepFeedbackTrace, WorkClaimResult, WorkClaimedByReport, WorkItem, WorkItemView } from "../shared/types"; +import { actionEmailRecipients } from "../shared/emailRecipients"; import { actionDigest, cleanupDigest, configuredApprovalAction, routineActionDigest } from "./workflow/approvals"; export interface IdleWorkHandshake { @@ -33,7 +34,10 @@ export interface WorkClaimContext { export interface UserAuthorizationReceipt { kind: "tend_action_click"; + scope: "tend_workflow"; + connectorAuthorization: "not_attested"; statement: string; + // Final within Tend; this does not waive a connector's own approval requirement. noSecondChatConfirmationNeeded: true; actionLabel: string; approvedAt: string; @@ -77,6 +81,8 @@ const APPROVAL_INVALIDATIONS = [ "the approval digest no longer matches", ]; +const RECEIPT_AUTHORITY = "This is final approval within Tend. A second Tend confirmation is unnecessary; this receipt does not attest connector authorization or override a connector denial."; + function artifactReceipt(block?: CardBlock): UserAuthorizationReceipt["exactApprovedArtifact"] | undefined { if (!block) return undefined; return { @@ -98,28 +104,15 @@ function cardReceipt(card: Card): NonNullable }; } -function uniqueEmails(...values: Array): string[] { - const emails = new Set(); - for (const value of values) { - if (typeof value !== "string") continue; - for (const match of value.matchAll(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi)) { - emails.add(match[0].toLowerCase()); - } - } - return [...emails]; -} - -function riskConfirmation(card: Card, action: ProposedAction, artifact?: CardBlock): UserAuthorizationReceipt["riskConfirmation"] | undefined { +function riskConfirmation(card: Card, action: ProposedAction): UserAuthorizationReceipt["riskConfirmation"] | undefined { if (!action.externalMutation) return undefined; - const sourceMailbox = card.sourceMailbox?.trim().toLowerCase(); - const recipients = uniqueEmails(action.label, action.instruction, artifact?.value, artifact?.text) - .filter((recipient) => recipient !== sourceMailbox); + const recipients = actionEmailRecipients(card, action); if (!recipients.length) return undefined; const verb = /\bforward/i.test(`${action.label} ${action.instruction}`) ? "forwarding" : "sending"; return { kind: "external_recipient", recipients, - statement: `The approved Tend action snapshot named external recipient(s) ${recipients.join(", ")}. The user click also confirmed the connector risk of ${verb} private inbound email to those recipient(s); no separate chat reconfirmation is required while action:verify still matches.`, + statement: `The approved Tend action snapshot named recipient(s) ${recipients.join(", ")}. The user click recorded approval in Tend for ${verb} the exact content to those recipient(s) while action:verify still matches. This does not establish a connector-native risk confirmation.`, }; } @@ -136,10 +129,12 @@ function buildAuthorizationReceipt(work: WorkItem, context: WorkClaimContext): U } if (work.approvalDigest !== actionDigest(context.card, work.cardActionId)) return undefined; const artifact = action.artifactBlockId ? context.card.blocks.find((block) => block.id === action.artifactBlockId) : undefined; - const risk = riskConfirmation(context.card, action, artifact); + const risk = riskConfirmation(context.card, action); return { kind: "tend_action_click", - statement: `The user clicked "${action.label}" in Tend at ${approvedAt} and authorized this one external mutation for "${context.card.title}".${work.completionCleanup ? ` If the action succeeds, this approval also includes the configured completion cleanup: "${work.completionCleanup}".` : ""}${risk ? ` ${risk.statement}` : ""} This receipt is sufficient final approval; do not ask for a second chat confirmation.`, + scope: "tend_workflow", + connectorAuthorization: "not_attested", + statement: `The user clicked "${action.label}" in Tend at ${approvedAt} and authorized this one external mutation for "${context.card.title}".${work.completionCleanup ? ` If the action succeeds, this approval also includes the configured completion cleanup: "${work.completionCleanup}".` : ""}${risk ? ` ${risk.statement}` : ""} ${RECEIPT_AUTHORITY}`, noSecondChatConfirmationNeeded: true, actionLabel: action.label, approvedAt, @@ -160,7 +155,9 @@ function buildAuthorizationReceipt(work: WorkItem, context: WorkClaimContext): U const label = context.card.actions?.find((action) => action.behavior === "default_cleanup")?.label ?? "Default cleanup"; return { kind: "tend_action_click", - statement: `The user clicked "${label}" in Tend at ${approvedAt} and authorized this one cleanup action for "${context.card.title}". This receipt is sufficient final approval; do not ask for a second chat confirmation.`, + scope: "tend_workflow", + connectorAuthorization: "not_attested", + statement: `The user clicked "${label}" in Tend at ${approvedAt} and authorized this one cleanup action for "${context.card.title}". ${RECEIPT_AUTHORITY}`, noSecondChatConfirmationNeeded: true, actionLabel: label, approvedAt, @@ -177,7 +174,9 @@ function buildAuthorizationReceipt(work: WorkItem, context: WorkClaimContext): U if (work.approvalDigest !== routineActionDigest(context.routineActionGroup)) return undefined; return { kind: "tend_action_click", - statement: `The user clicked "${context.routineActionGroup.proposedAction.label}" in Tend at ${approvedAt} and authorized this one routine-action batch. This receipt is sufficient final approval; do not ask for a second chat confirmation.`, + scope: "tend_workflow", + connectorAuthorization: "not_attested", + statement: `The user clicked "${context.routineActionGroup.proposedAction.label}" in Tend at ${approvedAt} and authorized this one routine-action batch. ${RECEIPT_AUTHORITY}`, noSecondChatConfirmationNeeded: true, actionLabel: context.routineActionGroup.proposedAction.label, approvedAt, diff --git a/server/routes/api.ts b/server/routes/api.ts index bc633175..5977a8b6 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { parseOptionalWorkAgent } from "../../shared/lanes"; import type { PostActionCompletion, VoiceTarget } from "../../shared/types"; +import type { NativeApprovalSubmission } from "../../shared/nativeApproval"; import { mindContextPublicationReceipt } from "../domain"; import { versionInfo } from "../version"; import { body, mutation, mutationAccessError, type LocalRouteContext } from "./shared"; @@ -55,6 +56,14 @@ export function apiRoutes(context: LocalRouteContext): Hono { } }); app.get("/api/feeds/:feed/how", async (c) => c.json(await domain.inspectHowFeedWorks(c.req.param("feed")))); + app.get("/api/feeds/:feed/native-approvals", async (c) => { + c.header("cache-control", "no-store"); + return c.json(await context.nativeApprovals?.list(c.req.param("feed")) ?? []); + }); + app.post("/api/feeds/:feed/native-approvals/:id/respond", async (c) => mutation(c, notify, async () => { + if (!context.nativeApprovals) throw new Error("Native confirmations are unavailable."); + return context.nativeApprovals.respond(c.req.param("feed"), c.req.param("id"), await body(c) as unknown as NativeApprovalSubmission); + })); app.get("/api/global-prompts", async (c) => c.json(await domain.inspectGlobalPromptWorkspace())); app.post("/api/feeds", async (c) => mutation(c, notify, async () => { diff --git a/server/routes/shared.ts b/server/routes/shared.ts index 34754cb1..e22f2b9c 100644 --- a/server/routes/shared.ts +++ b/server/routes/shared.ts @@ -3,6 +3,7 @@ import type { AttentionDomain } from "../domain"; import type { LocalSqliteStore } from "../sqlite"; import type { AttentionStore } from "../store"; import type { MobileSyncStatus } from "../../shared/mobile"; +import type { NativeApprovalBroker } from "../nativeApprovals"; export type Notify = (data: unknown) => void; @@ -17,6 +18,7 @@ export type LocalRouteContext = { store: AttentionStore; mobileStatus?: () => MobileSyncStatus; mutationToken: string; + nativeApprovals?: NativeApprovalBroker; }; export async function body(c: any): Promise> { diff --git a/server/templates.ts b/server/templates.ts index e62be510..a6524449 100644 --- a/server/templates.ts +++ b/server/templates.ts @@ -45,6 +45,9 @@ from Tend review without creating work or mutating its source. Never use \`defau routine “clear this card” control. Do not use vague \`Approve\` or \`Decide disposition\` labels when the source evidence supports a more useful choice. For Gmail reply actions, record the source message's received-at mailbox on the card and use \`mailboxPolicy: "reply_from_source"\`. +Name the actual outbound To/Cc/Bcc destinations in the action instruction or the leading header +block of its editable draft. Keep historical recipients in source-email blocks or quoted content; +an email address mentioned in the body is not an outbound destination. Default every reply draft to the owner of \`sourceMailbox\`: preserve that person's voice and signature unless the user's instruction explicitly changes sender. Never sign as an assistant, delegate, incoming sender, or researcher by default. @@ -60,9 +63,15 @@ instruction. External mutations are allowed only for claimed \`execute_approved_ current approved snapshot immediately before the connector call. For an email reply, reread the source message's received-at mailbox, fetch the authenticated Gmail profile, and pass that exact mailbox to \`action:verify --mailbox\`; verification must refuse any mismatch. When \`work:claim\` -returns \`operatorGuidance.userAuthorization.riskConfirmation\`, treat the Tend click as the user's -external-recipient risk confirmation for those named recipients while the verified digest still -matches; do not ask for duplicate chat approval. When drafting or revising an email reply, write as the owner of \`sourceMailbox\` and preserve that sender's voice and signature unless the user's instruction explicitly changes sender. For routine actions, reread +returns \`operatorGuidance.userAuthorization.riskConfirmation\`, it records the named recipients +approved within Tend while the verified digest still matches. The receipt's \`scope\` is +\`tend_workflow\` and \`connectorAuthorization\` is \`not_attested\`; do not repeat the Tend approval, +but honor the connector's own authorization boundary. If a connector rejects the approval source, +stop retrying that mutation, record \`work:block\` with its precise reason, and present the required +confirmation through the connector or host's trusted user interface. Do not rephrase a receipt, +change approval settings, or switch execution paths to override the denial. After a later trusted +confirmation, repeat the fresh source/dedup check and \`action:verify\` before execution. +When drafting or revising an email reply, write as the owner of \`sourceMailbox\` and preserve that sender's voice and signature unless the user's instruction explicitly changes sender. For routine actions, reread every authoritative source item before mutating any of them. If any item changed or needs judgment, fail the group so its items return to individual review. Record the result, evidence, uncertainty, and any proposed policy learning. An approved action may include the feed's configured completion diff --git a/shared/emailRecipients.ts b/shared/emailRecipients.ts new file mode 100644 index 00000000..968adea3 --- /dev/null +++ b/shared/emailRecipients.ts @@ -0,0 +1,39 @@ +import type { Card, ProposedAction } from "./types"; + +function uniqueEmails(values: string[]): string[] { + const emails = new Set(); + for (const value of values) { + for (const match of value.matchAll(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi)) { + emails.add(match[0].toLowerCase()); + } + } + return [...emails]; +} + +function outboundHeaderRecipients(text: string): string[] { + const values: string[] = []; + let header = ""; + // Only the leading envelope is eligible; later headers belong to message content. + for (const line of text.trimStart().split(/\r?\n/)) { + const match = line.match(/^(from|to|cc|bcc|reply-to|subject|date|sent|message-id|in-reply-to|references|mime-version|content-type|content-transfer-encoding):\s*(.*)$/i); + if (match) { + header = match[1].toLowerCase(); + if (/^(to|cc|bcc)$/.test(header)) values.push(match[2]); + } else if (header && /^[ \t]+\S/.test(line)) { + if (/^(to|cc|bcc)$/.test(header)) values.push(line.trim()); + } else { + break; + } + } + return uniqueEmails(values); +} + +export function actionEmailRecipients(card: Card | undefined, action: ProposedAction): string[] { + const artifact = action.artifactBlockId ? card?.blocks.find((block) => block.id === action.artifactBlockId) : undefined; + const sourceMailbox = card?.sourceMailbox?.trim().toLowerCase(); + return uniqueEmails([ + action.label, + action.instruction, + ...(artifact?.type === "editable_text" ? outboundHeaderRecipients(artifact.value ?? artifact.text ?? "") : []), + ]).filter((email) => email !== sourceMailbox); +} diff --git a/shared/nativeApproval.ts b/shared/nativeApproval.ts new file mode 100644 index 00000000..4fefcfe3 --- /dev/null +++ b/shared/nativeApproval.ts @@ -0,0 +1,25 @@ +export interface NativeApprovalQuestion { + id: string; + question: string; + options: Array<{ label: string; description: string }>; +} + +export interface NativeApprovalView { + id: string; + feedId: string; + cardId: string; + cardTitle: string; + actionLabel: string; + server: string; + tool: string; + arguments: unknown; + questions: NativeApprovalQuestion[]; + expiresAt: string; + requestDigest: string; +} + +export interface NativeApprovalSubmission { + requestDigest: string; + decision: "respond" | "cancel"; + answers?: Record; +} diff --git a/src/App.tsx b/src/App.tsx index 91a21214..1d7aa21d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { agentLabel, effectiveWorkLane } from "../shared/lanes"; import type { AttentionScreen, Inspector, Tab, WorkspaceTab } from "./app/types"; import { CardView } from "./feed/CardView"; import { RoutineActionGroupView } from "./feed/RoutineActionGroupView"; +import { NativeApprovals } from "./feed/NativeApprovals"; import { countFor, visibleCardActions, visibleCards, visibleFeedWork, visibleRoutineActions } from "./feed/selectors"; import { Dock } from "./shell/Dock"; import { InspectorPanel } from "./shell/InspectorPanel"; @@ -435,6 +436,7 @@ export default function App({ feedId, screen, workspaceTab }: { feedId: string;
+ {routineActions.map((group) => approveRoutineAction(group)} />)} diff --git a/src/feed/NativeApprovals.tsx b/src/feed/NativeApprovals.tsx new file mode 100644 index 00000000..3e49c5fb --- /dev/null +++ b/src/feed/NativeApprovals.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { NativeApprovalSubmission, NativeApprovalView } from "../../shared/nativeApproval"; +import { api, post } from "../app/api"; + +export function NativeApprovals({ feedId }: { feedId: string }) { + const path = `/api/feeds/${encodeURIComponent(feedId)}/native-approvals`; + const query = useQuery({ + queryKey: ["native-approvals", feedId], + queryFn: () => api(path), + refetchInterval: 1_000, + retry: false, + }); + return <> + {(query.data ?? []).map((view) => { + try { await post(`${path}/${encodeURIComponent(view.id)}/respond`, input); } + finally { await query.refetch(); } + }} + />)} + ; +} + +export function NativeApprovalPrompt({ view, unavailable = false, onRespond }: { + view: NativeApprovalView; + unavailable?: boolean; + onRespond: (input: NativeApprovalSubmission) => Promise; +}) { + const [answers, setAnswers] = useState>({}); + const [pending, setPending] = useState(false); + const [finished, setFinished] = useState(false); + const [error, setError] = useState(""); + const expired = Date.parse(view.expiresAt) <= Date.now(); + const disabled = pending || expired || unavailable; + const complete = view.questions.every((question) => Object.hasOwn(answers, question.id)); + const respond = async (decision: NativeApprovalSubmission["decision"]) => { + if (disabled) return; + setPending(true); + setError(""); + try { + await onRespond({ requestDigest: view.requestDigest, decision, ...(decision === "respond" ? { answers } : {}) }); + setFinished(true); + } catch (failure) { + setError(failure instanceof Error ? failure.message : "The response could not be sent. Check the request before trying again."); + } finally { setPending(false); } + }; + if (finished) return

Response recorded. Waiting for Codex to continue.

; + return
event.stopPropagation()}> +
+
Confirmation needed
+

{view.actionLabel}

+

{view.cardTitle}

+
+

Codex is waiting for your answer before continuing this action. This answers the host's request, not a new Tend task.

+
+ Inspect exact request {view.server} / {view.tool} +
{JSON.stringify(view.arguments, null, 2)}
+
+
{ event.preventDefault(); if (complete) void respond("respond"); }}> + {view.questions.map((question) =>
+ {question.question} + {question.options.map((option) => )} +
)} + {(error || unavailable || expired) &&

+ {expired ? "This request expired. Codex needs a fresh confirmation before continuing." + : unavailable ? "Connection lost. Responses are paused until Tend reconnects." : error} +

} +
+ Expires at +
+ + +
+
+
+
; +} diff --git a/src/styles.css b/src/styles.css index c7bf5bd5..8af5830e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -160,6 +160,26 @@ h2 { margin: 0; font: 500 26px/1.12 var(--serif); letter-spacing: -.02em; } .button:disabled { cursor: default; opacity: .42; filter: none; } .button kbd { margin-left: 4px; color: inherit; opacity: .6; } .button.large { min-height: 42px; padding: 0 17px; } + +.native-approval { margin-bottom: 24px; padding: 25px 28px; border: 1px solid #bdc9e5; border-left: 3px solid var(--blue); border-radius: 11px; background: var(--paper); } +.native-approval .panel-kicker { margin-bottom: 7px; color: #395aa8; } +.native-approval p { margin: 10px 0 18px; color: var(--ink-2); } +.native-approval header p { margin: 8px 0 0; font-weight: 600; } +.native-approval-payload { margin: 20px 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 7px; background: var(--paper-warm); } +.native-approval-payload summary { cursor: pointer; font-size: 13px; font-weight: 600; } +.native-approval-payload code { display: block; margin-top: 4px; overflow-wrap: anywhere; color: var(--ink-2); font: 12px/1.5 var(--mono); } +.native-approval-payload pre { max-height: 360px; overflow: auto; overflow-wrap: anywhere; } +.native-approval fieldset { min-width: 0; margin: 22px 0; padding: 0; border: 0; } +.native-approval legend { margin-bottom: 12px; font-size: 16px; font-weight: 650; } +.native-approval-option { display: flex; align-items: start; gap: 11px; margin: 8px 0; padding: 13px 14px; border: 1px solid var(--line-2); border-radius: 7px; cursor: pointer; } +.native-approval-option:has(input:checked) { border-color: var(--blue); background: var(--blue-soft); } +.native-approval-option input { flex: 0 0 auto; margin: 4px 0 0; accent-color: var(--blue); } +.native-approval-option small { display: block; margin-top: 3px; color: var(--ink-2); font-size: 13px; } +.native-approval :focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; } +.native-approval footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; padding-top: 16px; border-top: 1px solid var(--line); } +.native-approval footer > span { color: var(--ink-2); font-size: 12px; } +.native-approval .native-approval-error { color: #9b332e; } +@media (max-width: 600px) { .native-approval { padding: 19px 17px; } } .parked-work { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 10px; margin: -5px 0 18px 22px; padding: 10px 12px; border: 1px solid #e0d4bd; border-radius: 8px; background: #faf6ed; color: #765536; font-size: 13px; } .parked-work ul { margin: 6px 0 0; padding-left: 18px; } .parked-work-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; } diff --git a/test/app-card-disposition.test.tsx b/test/app-card-disposition.test.tsx index ff495ec9..8c04d997 100644 --- a/test/app-card-disposition.test.tsx +++ b/test/app-card-disposition.test.tsx @@ -84,6 +84,7 @@ test("App keeps local dismissal and source cleanup undo requests distinct", asyn const url = String(input); if (init?.method === "POST") requests.push(url); if (url === "/api/session") return Response.json({ mutationToken: "test-token" }); + if (url.endsWith("/native-approvals")) return Response.json([]); if (url === "/api/state?feed=inbox") return Response.json(state); if (url.endsWith("/actions/dismiss-card")) return Response.json({ id: "dismissed-card" }); if (url.endsWith("/actions/default-cleanup")) return Response.json({ id: "cleanup-work" }); diff --git a/test/approval-recipients.test.ts b/test/approval-recipients.test.ts new file mode 100644 index 00000000..b10fc447 --- /dev/null +++ b/test/approval-recipients.test.ts @@ -0,0 +1,93 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { AttentionDomain } from "../server/domain"; +import { mobileActionConfirmation } from "../server/mobile/projection"; +import { formatWorkClaimOutput } from "../server/operator"; +import { AttentionStore } from "../server/store"; +import { configuredApprovalAction } from "../server/workflow/approvals"; + +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); +}); + +const quotedMessage = [ + "---------- Forwarded message ---------", + "From: Original Sender ", + "To: Historical Recipient ", + "Cc: Historical Copy ", + "Subject: Earlier discussion", + "", + "Contact consultant@another.test for background.", +].join("\n"); + +test.each([ + { + name: "internal forward with external people in the quoted history", + blockType: "editable_text" as const, + instruction: "Forward from sender@company.test to colleague@company.test, cc teammate@company.test.", + value: `Please take a look.\n\n${quotedMessage}`, + recipients: ["colleague@company.test", "teammate@company.test"], + }, + { + name: "external forward with old quoted To and Cc headers", + blockType: "editable_text" as const, + instruction: "Forward from sender@company.test to partner@outside.test, cc observer@outside.test.", + value: `Contact consultant@another.test for background.\n\n${quotedMessage}`, + recipients: ["partner@outside.test", "observer@outside.test"], + }, + { + name: "outbound header block with folded Cc and Bcc", + blockType: "editable_text" as const, + instruction: "Send the exact approved draft.", + value: `From: sender@company.test\r\nTo: PARTNER@outside.test\r\nCc: observer@outside.test,\r\n second@outside.test\r\nBcc: private@outside.test\r\nSubject: Current draft\r\n\r\nContact body@another.test.\r\n\r\n${quotedMessage}`, + recipients: ["partner@outside.test", "observer@outside.test", "second@outside.test", "private@outside.test"], + }, + { + name: "body-only draft without named recipients", + blockType: "editable_text" as const, + instruction: "Send the exact approved reply in the source conversation.", + value: "Please contact body@another.test.\n\nTo: this-is-body@another.test\n\nThanks.", + recipients: undefined, + }, + { + name: "source email block with original envelope headers", + blockType: "email_thread" as const, + instruction: "Forward the original message to partner@outside.test.", + value: "From: original@elsewhere.test\nTo: sender@company.test\nCc: old-cc@elsewhere.test\nSubject: Original email\n\nOriginal content.", + recipients: ["partner@outside.test"], + }, +].map(({ name, ...scenario }) => [name, scenario] as const))("approval recipients exclude quoted and body addresses: %s", async (_name, { instruction, value, recipients, blockType }) => { + const root = await mkdtemp(path.join(os.tmpdir(), "tend-approval-recipients-")); + roots.push(root); + const store = new AttentionStore(root); + await store.init(); + const domain = new AttentionDomain(store); + await domain.upsertCard("inbox", { + id: "reply", + title: "Review this exact message.", + why: "Recipient reporting must reflect the outbound action.", + sourceMailbox: "sender@company.test", + blocks: [blockType === "email_thread" + ? { id: "draft", type: "email_thread", text: value } + : { id: "draft", type: "editable_text", value, editable: true }], + actions: [{ id: "send", label: "Send reply", behavior: "approve_action", instruction, + artifactBlockId: "draft", externalMutation: true, mailboxPolicy: "reply_from_source" }], + }); + const approved = await domain.runCardAction("inbox", "reply", "send"); + const card = await store.readCard("inbox", "reply"); + const output = formatWorkClaimOutput("inbox", approved, { card }); + if (!("operatorGuidance" in output)) throw new Error("Missing approval receipt."); + + expect(output.operatorGuidance?.userAuthorization?.riskConfirmation?.recipients).toEqual(recipients); + expect(mobileActionConfirmation(card, configuredApprovalAction(card, "send"))?.recipients).toEqual(recipients); + const artifact = output.operatorGuidance?.userAuthorization?.exactApprovedArtifact; + expect(artifact?.value ?? artifact?.text).toBe(value); + expect(output.operatorGuidance?.userAuthorization).toMatchObject({ + scope: "tend_workflow", + connectorAuthorization: "not_attested", + }); +}); diff --git a/test/codex-app-server.test.ts b/test/codex-app-server.test.ts new file mode 100644 index 00000000..5c36ae0b --- /dev/null +++ b/test/codex-app-server.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { declinedServerReply, runAppServerDrain, type AppServerDrainOptions } from "../server/codexAppServer"; +import type { NativeApprovalRequest } from "../server/nativeApprovals"; + +function run(scenario: string, onNativeApproval?: AppServerDrainOptions["onNativeApproval"], timeoutMs = 2_000) { + return runAppServerDrain({ threadId: "thread-inbox", prompt: "Test fixture only.", cwd: process.cwd(), + argv: [process.execPath, new URL("./fixtures/native-app-server.ts", import.meta.url).pathname, scenario], + timeoutMs, onNativeApproval }); +} + +test("native question waits for human input, forwards exact answers once, and inherits host policy", async () => { + let ready!: (request: NativeApprovalRequest) => void; + const requested = new Promise((resolve) => { ready = resolve; }); + let answer!: (reply: unknown) => void; + const reply = new Promise((resolve) => { answer = resolve; }); + let calls = 0; + let done = false; + const result = run("duplicate", (request) => { calls++; ready(request); return reply; }).then((code) => { done = true; return code; }); + expect(await requested).toMatchObject({ requestId: 88, tool: { id: "tool-item", threadId: "thread-inbox", turnId: "turn-one", + arguments: { to: "reader@example.test", body: "Exact body." } } }); + await Bun.sleep(30); + expect(done).toBe(false); + expect(calls).toBe(1); + answer({ answers: { send: { answers: ["Send once"] } } }); + expect(await result).toBe(0); + expect(calls).toBe(1); +}); + +for (const scenario of ["uncorrelated-elicitation", "missing-item", "missing-tool", "wrong-thread", "wrong-turn", "reused-turn"]) { + test(`declines ${scenario} without inventing an association`, async () => { + let calls = 0; + expect(await run(scenario, async () => { calls++; return {}; })).toBe(0); + expect(calls).toBe(0); + }); +} + +for (const scenario of ["cancel-request", "cancel-item", "cancel-turn", "changed-arguments", "disconnect", "timeout"]) { + test(`${scenario} aborts the pending UI and never sends its late answer`, async () => { + let aborted = false; + const result = await run(scenario, (_request, signal) => new Promise((resolve) => { + signal.addEventListener("abort", () => { + aborted = true; + resolve({ answers: { send: { answers: ["Send once"] } } }); + }, { once: true }); + }), scenario === "timeout" ? 150 : 2_000); + expect(aborted).toBe(true); + expect(result).toBe(["disconnect", "timeout"].includes(scenario) ? 1 : 0); + }); +} + +test("transport failure during initialization terminates without waiting forever", async () => { + expect(await run("disconnect-initialize")).toBe(1); +}); + +test("declines each unsupported request using its protocol response shape", () => { + expect(declinedServerReply("mcpServer/elicitation/request")).toEqual({ result: { action: "decline", content: null } }); + expect(declinedServerReply("item/tool/requestUserInput")).toEqual({ result: { answers: {} } }); + expect(declinedServerReply("execCommandApproval")).toEqual({ result: { decision: "denied" } }); + expect(declinedServerReply("item/commandExecution/requestApproval")).toEqual({ result: { decision: "decline" } }); + expect(declinedServerReply("unknown/request")).toMatchObject({ error: { code: -32601 } }); +}); diff --git a/test/domain.test.ts b/test/domain.test.ts index 1e041220..4e3ab5bc 100644 --- a/test/domain.test.ts +++ b/test/domain.test.ts @@ -155,6 +155,8 @@ describe("feed thread operator handshake", () => { expect(output.id).toBe(approved.id); expect(output.operatorGuidance.userAuthorization).toMatchObject({ kind: "tend_action_click", + scope: "tend_workflow", + connectorAuthorization: "not_attested", noSecondChatConfirmationNeeded: true, actionLabel: "Send reply", approvedAt: approved.createdAt, @@ -169,7 +171,8 @@ describe("feed thread operator handshake", () => { expect(output.operatorGuidance.userAuthorization.statement).toContain("configured completion cleanup"); expect(output.operatorGuidance.completionPrerequisite).toContain("Do not ask the user to click Archive separately"); expect(output.operatorGuidance.postActionRule).toContain('"postAction"'); - expect(output.operatorGuidance.userAuthorization.statement).toContain("do not ask for a second chat confirmation"); + expect(output.operatorGuidance.userAuthorization.statement).toContain("final approval within Tend"); + expect(output.operatorGuidance.userAuthorization.statement).toContain("does not attest connector authorization or override a connector denial"); expect(output.operatorGuidance.userAuthorization.invalidatesIf).toContain("the approved artifact changes"); }); @@ -208,9 +211,10 @@ describe("feed thread operator handshake", () => { recipients: ["sydney@smoothmedia.co"], }, }); - expect(output.operatorGuidance.userAuthorization.riskConfirmation.statement).toContain("private inbound email"); + expect(output.operatorGuidance.userAuthorization.riskConfirmation.statement).toContain("forwarding the exact content"); + expect(output.operatorGuidance.userAuthorization.riskConfirmation.statement).toContain("does not establish a connector-native risk confirmation"); expect(output.operatorGuidance.userAuthorization.statement).toContain("sydney@smoothmedia.co"); - expect(output.operatorGuidance.userAuthorization.statement).toContain("do not ask for a second chat confirmation"); + expect(output.operatorGuidance.userAuthorization.statement).toContain("final approval within Tend"); }); test("omits the click authorization receipt when the approval snapshot is stale", async () => { @@ -313,7 +317,11 @@ describe("auto-drain prompt", () => { const prompt = drainPrompt("inbox", "thread-inbox"); expect(prompt).toContain("operatorGuidance.userAuthorization"); expect(prompt).toContain("user's explicit authorization"); - expect(prompt).toContain("do not ask for a second chat confirmation"); + expect(prompt).toContain("do not repeat the Tend approval"); + expect(prompt).toContain("connectorAuthorization is not_attested"); + expect(prompt).toContain("stop retrying that mutation"); + expect(prompt).toContain("record work:block"); + expect(prompt).toContain("Do not rephrase a receipt, change approval settings, or switch execution paths to override the denial"); expect(prompt).toContain("bundled completion cleanup"); expect(prompt).toContain("Do not send the card back to the user for a separate Archive click"); expect(prompt).toContain("Always run `work:claim` at least once after `work:list`"); diff --git a/test/fixtures/native-app-server.ts b/test/fixtures/native-app-server.ts new file mode 100644 index 00000000..9c8c2248 --- /dev/null +++ b/test/fixtures/native-app-server.ts @@ -0,0 +1,69 @@ +import { createInterface } from "node:readline"; + +const scenario = process.argv[2]; +const threadId = "thread-inbox"; +const turnId = "turn-one"; +const item = { type: "mcpToolCall", id: "tool-item", server: "test-mail", tool: "send", arguments: { to: "reader@example.test", body: "Exact body." } }; +const send = (message: unknown) => process.stdout.write(`${JSON.stringify(message)}\n`); +const notify = (method: string, params: unknown) => send({ method, params }); +let cancelled = false; +let responses = 0; +const complete = (ok = true) => notify("turn/completed", { threadId, turn: { id: turnId, status: ok ? "completed" : "failed" } }); +const question = (overrides: Record = {}, id = 88) => send({ id, method: "item/tool/requestUserInput", params: { + threadId, turnId, itemId: item.id, + questions: [{ id: "send", question: "Send this exact message?", options: [ + { label: "Send once", description: "Send only this message." }, + { label: "Do not send", description: "Leave it unsent." }, + ] }], ...overrides, +} }); + +for await (const line of createInterface({ input: process.stdin })) { + const message = JSON.parse(line); + if (message.method === "initialize") { + if (scenario === "disconnect-initialize") process.exit(1); + send({ id: message.id, result: {} }); + } else if (message.method === "thread/resume") { + const changesPolicy = ["approvalPolicy", "approvalsReviewer"].some((key) => key in message.params); + send(changesPolicy ? { id: message.id, error: { message: "Must inherit host approval policy" } } : { id: message.id, result: {} }); + } else if (message.method === "turn/start") { + send({ id: message.id, result: { turn: { id: turnId } } }); + notify("turn/started", { threadId, turn: { id: turnId } }); + if (scenario !== "missing-tool") notify("item/started", { threadId, turnId, item }); + if (scenario === "uncorrelated-elicitation") { + send({ id: 88, method: "mcpServer/elicitation/request", params: { + threadId, turnId, serverName: item.server, mode: "form", message: "Approve?", + _meta: { itemId: item.id }, requestedSchema: { type: "object", properties: {} }, + } }); + } else if (scenario === "missing-item") question({ itemId: undefined }); + else if (scenario === "wrong-thread") question({ threadId: "other-thread" }); + else if (scenario === "wrong-turn" || scenario === "reused-turn") { + if (scenario === "reused-turn") { + notify("turn/started", { threadId, turn: { id: "turn-two" } }); + notify("item/started", { threadId, turnId: "turn-two", item }); + } + question({ turnId: "turn-two" }); + } else { + question(); + if (scenario === "duplicate") question(); + if (scenario.startsWith("cancel-") || scenario === "changed-arguments") setTimeout(() => { + cancelled = true; + if (scenario === "cancel-request") notify("serverRequest/resolved", { threadId, requestId: 88 }); + if (scenario === "cancel-item") notify("item/completed", { threadId, turnId, item }); + if (scenario === "changed-arguments") notify("item/started", { threadId, turnId, + item: { ...item, arguments: { ...item.arguments, body: "Changed body." } } }); + if (scenario === "cancel-turn") complete(); + else setTimeout(() => complete(responses === 0), 40); + }, 30); + if (scenario === "disconnect") setTimeout(() => process.exit(1), 30); + } + } else if (message.id === 88 && !message.method) { + responses++; + if (cancelled) { complete(false); continue; } + const expected = scenario === "uncorrelated-elicitation" ? { action: "decline", content: null } + : ["missing-item", "missing-tool", "wrong-thread", "wrong-turn", "reused-turn"].includes(scenario) ? { answers: {} } + : { answers: { send: { answers: ["Send once"] } } }; + const valid = JSON.stringify(message.result) === JSON.stringify(expected) && responses === 1; + if (scenario === "duplicate") { question(); setTimeout(() => complete(valid && responses === 1), 40); } + else complete(valid); + } +} diff --git a/test/mobile-command.test.ts b/test/mobile-command.test.ts index 9a0c699c..397a30e0 100644 --- a/test/mobile-command.test.ts +++ b/test/mobile-command.test.ts @@ -125,7 +125,7 @@ describe("mobile commands", () => { test("revalidates external recipients after applying an exact artifact edit", async () => { const { store, domain } = await setup(); - await domain.updateBlock("inbox", "reply", "draft", "Send this to original@example.com."); + await domain.updateBlock("inbox", "reply", "draft", "To: original@example.com\n\nExact reply body."); const projection = (await projectMobileWorkspace(store)).cards.find((card) => card.cardId === "reply")!; const action = projection.actions.find((item) => item.id === "send")!; @@ -134,7 +134,7 @@ describe("mobile commands", () => { actionId: "send", expectedActionDigest: action.digest, instruction: undefined, - edits: { draft: "Send this to changed@example.com." }, + edits: { draft: "To: changed@example.com\n\nExact reply body." }, riskConfirmation: { kind: "external_recipient", recipients: ["original@example.com"], diff --git a/test/native-approval-render.test.tsx b/test/native-approval-render.test.tsx new file mode 100644 index 00000000..1f9c5844 --- /dev/null +++ b/test/native-approval-render.test.tsx @@ -0,0 +1,42 @@ +import { afterAll, afterEach, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { NativeApprovalPrompt } from "../src/feed/NativeApprovals"; +import type { NativeApprovalSubmission, NativeApprovalView } from "../shared/nativeApproval"; + +const ownsDom = typeof document === "undefined"; +if (ownsDom) GlobalRegistrator.register(); +afterEach(() => cleanup()); +afterAll(() => { if (ownsDom) GlobalRegistrator.unregister(); }); + +const view: NativeApprovalView = { id: "native-1", feedId: "inbox", cardId: "card-1", cardTitle: "Reply to the reader", + actionLabel: "Send reply", server: "test-mail", tool: "send", requestDigest: "exact-request", + arguments: { to: "reader@example.test", body: "The exact reviewed reply." }, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + questions: [{ id: "consent", question: "Send this message to reader@example.test?", options: [ + { label: "Send once", description: "Send this exact reply." }, { label: "Do not send", description: "Leave it unsent." }, + ] }], +}; + +test("shows real tool arguments, selects nothing, and submits only an explicit choice", async () => { + const sent: NativeApprovalSubmission[] = []; + const ui = render( { sent.push(input); }} />); + expect(ui.getByText(/The exact reviewed reply/).textContent).toContain("reader@example.test"); + const submit = ui.getByRole("button", { name: "Send confirmation" }) as HTMLButtonElement; + expect(submit.disabled).toBe(true); + expect((ui.getByRole("radio", { name: /^Send once/ }) as HTMLInputElement).checked).toBe(false); + fireEvent.click(ui.getByRole("radio", { name: /^Send once/ })); + expect(sent).toEqual([]); + fireEvent.click(submit); + await waitFor(() => expect(sent).toEqual([{ requestDigest: "exact-request", decision: "respond", answers: { consent: "Send once" } }])); +}); + +test("cancel sends no answers and connection loss disables confirmation", async () => { + const sent: NativeApprovalSubmission[] = []; + const ui = render( { sent.push(input); }} />); + expect((ui.getByRole("button", { name: "Send confirmation" }) as HTMLButtonElement).disabled).toBe(true); + expect(ui.getByRole("alert").textContent).toContain("Connection lost"); + ui.rerender( { sent.push(input); }} />); + fireEvent.click(ui.getByRole("button", { name: "Cancel request" })); + await waitFor(() => expect(sent).toEqual([{ requestDigest: "exact-request", decision: "cancel" }])); +}); diff --git a/test/native-approvals.test.ts b/test/native-approvals.test.ts new file mode 100644 index 00000000..1d788a1a --- /dev/null +++ b/test/native-approvals.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { AttentionDomain } from "../server/domain"; +import { NativeApprovalBroker, nativeQuestions, type NativeApprovalRequest } from "../server/nativeApprovals"; +import { apiRoutes } from "../server/routes/api"; +import { AttentionStore } from "../server/store"; +import type { NativeApprovalView } from "../shared/nativeApproval"; +import type { WorkItem } from "../shared/types"; + +const fixtures: Array<{ root: string; broker: NativeApprovalBroker }> = []; +const questions = [{ id: "confirm", question: "Send this exact message to reader@example.test?", options: [ + { label: "Send once", description: "Send the displayed message to this recipient." }, + { label: "Do not send", description: "Leave the message unsent." }, +] }]; +const request: NativeApprovalRequest = { + requestId: 42, method: "item/tool/requestUserInput", + tool: { id: "tool-item", threadId: "thread-inbox", turnId: "turn-one", server: "test-mail", tool: "send", + arguments: { to: "reader@example.test", body: "Exact approved body." } }, + questions, +}; + +async function setup(timeoutMs?: number) { + const root = await mkdtemp(path.join(os.tmpdir(), "tend-native-approval-")); + const store = new AttentionStore(root); + await store.init(); + const domain = new AttentionDomain(store); + await domain.bindFeed("inbox", "thread-inbox"); + await domain.upsertCard("inbox", { id: "native-card", title: "Reply to the reader", why: "Test only.", + sourceMailbox: "owner@example.test", + blocks: [{ id: "draft", type: "editable_text", label: "Draft", value: "To: reader@example.test\n\nExact approved body.", editable: true }], + actions: [{ id: "send", label: "Send reply", behavior: "approve_action", instruction: "Send the displayed draft to reader@example.test.", + artifactBlockId: "draft", externalMutation: true, mailboxPolicy: "reply_from_source" }], + }); + await domain.runCardAction("inbox", "native-card", "send"); + const work = await domain.claimWork("inbox", "thread-inbox") as WorkItem; + await domain.verifyApprovedAction("inbox", work.id, work.capabilityToken, "owner@example.test"); + const broker = new NativeApprovalBroker(store, () => {}, timeoutMs); + fixtures.push({ root, broker }); + const app = apiRoutes({ root, artifactsDir: root, dataDir: root, domain, store, nativeApprovals: broker, + sqlite: { status: () => ({ ok: true }) } as any, port: 0, mutationToken: "browser-token", notify: () => {} }); + return { store, domain, work, broker, app }; +} + +async function waitForView(broker: NativeApprovalBroker): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + const [view] = await broker.list("inbox"); + if (view) return view; + await Bun.sleep(5); + } + throw new Error("No native prompt appeared."); +} + +afterEach(async () => { + for (const { root, broker } of fixtures.splice(0)) { + broker.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +describe("native approval broker", () => { + test("waits for a real response and sends the exact native option only once", async () => { + const { broker, store } = await setup(); + let resolved = false; + const response = broker.request("inbox", request, new AbortController().signal).then((value) => { resolved = true; return value; }); + const view = await waitForView(broker); + expect(resolved).toBe(false); + expect(view).toMatchObject({ cardId: "native-card", actionLabel: "Send reply", arguments: request.tool.arguments, questions }); + expect(JSON.stringify(view)).not.toContain("capabilityToken"); + const input = { requestDigest: view.requestDigest, decision: "respond" as const, answers: { confirm: "Send once" } }; + await broker.respond("inbox", view.id, input); + expect(await response).toEqual({ answers: { confirm: { answers: ["Send once"] } } }); + await expect(broker.respond("inbox", view.id, input)).rejects.toThrow("no longer pending"); + expect((await store.readWorkItems("inbox"))[0].status).toBe("working"); + }); + + test("rejects cross-feed, forged digest, missing and invented answers without resolving", async () => { + const { broker } = await setup(); + const response = broker.request("inbox", request, new AbortController().signal); + const view = await waitForView(broker); + const input = { requestDigest: view.requestDigest, decision: "respond" as const, answers: { confirm: "Send once" } }; + await expect(broker.respond("company-attention", view.id, input)).rejects.toThrow("no longer pending"); + await expect(broker.respond("inbox", view.id, { ...input, requestDigest: "forged" })).rejects.toThrow("changed"); + await expect(broker.respond("inbox", view.id, { ...input, answers: {} })).rejects.toThrow("every"); + await expect(broker.respond("inbox", view.id, { ...input, answers: { confirm: "Approve forever" } })).rejects.toThrow("exact options"); + expect(await broker.list("inbox")).toHaveLength(1); + await broker.respond("inbox", view.id, { requestDigest: view.requestDigest, decision: "cancel" }); + expect(await response).toEqual({ answers: {} }); + }); + + for (const changed of ["artifact", "mailbox", "card", "work", "cleanup"] as const) { + test(`invalidates a pending response when ${changed} changes`, async () => { + const { broker, store, work } = await setup(); + const response = broker.request("inbox", request, new AbortController().signal); + const view = await waitForView(broker); + if (changed === "work") await store.writeWork({ ...await store.readWork("inbox", work.id), status: "approved_blocked" }); + else if (changed === "cleanup") await store.writeConfig({ ...await store.readConfig("inbox"), defaultCleanup: "Changed cleanup." }); + else { + const card = await store.readCard("inbox", "native-card"); + if (changed === "artifact" && card.blocks[0].type === "editable_text") card.blocks[0].value = "Changed body."; + if (changed === "mailbox") card.sourceMailbox = "other@example.test"; + if (changed === "card") card.title = "Different context"; + await store.writeCard(card); + } + await expect(broker.respond("inbox", view.id, { requestDigest: view.requestDigest, decision: "respond", answers: { confirm: "Send once" } })).rejects.toThrow(); + expect(await response).toEqual({ answers: {} }); + expect(await broker.list("inbox")).toEqual([]); + }); + } + + test("host cancellation and server shutdown discard prompts without approving", async () => { + const { broker } = await setup(); + const controller = new AbortController(); + const response = broker.request("inbox", request, controller.signal); + const view = await waitForView(broker); + controller.abort(); + expect(await response).toEqual({ answers: {} }); + await expect(broker.respond("inbox", view.id, { requestDigest: view.requestDigest, decision: "respond", answers: { confirm: "Send once" } })).rejects.toThrow(); + const second = broker.request("inbox", { ...request, requestId: 43 }, new AbortController().signal); + await waitForView(broker); + broker.close(); + expect(await second).toEqual({ answers: {} }); + }); + + test("expires requests instead of leaving reusable approval state", async () => { + const { broker } = await setup(50); + const response = broker.request("inbox", request, new AbortController().signal); + await waitForView(broker); + expect(await response).toEqual({ answers: {} }); + expect(await broker.list("inbox")).toEqual([]); + }); + + test("refuses unverified, ordinary, or wrong-task work", async () => { + const { broker, store, work } = await setup(); + await expect(broker.request("inbox", { ...request, tool: { ...request.tool, threadId: "other" } }, new AbortController().signal)).rejects.toThrow("task"); + await store.writeWork({ ...work, verifiedApprovalDigest: undefined, verifiedAt: undefined }); + await expect(broker.request("inbox", request, new AbortController().signal)).rejects.toThrow("verified"); + await store.writeWork({ ...work, kind: "scoped_instruction", approvalDigest: undefined }); + await expect(broker.request("inbox", request, new AbortController().signal)).rejects.toThrow("verified"); + }); + + for (const changed of ["mailbox", "cleanup"] as const) { + test(`refuses ${changed} changed between verification and the native request`, async () => { + const { broker, store } = await setup(); + if (changed === "mailbox") await store.writeCard({ ...await store.readCard("inbox", "native-card"), sourceMailbox: "other@example.test" }); + else await store.writeConfig({ ...await store.readConfig("inbox"), defaultCleanup: "Changed cleanup." }); + await expect(broker.request("inbox", request, new AbortController().signal)).rejects.toThrow(); + expect(await broker.list("inbox")).toEqual([]); + }); + } + + test("uses protected browser routes and returns stale submissions as errors", async () => { + const { broker, app } = await setup(); + const response = broker.request("inbox", request, new AbortController().signal); + const view = await waitForView(broker); + const url = `/api/feeds/inbox/native-approvals/${view.id}/respond`; + const body = JSON.stringify({ requestDigest: view.requestDigest, decision: "respond", answers: { confirm: "Do not send" } }); + const json = { "content-type": "application/json" }; + expect((await app.request(url, { method: "POST", headers: { ...json, origin: "https://foreign.example" }, body })).status).toBe(403); + expect((await app.request(url, { method: "POST", headers: { ...json, origin: "http://127.0.0.1:4321" }, body })).status).toBe(403); + const headers = { ...json, origin: "http://127.0.0.1:4321", "x-attention-mutation-token": "browser-token" }; + const listed = await app.request("/api/feeds/inbox/native-approvals"); + expect(listed.headers.get("cache-control")).toBe("no-store"); + expect((await listed.json())[0].arguments).toEqual(request.tool.arguments); + expect((await app.request(url, { method: "POST", headers, body })).status).toBe(200); + expect(await response).toEqual({ answers: { confirm: { answers: ["Do not send"] } } }); + expect((await app.request(url, { method: "POST", headers, body })).status).toBe(400); + }); + + test("only displays bounded, nonsecret choice questions", () => { + expect(nativeQuestions({ questions })).toEqual(questions); + expect(() => nativeQuestions({ questions: [{ ...questions[0], isSecret: true }] })).toThrow(); + expect(() => nativeQuestions({ questions: [{ ...questions[0], options: null }] })).toThrow(); + expect(() => nativeQuestions({ questions: [questions[0], questions[0]] })).toThrow(); + }); +});