diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8454a26 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +pnpm-lock.yaml +*.md +.next/ +.eve/ +.output/ +.vercel/ +coverage/ +dist/ +next-env.d.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..de753c5 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index db40b4c..f21b963 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -19,13 +19,20 @@ export function isAllowedBellMessage(message: Pick value.trim()), - ); + const allowedSenders = z + .array(phoneNumber) + .nonempty() + .safeParse( + (process.env.BELL_LINQ_ALLOWED_SENDERS ?? "").split(",").map((value) => value.trim()), + ); - return incoming.success && receivingNumber.success && allowedSenders.success - && incoming.data.chat.owner_handle.handle === receivingNumber.data - && allowedSenders.data.includes(incoming.data.sender_handle.handle); + return ( + incoming.success && + receivingNumber.success && + allowedSenders.success && + incoming.data.chat.owner_handle.handle === receivingNumber.data && + allowedSenders.data.includes(incoming.data.sender_handle.handle) + ); } export default linqChannel({ @@ -41,11 +48,19 @@ export default linqChannel({ const auth = defaultLinqAuth(message); const incoming = inboundMessage.parse(message.raw); return { - auth: { ...auth, attributes: { ...auth.attributes, bell_watch_chat: incoming.chat.id, bell_watch_sender: incoming.sender_handle.handle, bell_watch_line: incoming.chat.owner_handle.handle } }, + auth: { + ...auth, + attributes: { + ...auth.attributes, + bell_watch_chat: incoming.chat.id, + bell_watch_sender: incoming.sender_handle.handle, + bell_watch_line: incoming.chat.owner_handle.handle, + }, + }, context: [ - "This request arrived through Bell's private Linq iMessage or SMS channel. " - + "Reply concisely in this chat. Use the same browser for follow-up requests. " - + "For a human-only step, send the human_handoff tool's link and wait for a reply.", + "This request arrived through Bell's private Linq iMessage or SMS channel. " + + "Reply concisely in this chat. Use the same browser for follow-up requests. " + + "For a human-only step, send the human_handoff tool's link and wait for a reply.", ], }; }, diff --git a/agent/extensions/link/lib/spend-requests.ts b/agent/extensions/link/lib/spend-requests.ts index 7286b10..1227e6a 100644 --- a/agent/extensions/link/lib/spend-requests.ts +++ b/agent/extensions/link/lib/spend-requests.ts @@ -1,7 +1,10 @@ import { z } from "zod"; import { isLinkSandbox, isSandboxRequest } from "../../../../server/link/sandbox"; -export const spendRequestId = z.string().min(1).max(255) +export const spendRequestId = z + .string() + .min(1) + .max(255) .regex(/^[A-Za-z0-9_-]+$/, "Invalid Link spend-request ID."); export const spendRequestInput = z.strictObject({ id: spendRequestId }); @@ -17,9 +20,23 @@ export async function linkMetadataRequest(request: () => T | Promise): Pro } catch (error) { // The SDK includes provider response bodies in its errors. Do not persist // those bodies, while preserving Eve's separate authorization pause signals. - if (error instanceof Error && ["LinkApiError", "LinkResponseError", "LinkTransportError", "LinkConfigurationError"].includes(error.name)) { - const status = "status" in error && typeof error.status === "number" && Number.isInteger(error.status) - && error.status >= 100 && error.status <= 599 ? ` (HTTP ${error.status})` : ""; + if ( + error instanceof Error && + [ + "LinkApiError", + "LinkResponseError", + "LinkTransportError", + "LinkConfigurationError", + ].includes(error.name) + ) { + const status = + "status" in error && + typeof error.status === "number" && + Number.isInteger(error.status) && + error.status >= 100 && + error.status <= 599 + ? ` (HTTP ${error.status})` + : ""; throw new Error(`Link request failed${status}. Check the existing request before retrying.`); } throw error; @@ -42,51 +59,71 @@ const metadata = z.object({ merchant_name: z.string().optional(), merchant_url: z.string().optional(), context: z.string().optional(), - line_items: z.array(z.object({ - name: z.string(), - url: z.string().optional(), - image_url: z.string().optional(), - description: z.string().optional(), - sku: z.string().optional(), - totals: z.array(total).optional(), - quantity: z.number().optional(), - unit_amount: z.number().optional(), - product_url: z.string().optional(), - })).optional(), + line_items: z + .array( + z.object({ + name: z.string(), + url: z.string().optional(), + image_url: z.string().optional(), + description: z.string().optional(), + sku: z.string().optional(), + totals: z.array(total).optional(), + quantity: z.number().optional(), + unit_amount: z.number().optional(), + product_url: z.string().optional(), + }), + ) + .optional(), totals: z.array(total).optional(), approval_url: z.string().optional(), activity_url: z.string().optional(), expires_at: z.number().optional(), - recurring: z.object({ - interval: z.string(), - interval_count: z.number().optional(), - }).nullable().optional(), - payment_status_details: z.object({ - outcome: z.enum(["success", "failure"]), - code: z.string().nullable().optional(), - decline_code: z.string().nullable().optional(), - amount: z.number(), - currency: z.string(), - created: z.number().nullable().optional(), - refund_details: z.object({ + recurring: z + .object({ + interval: z.string(), + interval_count: z.number().optional(), + }) + .nullable() + .optional(), + payment_status_details: z + .object({ + outcome: z.enum(["success", "failure"]), + code: z.string().nullable().optional(), + decline_code: z.string().nullable().optional(), amount: z.number(), currency: z.string(), - state: z.string(), - created: z.number(), - }).nullable().optional(), - }).nullable().optional(), - status_details: z.object({ - requires_action: z.object({ - failure_code: z.string().optional(), - next_action: z.object({ - type: z.string().optional(), - display_message: z.string().optional(), - action_url: z.string().nullable().optional(), - resolution: z.string().optional(), - expires_at: z.number().nullable().optional(), - }).optional(), - }).optional(), - }).nullable().optional(), + created: z.number().nullable().optional(), + refund_details: z + .object({ + amount: z.number(), + currency: z.string(), + state: z.string(), + created: z.number(), + }) + .nullable() + .optional(), + }) + .nullable() + .optional(), + status_details: z + .object({ + requires_action: z + .object({ + failure_code: z.string().optional(), + next_action: z + .object({ + type: z.string().optional(), + display_message: z.string().optional(), + action_url: z.string().nullable().optional(), + resolution: z.string().optional(), + expires_at: z.number().nullable().optional(), + }) + .optional(), + }) + .optional(), + }) + .nullable() + .optional(), created_at: z.string(), updated_at: z.string(), }); @@ -99,7 +136,7 @@ export function spendRequestMetadata(value: unknown) { export function spendRequestListMetadata(value: unknown) { if (!Array.isArray(value)) throw new Error("Unexpected Link spend-request list."); - return value.map(spendRequestMetadata).filter(item => isSandboxRequest(item.id)); + return value.map(spendRequestMetadata).filter((item) => isSandboxRequest(item.id)); } export function spendApprovalMetadata(value: unknown) { diff --git a/agent/extensions/link/tools/cancel_spend_request.ts b/agent/extensions/link/tools/cancel_spend_request.ts index 74b7cdd..f2c62b7 100644 --- a/agent/extensions/link/tools/cancel_spend_request.ts +++ b/agent/extensions/link/tools/cancel_spend_request.ts @@ -1,7 +1,11 @@ import { cancel_spend_request } from "@stripe/link-integrations-eve/tools"; import { defineTool } from "eve/tools"; import { freshLinkContext } from "../../../lib/link-auth"; -import { linkMetadataRequest, spendRequestInput, spendRequestMetadata } from "../lib/spend-requests"; +import { + linkMetadataRequest, + spendRequestInput, + spendRequestMetadata, +} from "../lib/spend-requests"; export default defineTool({ description: cancel_spend_request.description, @@ -10,6 +14,10 @@ export default defineTool({ async execute(args, ctx) { const input = spendRequestInput.parse(args); ctx.abortSignal.throwIfAborted(); - return spendRequestMetadata(await linkMetadataRequest(() => cancel_spend_request.execute(input, freshLinkContext(ctx, input.id)))); + return spendRequestMetadata( + await linkMetadataRequest(() => + cancel_spend_request.execute(input, freshLinkContext(ctx, input.id)), + ), + ); }, }); diff --git a/agent/extensions/link/tools/checkout.ts b/agent/extensions/link/tools/checkout.ts index 27ef480..cce769c 100644 --- a/agent/extensions/link/tools/checkout.ts +++ b/agent/extensions/link/tools/checkout.ts @@ -8,20 +8,43 @@ import { isLinkSandbox } from "../../../../server/link/sandbox"; import { linkMetadataRequest } from "../lib/spend-requests"; export default defineTool({ - description: "Submit an already-approved Link purchase in the existing browser. Supply exact CSS payment-field mappings and the approved amount/currency. Virtual-card values stay server-side. Shared Payment Tokens and Link Pay Tokens require a separate integration and are not accepted. Use only once per purchase; an uncertain outcome requires checking the merchant and Link before any retry. Human handoff remains available for 3DS.", + description: + "Submit an already-approved Link purchase in the existing browser. Supply exact CSS payment-field mappings and the approved amount/currency. Virtual-card values stay server-side. Shared Payment Tokens and Link Pay Tokens require a separate integration and are not accepted. Use only once per purchase; an uncertain outcome requires checking the merchant and Link before any retry. Human handoff remains available for 3DS.", inputSchema: checkoutInput, async execute(args, ctx) { const input = checkoutInput.parse(args); linkPrincipal(ctx); ctx.abortSignal.throwIfAborted(); - const retrieve = (include?: [typeof input.credential]) => linkMetadataRequest(() => retrieve_spend_request.execute({ id: input.id, ...(include ? { include } : {}) }, freshLinkContext(ctx, input.id))); + const retrieve = (include?: [typeof input.credential]) => + linkMetadataRequest(() => + retrieve_spend_request.execute( + { id: input.id, ...(include ? { include } : {}) }, + freshLinkContext(ctx, input.id), + ), + ); const origins = paymentOrigins(await retrieve(), input); - const output = await runCancelableBrowserWork(ctx.session.id, ctx.abortSignal, () => runBrowserOperation(ctx.session.id, browser => browser.withHostedPage(ctx.abortSignal, (page, connection) => - checkoutInHostedPage(page, connection, input, origins, async () => { - const result = await retrieve([input.credential]); - if (JSON.stringify(paymentOrigins(result, input)) !== JSON.stringify(origins)) throw new Error("Link approval changed."); - return result; - }, ctx.abortSignal), true))); + const output = await runCancelableBrowserWork(ctx.session.id, ctx.abortSignal, () => + runBrowserOperation(ctx.session.id, (browser) => + browser.withHostedPage( + ctx.abortSignal, + (page, connection) => + checkoutInHostedPage( + page, + connection, + input, + origins, + async () => { + const result = await retrieve([input.credential]); + if (JSON.stringify(paymentOrigins(result, input)) !== JSON.stringify(origins)) + throw new Error("Link approval changed."); + return result; + }, + ctx.abortSignal, + ), + true, + ), + ), + ); return isLinkSandbox() ? { ...output.result, test: true } : output.result; }, }); diff --git a/agent/extensions/link/tools/create_report.ts b/agent/extensions/link/tools/create_report.ts index 72a265d..c54c828 100644 --- a/agent/extensions/link/tools/create_report.ts +++ b/agent/extensions/link/tools/create_report.ts @@ -2,6 +2,8 @@ import { create_report } from "@stripe/link-integrations-eve/tools"; import { defineTool } from "eve/tools"; import { freshLinkContext } from "../../../lib/link-auth"; -export default defineTool({ ...create_report, - execute: (input, ctx) => create_report.execute(input, freshLinkContext(ctx, input.spend_request_id)), +export default defineTool({ + ...create_report, + execute: (input, ctx) => + create_report.execute(input, freshLinkContext(ctx, input.spend_request_id)), }); diff --git a/agent/extensions/link/tools/create_spend_request.ts b/agent/extensions/link/tools/create_spend_request.ts index 901287e..89168f6 100644 --- a/agent/extensions/link/tools/create_spend_request.ts +++ b/agent/extensions/link/tools/create_spend_request.ts @@ -11,7 +11,14 @@ export default defineTool({ approval: create_spend_request.approval, async execute(input, ctx) { ctx.abortSignal.throwIfAborted(); - const result = spendRequestMetadata(await linkMetadataRequest(() => create_spend_request.execute(sandboxPurchase(input), freshLinkContext(ctx, undefined, "create")))); + const result = spendRequestMetadata( + await linkMetadataRequest(() => + create_spend_request.execute( + sandboxPurchase(input), + freshLinkContext(ctx, undefined, "create"), + ), + ), + ); rememberSandboxRequest(result.id); return result; }, diff --git a/agent/extensions/link/tools/list_payment_methods.ts b/agent/extensions/link/tools/list_payment_methods.ts index 9454ff2..98e2301 100644 --- a/agent/extensions/link/tools/list_payment_methods.ts +++ b/agent/extensions/link/tools/list_payment_methods.ts @@ -2,6 +2,7 @@ import { list_payment_methods } from "@stripe/link-integrations-eve/tools"; import { defineTool } from "eve/tools"; import { freshLinkContext } from "../../../lib/link-auth"; -export default defineTool({ ...list_payment_methods, +export default defineTool({ + ...list_payment_methods, execute: (input, ctx) => list_payment_methods.execute(input, freshLinkContext(ctx)), }); diff --git a/agent/extensions/link/tools/list_shipping_addresses.ts b/agent/extensions/link/tools/list_shipping_addresses.ts index 1aa742e..017abfe 100644 --- a/agent/extensions/link/tools/list_shipping_addresses.ts +++ b/agent/extensions/link/tools/list_shipping_addresses.ts @@ -2,6 +2,7 @@ import { list_shipping_addresses } from "@stripe/link-integrations-eve/tools"; import { defineTool } from "eve/tools"; import { freshLinkContext } from "../../../lib/link-auth"; -export default defineTool({ ...list_shipping_addresses, +export default defineTool({ + ...list_shipping_addresses, execute: (input, ctx) => list_shipping_addresses.execute(input, freshLinkContext(ctx)), }); diff --git a/agent/extensions/link/tools/list_spend_requests.ts b/agent/extensions/link/tools/list_spend_requests.ts index f53797c..f54c3c0 100644 --- a/agent/extensions/link/tools/list_spend_requests.ts +++ b/agent/extensions/link/tools/list_spend_requests.ts @@ -9,6 +9,10 @@ export default defineTool({ approval: list_spend_requests.approval, async execute(input, ctx) { ctx.abortSignal.throwIfAborted(); - return spendRequestListMetadata(await linkMetadataRequest(() => list_spend_requests.execute(input, freshLinkContext(ctx, undefined, "list")))); + return spendRequestListMetadata( + await linkMetadataRequest(() => + list_spend_requests.execute(input, freshLinkContext(ctx, undefined, "list")), + ), + ); }, }); diff --git a/agent/extensions/link/tools/request_spend_approval.ts b/agent/extensions/link/tools/request_spend_approval.ts index 9d92cb1..8f93ebb 100644 --- a/agent/extensions/link/tools/request_spend_approval.ts +++ b/agent/extensions/link/tools/request_spend_approval.ts @@ -1,7 +1,11 @@ import { request_spend_approval } from "@stripe/link-integrations-eve/tools"; import { defineTool } from "eve/tools"; import { freshLinkContext } from "../../../lib/link-auth"; -import { linkMetadataRequest, spendApprovalMetadata, spendRequestInput } from "../lib/spend-requests"; +import { + linkMetadataRequest, + spendApprovalMetadata, + spendRequestInput, +} from "../lib/spend-requests"; export default defineTool({ description: request_spend_approval.description, @@ -10,6 +14,10 @@ export default defineTool({ async execute(args, ctx) { const input = spendRequestInput.parse(args); ctx.abortSignal.throwIfAborted(); - return spendApprovalMetadata(await linkMetadataRequest(() => request_spend_approval.execute(input, freshLinkContext(ctx, input.id)))); + return spendApprovalMetadata( + await linkMetadataRequest(() => + request_spend_approval.execute(input, freshLinkContext(ctx, input.id)), + ), + ); }, }); diff --git a/agent/extensions/link/tools/retrieve_spend_request.ts b/agent/extensions/link/tools/retrieve_spend_request.ts index f118be8..cc973b9 100644 --- a/agent/extensions/link/tools/retrieve_spend_request.ts +++ b/agent/extensions/link/tools/retrieve_spend_request.ts @@ -1,15 +1,22 @@ import { retrieve_spend_request } from "@stripe/link-integrations-eve/tools"; import { defineTool } from "eve/tools"; import { freshLinkContext } from "../../../lib/link-auth"; -import { linkMetadataRequest, spendRequestInput, spendRequestMetadata } from "../lib/spend-requests"; +import { + linkMetadataRequest, + spendRequestInput, + spendRequestMetadata, +} from "../lib/spend-requests"; export default defineTool({ - description: "Retrieve a Link spend request's status and approval metadata. Never returns card numbers, security codes, or payment tokens. Use link__checkout to complete an approved purchase with server-side credential entry.", + description: + "Retrieve a Link spend request's status and approval metadata. Never returns card numbers, security codes, or payment tokens. Use link__checkout to complete an approved purchase with server-side credential entry.", inputSchema: spendRequestInput, async execute(args, ctx) { const input = spendRequestInput.parse(args); ctx.abortSignal.throwIfAborted(); - const result = await linkMetadataRequest(() => retrieve_spend_request.execute(input, freshLinkContext(ctx, input.id))); + const result = await linkMetadataRequest(() => + retrieve_spend_request.execute(input, freshLinkContext(ctx, input.id)), + ); return result === null ? null : spendRequestMetadata(result); }, }); diff --git a/agent/extensions/link/tools/retrieve_user_info.ts b/agent/extensions/link/tools/retrieve_user_info.ts index 60d55c6..5998a5f 100644 --- a/agent/extensions/link/tools/retrieve_user_info.ts +++ b/agent/extensions/link/tools/retrieve_user_info.ts @@ -2,6 +2,7 @@ import { retrieve_user_info } from "@stripe/link-integrations-eve/tools"; import { defineTool } from "eve/tools"; import { freshLinkContext } from "../../../lib/link-auth"; -export default defineTool({ ...retrieve_user_info, +export default defineTool({ + ...retrieve_user_info, execute: (input, ctx) => retrieve_user_info.execute(input, freshLinkContext(ctx)), }); diff --git a/agent/extensions/link/tools/update_spend_request.ts b/agent/extensions/link/tools/update_spend_request.ts index c09a156..8ef6b4a 100644 --- a/agent/extensions/link/tools/update_spend_request.ts +++ b/agent/extensions/link/tools/update_spend_request.ts @@ -1,15 +1,24 @@ import { update_spend_request } from "@stripe/link-integrations-eve/tools"; import { defineTool } from "eve/tools"; import { freshLinkContext } from "../../../lib/link-auth"; -import { linkMetadataRequest, spendRequestMetadata, validateSpendRequestId } from "../lib/spend-requests"; +import { + linkMetadataRequest, + spendRequestMetadata, + validateSpendRequestId, +} from "../lib/spend-requests"; export default defineTool({ - description: "Update a Link spend request and return approval/status metadata only. Use link__checkout for server-side credential entry after approval.", + description: + "Update a Link spend request and return approval/status metadata only. Use link__checkout for server-side credential entry after approval.", inputSchema: update_spend_request.inputSchema, approval: update_spend_request.approval, async execute(input, ctx) { validateSpendRequestId(input); ctx.abortSignal.throwIfAborted(); - return spendRequestMetadata(await linkMetadataRequest(() => update_spend_request.execute(input, freshLinkContext(ctx, input.id)))); + return spendRequestMetadata( + await linkMetadataRequest(() => + update_spend_request.execute(input, freshLinkContext(ctx, input.id)), + ), + ); }, }); diff --git a/agent/lib/browser-runtime.ts b/agent/lib/browser-runtime.ts index 57e8ae6..7da7b3b 100644 --- a/agent/lib/browser-runtime.ts +++ b/agent/lib/browser-runtime.ts @@ -1,5 +1,8 @@ import type { CaptchaTransition } from "../../lib/browser/captcha"; -import { personalBrowserEnabled, withPersonalBrowserLock } from "../../server/onepassword/browser-policy"; +import { + personalBrowserEnabled, + withPersonalBrowserLock, +} from "../../server/onepassword/browser-policy"; import { createAgentContext, ensureAgentContext, @@ -10,7 +13,11 @@ import { } from "../../server/browser/agent-contexts.js"; import { BrowserSession, CaptchaHandoffError } from "../../server/browser/browser-session.js"; import { withBrowserPrivacyLock } from "../../server/browser/privacy-lock"; -import type { BrowserProxyLocation, ConversationState, TraceEvent } from "../../server/browser/types.js"; +import type { + BrowserProxyLocation, + ConversationState, + TraceEvent, +} from "../../server/browser/types.js"; import { runWithCancellation } from "./cancellation"; import { applyCaptchaTransition, @@ -88,12 +95,22 @@ export async function runBrowserOperation( operation: BrowserOperation, includeScreenshot = false, ): Promise> { - return withBrowserPrivacyLock(sessionId, - () => withPersonalBrowserLock(sessionId, () => runBrowserOperationUnlocked(sessionId, operation, includeScreenshot)), - undefined, Boolean(taskState.get().browser.sessionId)); + return withBrowserPrivacyLock( + sessionId, + () => + withPersonalBrowserLock(sessionId, () => + runBrowserOperationUnlocked(sessionId, operation, includeScreenshot), + ), + undefined, + Boolean(taskState.get().browser.sessionId), + ); } -async function runBrowserOperationUnlocked(sessionId: string, operation: BrowserOperation, includeScreenshot: boolean): Promise> { +async function runBrowserOperationUnlocked( + sessionId: string, + operation: BrowserOperation, + includeScreenshot: boolean, +): Promise> { const runtime = getRuntime(sessionId); scheduleIdleClose(sessionId, runtime); const traceStart = runtime.state.traces.length; @@ -141,8 +158,7 @@ async function runBrowserOperationUnlocked(sessionId: string, operation: Brow export async function saveBrowserRuntime(sessionId: string): Promise { const runtime = runtimes.get(sessionId); - if (runtime) - taskState.update((current) => updateTaskBrowser(current, runtime.state.browser)); + if (runtime) taskState.update((current) => updateTaskBrowser(current, runtime.state.browser)); } export async function closeBrowserRuntime(sessionId: string): Promise { @@ -153,12 +169,17 @@ export async function closeBrowserRuntime(sessionId: string): Promise { (taskState.get().browser.sessionId ? getRuntime(sessionId) : undefined); if (runtime) { if (runtime.idleTimer) clearTimeout(runtime.idleTimer); - runtime.closing ??= runtime.browser.close().then(() => { - runtimes.delete(sessionId); - taskState.update((current) => - closeTaskBrowser(updateTaskBrowser(current, runtime.state.browser)), - ); - }).finally(() => { runtime.closing = undefined; }); + runtime.closing ??= runtime.browser + .close() + .then(() => { + runtimes.delete(sessionId); + taskState.update((current) => + closeTaskBrowser(updateTaskBrowser(current, runtime.state.browser)), + ); + }) + .finally(() => { + runtime.closing = undefined; + }); return runtime.closing; } taskState.update(closeTaskBrowser); @@ -339,13 +360,17 @@ function createRuntimeBrowser( onCaptchaTransition: (transition) => { captchaTransitions.push(transition); }, - ensureContext: personalBrowserEnabled() ? undefined : async (currentContextId) => { - const context = await ensureAgentContext(sessionId, currentContextId); - state.browser.contextId = context.id; - state.browser.contextStatus = context.status; - taskState.update((current) => recordTaskBrowserContext(current, context.id, context.status)); - return { id: context.id, status: context.status }; - }, + ensureContext: personalBrowserEnabled() + ? undefined + : async (currentContextId) => { + const context = await ensureAgentContext(sessionId, currentContextId); + state.browser.contextId = context.id; + state.browser.contextStatus = context.status; + taskState.update((current) => + recordTaskBrowserContext(current, context.id, context.status), + ); + return { id: context.id, status: context.status }; + }, }); } diff --git a/agent/lib/link-auth.ts b/agent/lib/link-auth.ts index 0cb863a..b5672aa 100644 --- a/agent/lib/link-auth.ts +++ b/agent/lib/link-auth.ts @@ -1,31 +1,42 @@ -import { defineInteractiveAuthorization, isConnectionAuthorizationRequiredError, type ConnectionPrincipal } from "eve/connections"; +import { + defineInteractiveAuthorization, + isConnectionAuthorizationRequiredError, + type ConnectionPrincipal, +} from "eve/connections"; import type { ToolContext } from "eve/tools"; import { getLinkOAuth, linkOwner, safeLinkCall, type LinkResume } from "../../server/link/oauth"; import { assertSandboxRequest, isLinkSandbox, sandboxToken } from "../../server/link/sandbox"; export function createLinkAuth(service = getLinkOAuth) { const hosted = () => { - if (isLinkSandbox()) throw new Error("The sandbox uses an operator-supplied token and test spend tools; hosted wallet OAuth and wallet-history tools are unavailable."); + if (isLinkSandbox()) + throw new Error( + "The sandbox uses an operator-supplied token and test spend tools; hosted wallet OAuth and wallet-history tools are unavailable.", + ); return service(); }; return defineInteractiveAuthorization({ displayName: "Link", - getToken: ({ principal }) => safeLinkCall(() => { - linkOwner(principal); - return hosted().getToken(principal); - }), - evict: ({ principal }) => safeLinkCall(() => { - linkOwner(principal); - return hosted().evict(principal); - }), - startAuthorization: ({ principal, callbackUrl }) => safeLinkCall(() => { - linkOwner(principal); - return hosted().start(principal, callbackUrl); - }), - completeAuthorization: ({ principal, callbackUrl, resume, callback }) => safeLinkCall(() => { - linkOwner(principal); - return hosted().complete(principal, callbackUrl, resume, callback.params); - }), + getToken: ({ principal }) => + safeLinkCall(() => { + linkOwner(principal); + return hosted().getToken(principal); + }), + evict: ({ principal }) => + safeLinkCall(() => { + linkOwner(principal); + return hosted().evict(principal); + }), + startAuthorization: ({ principal, callbackUrl }) => + safeLinkCall(() => { + linkOwner(principal); + return hosted().start(principal, callbackUrl); + }), + completeAuthorization: ({ principal, callbackUrl, resume, callback }) => + safeLinkCall(() => { + linkOwner(principal); + return hosted().complete(principal, callbackUrl, resume, callback.params); + }), }); } @@ -33,9 +44,10 @@ export const linkAuth = createLinkAuth(); export function linkPrincipal(ctx: ToolContext): ConnectionPrincipal { const auth = ctx.session.auth.current; - const principal: ConnectionPrincipal = auth?.principalType === "user" && auth.authenticator === "linq-message" - ? { type: "user", id: auth.principalId, issuer: auth.issuer } - : { type: "app" }; + const principal: ConnectionPrincipal = + auth?.principalType === "user" && auth.authenticator === "linq-message" + ? { type: "user", id: auth.principalId, issuer: auth.issuer } + : { type: "app" }; linkOwner(principal); return principal; } @@ -44,15 +56,28 @@ export function linkPrincipal(ctx: ToolContext): ConnectionPrincipal { * native SDK request. A bearer cached earlier in a step must not bypass a * concurrent disconnect from another conversation. */ -export function freshLinkContext(ctx: ToolContext, requestId?: string, sandboxOperation?: "create" | "list"): ToolContext { +export function freshLinkContext( + ctx: ToolContext, + requestId?: string, + sandboxOperation?: "create" | "list", +): ToolContext { if (isLinkSandbox()) { const principal = linkPrincipal(ctx); sandboxToken(principal); if (requestId) assertSandboxRequest(requestId); - else if (!sandboxOperation) throw new Error("The sandbox exposes test spend requests only, not saved wallet details or payment history."); - return { ...ctx, getToken: async () => sandboxToken(principal), requireAuth: () => { - throw new Error("The sandbox token expired or was rejected. Replace it in the process environment; do not retry an uncertain purchase."); - } }; + else if (!sandboxOperation) + throw new Error( + "The sandbox exposes test spend requests only, not saved wallet details or payment history.", + ); + return { + ...ctx, + getToken: async () => sandboxToken(principal), + requireAuth: () => { + throw new Error( + "The sandbox token expired or was rejected. Replace it in the process environment; do not retry an uncertain purchase.", + ); + }, + }; } return { ...ctx, diff --git a/agent/lib/linq.ts b/agent/lib/linq.ts index 9462924..50e7277 100644 --- a/agent/lib/linq.ts +++ b/agent/lib/linq.ts @@ -7,7 +7,9 @@ export const linqBrowserEvents: NonNullable = { try { await browserChannelEvents["session.failed"](event); } finally { - await channel.thread?.post("This session could not recover from an error. Start a new conversation to continue."); + await channel.thread?.post( + "This session could not recover from an error. Start a new conversation to continue.", + ); } }, }; diff --git a/agent/lib/monitors.ts b/agent/lib/monitors.ts index 6f785ba..218e634 100644 --- a/agent/lib/monitors.ts +++ b/agent/lib/monitors.ts @@ -3,7 +3,8 @@ import { assertOwner, destinationSchema } from "../../server/monitors/config"; export function watchIdentity(ctx: ToolContext) { const auth = ctx.session.auth.current; - if (!auth || auth.authenticator !== "linq-message" || auth.principalType !== "user") throw new Error("Watches require an authenticated Linq sender."); + if (!auth || auth.authenticator !== "linq-message" || auth.principalType !== "user") + throw new Error("Watches require an authenticated Linq sender."); const owner = { issuer: auth.issuer ?? "", id: auth.principalId }; assertOwner(owner); return owner; @@ -11,13 +12,21 @@ export function watchIdentity(ctx: ToolContext) { export function watchDestination(ctx: ToolContext) { watchIdentity(ctx); const attributes = ctx.session.auth.current!.attributes; - return destinationSchema.parse({ chat: attributes.bell_watch_chat, sender: attributes.bell_watch_sender, line: attributes.bell_watch_line }); + return destinationSchema.parse({ + chat: attributes.bell_watch_chat, + sender: attributes.bell_watch_sender, + line: attributes.bell_watch_line, + }); } export async function safeWatch(operation: () => Promise): Promise { - try { return await operation(); } - catch (error) { + try { + return await operation(); + } catch (error) { const message = error instanceof Error ? error.message : ""; - if (/^(Watches |Watch not found|Watch operation|This Linq)/.test(message)) throw new Error(message); - throw new Error("Watch operation failed. List watches before retrying creation; an uncertain watch may already exist."); + if (/^(Watches |Watch not found|Watch operation|This Linq)/.test(message)) + throw new Error(message); + throw new Error( + "Watch operation failed. List watches before retrying creation; an uncertain watch may already exist.", + ); } } diff --git a/agent/lib/onepassword-auth.ts b/agent/lib/onepassword-auth.ts index c69ec26..7e5e5f5 100644 --- a/agent/lib/onepassword-auth.ts +++ b/agent/lib/onepassword-auth.ts @@ -5,14 +5,24 @@ import { failed, ownerOf } from "../../server/onepassword/config"; export function onePasswordPrincipal(ctx: ToolContext): ConnectionPrincipal { const auth = ctx.session.auth.current; - const principal: ConnectionPrincipal = auth?.principalType === "user" && auth.authenticator === "linq-message" ? { type: "user", id: auth.principalId, issuer: auth.issuer } : { type: "app" }; + const principal: ConnectionPrincipal = + auth?.principalType === "user" && auth.authenticator === "linq-message" + ? { type: "user", id: auth.principalId, issuer: auth.issuer } + : { type: "app" }; ownerOf(principal); return principal; } export async function safeOnePassword(operation: () => Promise): Promise { - try { return await operation(); } - catch (error) { - if (error instanceof Error && ["ConnectionAuthorizationRequiredError", "ConnectionAuthorizationFailedError"].includes(error.name)) throw error; + try { + return await operation(); + } catch (error) { + if ( + error instanceof Error && + ["ConnectionAuthorizationRequiredError", "ConnectionAuthorizationFailedError"].includes( + error.name, + ) + ) + throw error; return failed("request_failed"); } } @@ -20,8 +30,10 @@ export function createOnePasswordAuth(service = getOnePassword) { return defineInteractiveAuthorization({ displayName: "1Password", getToken: ({ principal }) => safeOnePassword(() => service().getToken(principal)), - startAuthorization: ({ principal, callbackUrl }) => safeOnePassword(() => service().start(principal, callbackUrl)), - completeAuthorization: ({ principal, callbackUrl, resume, callback }) => safeOnePassword(() => service().complete(principal, callbackUrl, resume, callback.params)), + startAuthorization: ({ principal, callbackUrl }) => + safeOnePassword(() => service().start(principal, callbackUrl)), + completeAuthorization: ({ principal, callbackUrl, resume, callback }) => + safeOnePassword(() => service().complete(principal, callbackUrl, resume, callback.params)), }); } export const onePasswordAuth = createOnePasswordAuth(); diff --git a/agent/tools/link_disconnect.ts b/agent/tools/link_disconnect.ts index d63f778..e653eff 100644 --- a/agent/tools/link_disconnect.ts +++ b/agent/tools/link_disconnect.ts @@ -6,11 +6,15 @@ import { linkPrincipal } from "../lib/link-auth"; import { isLinkSandbox } from "../../server/link/sandbox"; export default defineTool({ - description: "Disconnect this Linq sender's Link wallet and revoke its OAuth grant. If revocation fails, access stays blocked; retry this tool to finish disconnecting. This does not cancel merchant orders or subscriptions.", + description: + "Disconnect this Linq sender's Link wallet and revoke its OAuth grant. If revocation fails, access stays blocked; retry this tool to finish disconnecting. This does not cancel merchant orders or subscriptions.", inputSchema: z.object({}), approval: always(), async execute(_input, ctx) { - if (isLinkSandbox()) throw new Error("The sandbox token is managed by its operator. Remove it from the running process and revoke it in Link; Bell has no saved wallet connection to delete."); + if (isLinkSandbox()) + throw new Error( + "The sandbox token is managed by its operator. Remove it from the running process and revoke it in Link; Bell has no saved wallet connection to delete.", + ); return safeLinkCall(() => { return getLinkOAuth().disconnect(linkPrincipal(ctx)); }); diff --git a/agent/tools/onepassword_connect.ts b/agent/tools/onepassword_connect.ts index 898f45b..ee09f95 100644 --- a/agent/tools/onepassword_connect.ts +++ b/agent/tools/onepassword_connect.ts @@ -3,7 +3,8 @@ import { z } from "zod"; import { onePasswordAuth, onePasswordPrincipal } from "../lib/onepassword-auth"; import { requirePersonalConnections } from "../../server/onepassword/config"; export default defineTool({ - description: "Connect this authenticated private iMessage sender's personal 1Password account. The private authorization link is delivered by the channel. Connecting alone does not approve any login.", + description: + "Connect this authenticated private iMessage sender's personal 1Password account. The private authorization link is delivered by the channel. Connecting alone does not approve any login.", inputSchema: z.object({}), async execute(_, ctx) { onePasswordPrincipal(ctx); diff --git a/agent/tools/onepassword_disconnect.ts b/agent/tools/onepassword_disconnect.ts index aae4f3e..9d97c78 100644 --- a/agent/tools/onepassword_disconnect.ts +++ b/agent/tools/onepassword_disconnect.ts @@ -4,7 +4,10 @@ import { z } from "zod"; import { getOnePassword } from "../../server/onepassword/oauth"; import { onePasswordPrincipal, safeOnePassword } from "../lib/onepassword-auth"; export default defineTool({ - description: "Revoke this sender's entire personal 1Password connection and all login grants. Requires consent. A failed revoke remains blocked and can be retried.", - inputSchema: z.object({}), approval: always(), - execute: (_, ctx) => safeOnePassword(() => getOnePassword().disconnect(onePasswordPrincipal(ctx))), + description: + "Revoke this sender's entire personal 1Password connection and all login grants. Requires consent. A failed revoke remains blocked and can be retried.", + inputSchema: z.object({}), + approval: always(), + execute: (_, ctx) => + safeOnePassword(() => getOnePassword().disconnect(onePasswordPrincipal(ctx))), }); diff --git a/agent/tools/onepassword_login.ts b/agent/tools/onepassword_login.ts index b1000d5..15030df 100644 --- a/agent/tools/onepassword_login.ts +++ b/agent/tools/onepassword_login.ts @@ -3,57 +3,103 @@ import { defineInteractiveAuthorization } from "eve/connections"; import { defineTool } from "eve/tools"; import { z } from "zod"; import { onePasswordAuth, onePasswordPrincipal, safeOnePassword } from "../lib/onepassword-auth"; -import { closeBrowserRuntime, runBrowserOperation, runCancelableBrowserWork, saveBrowserRuntime } from "../lib/browser-runtime"; +import { + closeBrowserRuntime, + runBrowserOperation, + runCancelableBrowserWork, + saveBrowserRuntime, +} from "../lib/browser-runtime"; import { getOnePasswordLogin, loginId, validateLogin } from "../../server/onepassword/login"; import { createAccess, fillAccess } from "../../server/onepassword/extension"; import { failed, requirePersonalConnections } from "../../server/onepassword/config"; export default defineTool({ - description: "Sign in with this private iMessage sender's personal 1Password account. Ask for account connection and website approval through private channel links. Provide the exact HTTPS login URL and a distinct expected success path on the same origin. Only single-step sign-ins are supported. A failed fill closes the browser to protect filled fields. Success verifies the destination, not the user's identity or authorization at the website. Use a new call to retry a denied or expired request.", - inputSchema: z.object({ loginUrl: z.string().url().max(2048), successUrl: z.string().url().max(2048) }), + description: + "Sign in with this private iMessage sender's personal 1Password account. Ask for account connection and website approval through private channel links. Provide the exact HTTPS login URL and a distinct expected success path on the same origin. Only single-step sign-ins are supported. A failed fill closes the browser to protect filled fields. Success verifies the destination, not the user's identity or authorization at the website. Use a new call to retry a denied or expired request.", + inputSchema: z.object({ + loginUrl: z.string().url().max(2048), + successUrl: z.string().url().max(2048), + }), async execute(input, ctx) { - const principal = onePasswordPrincipal(ctx), target = validateLogin(input); + const principal = onePasswordPrincipal(ctx), + target = validateLogin(input); requirePersonalConnections(); - if (!process.env.BROWSERBASE_1PASSWORD_EXTENSION_ID?.trim()) return failed("configure_BROWSERBASE_1PASSWORD_EXTENSION_ID"); + if (!process.env.BROWSERBASE_1PASSWORD_EXTENSION_ID?.trim()) + return failed("configure_BROWSERBASE_1PASSWORD_EXTENSION_ID"); await ctx.getToken(onePasswordAuth, { authKey: "onepassword" }); - const service = getOnePasswordLogin(), id = loginId(ctx.session.id, ctx.callId); + const service = getOnePasswordLogin(), + id = loginId(ctx.session.id, ctx.callId); const authorization = defineInteractiveAuthorization<{ id: string }>({ displayName: "1Password login", getToken: () => safeOnePassword(() => service.getToken(principal, id)), - startAuthorization: ({ callbackUrl }) => safeOnePassword(() => service.start(principal, ctx.session.id, id, target, callbackUrl, async credentials => { - const output = await runCancelableBrowserWork(ctx.session.id, ctx.abortSignal, () => runBrowserOperation(ctx.session.id, browser => browser.withHostedPage(ctx.abortSignal, async (page, connectUrl) => { - await page.goto(target.loginUrl, { waitUntil: "domcontentloaded", timeout: 30_000 }); - if (await page.url() !== target.loginUrl) return failed("login_url_redirected"); - return createAccess(connectUrl, credentials.accessToken, credentials.integrationKey, target.loginUrl); - }))); - if (!output.workbench.browser.sessionId || !("requestId" in output.result)) return failed("browser_not_ready"); - await saveBrowserRuntime(ctx.session.id); - return { ...output.result, browserSessionId: output.workbench.browser.sessionId }; - })), - completeAuthorization: ({ callbackUrl, resume, callback }) => safeOnePassword(() => service.complete(principal, id, callbackUrl, resume, callback.params)), + startAuthorization: ({ callbackUrl }) => + safeOnePassword(() => + service.start(principal, ctx.session.id, id, target, callbackUrl, async (credentials) => { + const output = await runCancelableBrowserWork(ctx.session.id, ctx.abortSignal, () => + runBrowserOperation(ctx.session.id, (browser) => + browser.withHostedPage(ctx.abortSignal, async (page, connectUrl) => { + await page.goto(target.loginUrl, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + if ((await page.url()) !== target.loginUrl) return failed("login_url_redirected"); + return createAccess( + connectUrl, + credentials.accessToken, + credentials.integrationKey, + target.loginUrl, + ); + }), + ), + ); + if (!output.workbench.browser.sessionId || !("requestId" in output.result)) + return failed("browser_not_ready"); + await saveBrowserRuntime(ctx.session.id); + return { ...output.result, browserSessionId: output.workbench.browser.sessionId }; + }), + ), + completeAuthorization: ({ callbackUrl, resume, callback }) => + safeOnePassword(() => + service.complete(principal, id, callbackUrl, resume, callback.params), + ), }); await ctx.getToken(authorization, { authKey: `onepassword-login-${id}` }); - return safeOnePassword(() => service.fill(principal, id, ctx.session.id, async (credentials, login) => { - try { - const output = await runCancelableBrowserWork(ctx.session.id, ctx.abortSignal, () => runBrowserOperation(ctx.session.id, browser => browser.withHostedPage(ctx.abortSignal, async (page, connectUrl, sessionId) => { - if (sessionId !== login.browserSessionId) return failed("browser_session_changed"); - if (await page.url() !== login.loginUrl || !login.reference) return failed("login_page_changed"); - await markSecretFill(true); - try { - await fillAccess(connectUrl, credentials.accessToken, credentials.integrationKey, login.reference, login.loginUrl, login.successUrl); - await markSecretFill(false); - } catch { - await browser.close(); - return failed("login_not_verified_start_new_conversation"); - } - return { filled: true }; - }))); - if (!("filled" in output.result) || !output.result.filled) return failed("browser_not_ready"); - await saveBrowserRuntime(ctx.session.id); - } catch { - await closeBrowserRuntime(ctx.session.id); - return failed("login_not_verified_browser_closed"); - } - })); + return safeOnePassword(() => + service.fill(principal, id, ctx.session.id, async (credentials, login) => { + try { + const output = await runCancelableBrowserWork(ctx.session.id, ctx.abortSignal, () => + runBrowserOperation(ctx.session.id, (browser) => + browser.withHostedPage(ctx.abortSignal, async (page, connectUrl, sessionId) => { + if (sessionId !== login.browserSessionId) return failed("browser_session_changed"); + if ((await page.url()) !== login.loginUrl || !login.reference) + return failed("login_page_changed"); + await markSecretFill(true); + try { + await fillAccess( + connectUrl, + credentials.accessToken, + credentials.integrationKey, + login.reference, + login.loginUrl, + login.successUrl, + ); + await markSecretFill(false); + } catch { + await browser.close(); + return failed("login_not_verified_start_new_conversation"); + } + return { filled: true }; + }), + ), + ); + if (!("filled" in output.result) || !output.result.filled) + return failed("browser_not_ready"); + await saveBrowserRuntime(ctx.session.id); + } catch { + await closeBrowserRuntime(ctx.session.id); + return failed("login_not_verified_browser_closed"); + } + }), + ); }, }); diff --git a/agent/tools/onepassword_status.ts b/agent/tools/onepassword_status.ts index 8f4b4a5..0d01399 100644 --- a/agent/tools/onepassword_status.ts +++ b/agent/tools/onepassword_status.ts @@ -4,7 +4,8 @@ import { getOnePassword } from "../../server/onepassword/oauth"; import { onePasswordPrincipal, safeOnePassword } from "../lib/onepassword-auth"; import { personalConnectionsEnabled } from "../../server/onepassword/config"; export default defineTool({ - description: "Check this authenticated sender's personal 1Password connection without exposing tokens, keys, or vault contents.", + description: + "Check this authenticated sender's personal 1Password connection without exposing tokens, keys, or vault contents.", inputSchema: z.object({}), async execute(_, ctx) { const principal = onePasswordPrincipal(ctx); diff --git a/agent/tools/vault_list_logins.ts b/agent/tools/vault_list_logins.ts index 0e711b4..e5e7496 100644 --- a/agent/tools/vault_list_logins.ts +++ b/agent/tools/vault_list_logins.ts @@ -9,13 +9,24 @@ export default defineTool({ "List safe login metadata and opaque item IDs from configured vaults. No secret values are returned. Use the returned provider and IDs, and choose only an item whose allowedHosts contains the current page host.", inputSchema: z.object({ provider: z.enum(["native", "onepassword"]).optional() }), async execute({ provider }, ctx) { - if (ctx.session.auth?.current?.authenticator === "linq-message") throw new Error("Shared operator vaults are not available over iMessage. Use onepassword_login for personal credentials."); + if (ctx.session.auth?.current?.authenticator === "linq-message") + throw new Error( + "Shared operator vaults are not available over iMessage. Use onepassword_login for personal credentials.", + ); const items = []; if (provider === "native" || (!provider && process.env.BELL_VAULT_MASTER_KEY?.trim())) - items.push(...await nativeVault.list()); - if (provider === "onepassword" || (!provider && - (process.env.OP_SERVICE_ACCOUNT_TOKEN?.trim() || process.env.OP_ACCOUNT?.trim()))) - items.push(...(await onePasswordVault.list()).map((item) => ({ ...item, provider: "onepassword" as const }))); + items.push(...(await nativeVault.list())); + if ( + provider === "onepassword" || + (!provider && + (process.env.OP_SERVICE_ACCOUNT_TOKEN?.trim() || process.env.OP_ACCOUNT?.trim())) + ) + items.push( + ...(await onePasswordVault.list()).map((item) => ({ + ...item, + provider: "onepassword" as const, + })), + ); return { items }; }, toModelOutput(output) { diff --git a/agent/tools/vault_login.ts b/agent/tools/vault_login.ts index 3a95ca0..f229b65 100644 --- a/agent/tools/vault_login.ts +++ b/agent/tools/vault_login.ts @@ -23,7 +23,10 @@ export default defineTool({ totpFlow: z.enum(["same_form", "after_submit"]).default("same_form"), }), async execute(input, ctx) { - if (ctx.session.auth?.current?.authenticator === "linq-message") throw new Error("Shared operator vaults are not available over iMessage. Use onepassword_login for personal credentials."); + if (ctx.session.auth?.current?.authenticator === "linq-message") + throw new Error( + "Shared operator vaults are not available over iMessage. Use onepassword_login for personal credentials.", + ); const readLogin = () => { if (input.provider === "native") return nativeVault.login(input.itemId); if (!input.vaultId) throw new Error("A vault ID is required for a 1Password login."); diff --git a/agent/tools/watch_create.ts b/agent/tools/watch_create.ts index 9178634..13247e4 100644 --- a/agent/tools/watch_create.ts +++ b/agent/tools/watch_create.ts @@ -5,12 +5,20 @@ import { watchIdentity, safeWatch, watchDestination } from "../lib/monitors"; import { queryInput, frequencyInput } from "../../server/monitors/config"; export default defineTool({ - description: "Create a public-web watch with Parallel lite. Frequency defaults to daily; whole hours/days/weeks from 1h to 30d are supported. Faster checks increase scheduled usage. Only create when the user asks for ongoing monitoring. Never include secrets or private browser content. Watches continue until stopped; list existing watches before retrying an uncertain creation.", + description: + "Create a public-web watch with Parallel lite. Frequency defaults to daily; whole hours/days/weeks from 1h to 30d are supported. Faster checks increase scheduled usage. Only create when the user asks for ongoing monitoring. Never include secrets or private browser content. Watches continue until stopped; list existing watches before retrying an uncertain creation.", inputSchema: z.object({ query: queryInput, frequency: frequencyInput.default("1d") }), - execute: (input, ctx) => safeWatch(() => { - watchIdentity(ctx); - const service = monitors(); - return service.create(watchIdentity(ctx), watchDestination(ctx), JSON.stringify([ctx.session.id, ctx.callId]), input.query, input.frequency); - }), - toModelOutput: output => toolOutput.json(output), + execute: (input, ctx) => + safeWatch(() => { + watchIdentity(ctx); + const service = monitors(); + return service.create( + watchIdentity(ctx), + watchDestination(ctx), + JSON.stringify([ctx.session.id, ctx.callId]), + input.query, + input.frequency, + ); + }), + toModelOutput: (output) => toolOutput.json(output), }); diff --git a/agent/tools/watch_list.ts b/agent/tools/watch_list.ts index 573271d..581f616 100644 --- a/agent/tools/watch_list.ts +++ b/agent/tools/watch_list.ts @@ -4,11 +4,26 @@ import { monitors } from "../../server/monitors/service"; import { watchIdentity, safeWatch } from "../lib/monitors"; export default defineTool({ - description: "List this sender's watches, recent updates, and pending failures. Use this to reconcile uncertain creation before making another watch, or to find the ID of a watch to update or stop. Returns one page of 50 watches, open ones (still running or still needing repair) before cancelled and failed ones, newest first within each. When next_cursor is not null there are older watches: call again with that value as cursor to read the next page.", + description: + "List this sender's watches, recent updates, and pending failures. Use this to reconcile uncertain creation before making another watch, or to find the ID of a watch to update or stop. Returns one page of 50 watches, open ones (still running or still needing repair) before cancelled and failed ones, newest first within each. When next_cursor is not null there are older watches: call again with that value as cursor to read the next page.", inputSchema: z.object({ - status: z.enum(["all", "open", "closed"]).default("all").describe("Which watches to return: all, only open ones (not cancelled or failed), or only closed ones."), - cursor: z.string().max(512).optional().describe("The next_cursor from a previous watch_list call, to read the next page of older watches."), + status: z + .enum(["all", "open", "closed"]) + .default("all") + .describe( + "Which watches to return: all, only open ones (not cancelled or failed), or only closed ones.", + ), + cursor: z + .string() + .max(512) + .optional() + .describe( + "The next_cursor from a previous watch_list call, to read the next page of older watches.", + ), }), - execute: (input, ctx) => safeWatch(() => monitors().list(watchIdentity(ctx), { status: input.status, cursor: input.cursor })), - toModelOutput: output => toolOutput.json(output), + execute: (input, ctx) => + safeWatch(() => + monitors().list(watchIdentity(ctx), { status: input.status, cursor: input.cursor }), + ), + toModelOutput: (output) => toolOutput.json(output), }); diff --git a/agent/tools/watch_stop.ts b/agent/tools/watch_stop.ts index b282e1a..109c439 100644 --- a/agent/tools/watch_stop.ts +++ b/agent/tools/watch_stop.ts @@ -4,12 +4,14 @@ import { monitors } from "../../server/monitors/service"; import { watchIdentity, safeWatch } from "../lib/monitors"; export default defineTool({ - description: "Permanently stop one of this sender's watches. Local alerts stop immediately; cancel_pending means upstream cancellation is still pending and billing may continue.", + description: + "Permanently stop one of this sender's watches. Local alerts stop immediately; cancel_pending means upstream cancellation is still pending and billing may continue.", inputSchema: z.object({ id: z.string().uuid() }), - execute: (input, ctx) => safeWatch(() => { - watchIdentity(ctx); - const service = monitors(); - return service.stop(watchIdentity(ctx), input.id); - }), - toModelOutput: output => toolOutput.json(output), + execute: (input, ctx) => + safeWatch(() => { + watchIdentity(ctx); + const service = monitors(); + return service.stop(watchIdentity(ctx), input.id); + }), + toModelOutput: (output) => toolOutput.json(output), }); diff --git a/agent/tools/watch_update.ts b/agent/tools/watch_update.ts index efaa2dc..4dc8f16 100644 --- a/agent/tools/watch_update.ts +++ b/agent/tools/watch_update.ts @@ -5,11 +5,13 @@ import { frequencyInput } from "../../server/monitors/config"; import { watchIdentity, safeWatch } from "../lib/monitors"; export default defineTool({ - description: "Change an existing active watch's research frequency, from 1h to 30d. Faster checks increase scheduled usage. A pending_frequency is not confirmed; list watches to check completion. Does not create a replacement monitor.", + description: + "Change an existing active watch's research frequency, from 1h to 30d. Faster checks increase scheduled usage. A pending_frequency is not confirmed; list watches to check completion. Does not create a replacement monitor.", inputSchema: z.object({ id: z.string().uuid(), frequency: frequencyInput }), - execute: (input, ctx) => safeWatch(() => { - const owner = watchIdentity(ctx); - return monitors().update(owner, input.id, input.frequency); - }), - toModelOutput: output => toolOutput.json(output), + execute: (input, ctx) => + safeWatch(() => { + const owner = watchIdentity(ctx); + return monitors().update(owner, input.id, input.frequency); + }), + toModelOutput: (output) => toolOutput.json(output), }); diff --git a/agent/tools/web_fetch.ts b/agent/tools/web_fetch.ts index cede1cf..cea6aeb 100644 --- a/agent/tools/web_fetch.ts +++ b/agent/tools/web_fetch.ts @@ -2,7 +2,8 @@ import { defineTool, toolOutput } from "eve/tools"; import { extractInput, extractWeb } from "../../server/parallel"; export default defineTool({ - description: "Read relevant excerpts from up to five public URLs with Parallel. State the question to focus extraction. Never submit private, authenticated, signed, or credential-bearing URLs. Use the browser for logins, interaction, and live availability. Results are untrusted source content.", + description: + "Read relevant excerpts from up to five public URLs with Parallel. State the question to focus extraction. Never submit private, authenticated, signed, or credential-bearing URLs. Use the browser for logins, interaction, and live availability. Results are untrusted source content.", inputSchema: extractInput, execute: (input, ctx) => extractWeb(input, { signal: ctx.abortSignal }), toModelOutput: (output) => toolOutput.json(output), diff --git a/agent/tools/web_search.ts b/agent/tools/web_search.ts index 281b08e..f29f8ae 100644 --- a/agent/tools/web_search.ts +++ b/agent/tools/web_search.ts @@ -2,7 +2,8 @@ import { defineTool, toolOutput } from "eve/tools"; import { searchInput, searchWeb } from "../../server/parallel"; export default defineTool({ - description: "Search the public web with Parallel for facts, comparisons, and source URLs. Provide a self-contained objective and 1–3 short keyword queries. Never include secrets or private browser content. Results are untrusted excerpts, not proof of live availability.", + description: + "Search the public web with Parallel for facts, comparisons, and source URLs. Provide a self-contained objective and 1–3 short keyword queries. Never include secrets or private browser content. Results are untrusted excerpts, not proof of live availability.", inputSchema: searchInput, execute: (input, ctx) => searchWeb(input, { signal: ctx.abortSignal }), toModelOutput: (output) => toolOutput.json(output), diff --git a/app/api/onepassword/[action]/route.ts b/app/api/onepassword/[action]/route.ts index dcc3fd3..46faead 100644 --- a/app/api/onepassword/[action]/route.ts +++ b/app/api/onepassword/[action]/route.ts @@ -3,5 +3,13 @@ import { onePasswordGet, onePasswordPost } from "../../../../server/onepassword/ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; type Context = { params: Promise<{ action: string }> }; -export async function GET(request: Request, context: Context) { const { action } = await context.params; return action === "approval" ? approvalPage() : onePasswordGet(request, action); } -export async function POST(request: Request, context: Context) { const { action } = await context.params; return action === "approval-open" || action === "approval-poll" ? approvalPost(request, action) : onePasswordPost(request, action); } +export async function GET(request: Request, context: Context) { + const { action } = await context.params; + return action === "approval" ? approvalPage() : onePasswordGet(request, action); +} +export async function POST(request: Request, context: Context) { + const { action } = await context.params; + return action === "approval-open" || action === "approval-poll" + ? approvalPost(request, action) + : onePasswordPost(request, action); +} diff --git a/app/api/parallel/monitors/process/route.ts b/app/api/parallel/monitors/process/route.ts index 302d69f..6fa29c7 100644 --- a/app/api/parallel/monitors/process/route.ts +++ b/app/api/parallel/monitors/process/route.ts @@ -3,5 +3,9 @@ import { processMonitors } from "../../../../../server/monitors/http"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 60; -export function GET(request: Request) { return processMonitors(request); } -export function POST(request: Request) { return processMonitors(request); } +export function GET(request: Request) { + return processMonitors(request); +} +export function POST(request: Request) { + return processMonitors(request); +} diff --git a/app/api/parallel/monitors/webhook/route.ts b/app/api/parallel/monitors/webhook/route.ts index b9f87e3..3f4c723 100644 --- a/app/api/parallel/monitors/webhook/route.ts +++ b/app/api/parallel/monitors/webhook/route.ts @@ -3,4 +3,6 @@ import { receiveWebhook } from "../../../../../server/monitors/http"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export const maxDuration = 60; -export function POST(request: Request) { return receiveWebhook(request); } +export function POST(request: Request) { + return receiveWebhook(request); +} diff --git a/app/chat.tsx b/app/chat.tsx index 9f1869e..f2f6808 100644 --- a/app/chat.tsx +++ b/app/chat.tsx @@ -64,11 +64,7 @@ export default function Chat({ sessionId, demoUrl }: { sessionId?: string; demoU Bell. - diff --git a/app/handoff/[token]/live/route.ts b/app/handoff/[token]/live/route.ts index f5749f9..b5d2dd2 100644 --- a/app/handoff/[token]/live/route.ts +++ b/app/handoff/[token]/live/route.ts @@ -1,4 +1,7 @@ -import { BrowserHandoffError, resolveBrowserHandoffLiveViewUrl } from "../../../../server/browser/handoff"; +import { + BrowserHandoffError, + resolveBrowserHandoffLiveViewUrl, +} from "../../../../server/browser/handoff"; export const runtime = "nodejs"; diff --git a/eslint.config.mjs b/eslint.config.mjs index 508e31b..99578fe 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,15 @@ import typescript from "eslint-config-next/typescript"; const config = [ ...next, ...typescript, - { ignores: ["**/.next/**", "**/.eve/**", "**/.output/**", "**/next-env.d.ts", "**/dist/**", "**/node_modules/**"] }, + { + ignores: [ + "**/.next/**", + "**/.eve/**", + "**/.output/**", + "**/next-env.d.ts", + "**/dist/**", + "**/node_modules/**", + ], + }, ]; export default config; diff --git a/next.config.ts b/next.config.ts index 9182661..f5e637b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,12 @@ import type { NextConfig } from "next"; const config: NextConfig = { agentRules: false, allowedDevOrigins: ["127.0.0.1", "localhost"], - serverExternalPackages: ["@browserbasehq/sdk", "@browserbasehq/stagehand", "playwright-core", "pg"], + serverExternalPackages: [ + "@browserbasehq/sdk", + "@browserbasehq/stagehand", + "playwright-core", + "pg", + ], turbopack: { root: process.cwd() }, }; export default withEve(config, { eveBuildCommand: "pnpm build:agent" }); diff --git a/package.json b/package.json index fafb213..d46da24 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "repro:stagehand-extensions": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/repro-stagehand-extensions.mjs", "typecheck": "tsc --noEmit", "lint": "eslint . --max-warnings=0", - "check": "pnpm test && pnpm typecheck && pnpm lint && pnpm build", + "format": "prettier --write .", + "format:check": "prettier --check .", + "check": "pnpm test && pnpm typecheck && pnpm lint && pnpm format:check && pnpm build", "baselayer:mint": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/mint-baselayer-credential.mjs", "test:retirement:live": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/test-session-retirement.mjs" }, @@ -45,6 +47,7 @@ "@types/react-dom": "19.3.0", "eslint": "9.39.5", "eslint-config-next": "16.4.3", + "prettier": "3.9.10", "typescript": "6.0.3", "vitest": "4.1.12" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a010a4d..6f231ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: eslint-config-next: specifier: 16.4.3 version: 16.4.3(@typescript-eslint/parser@8.71.1(eslint@9.39.5)(typescript@6.0.3))(eslint@9.39.5)(typescript@6.0.3) + prettier: + specifier: 3.9.10 + version: 3.9.10 typescript: specifier: 6.0.3 version: 6.0.3 @@ -2291,6 +2294,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} + engines: {node: '>=14'} + hasBin: true + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -4931,6 +4939,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.9.10: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 diff --git a/scripts/browser-test-auth.ts b/scripts/browser-test-auth.ts index e03cd05..c1d6816 100644 --- a/scripts/browser-test-auth.ts +++ b/scripts/browser-test-auth.ts @@ -1,13 +1,30 @@ import type { BrowserContext, Page } from "playwright-core"; -export async function restrictBrowserRequests(context: BrowserContext, page: Page, origin: string, headers: Record, onError: (error: unknown) => void) { +export async function restrictBrowserRequests( + context: BrowserContext, + page: Page, + origin: string, + headers: Record, + onError: (error: unknown) => void, +) { const cdp = await context.newCDPSession(page); cdp.on("Fetch.requestPaused", ({ requestId, request }) => { const allowed = new URL(request.url).origin === origin; // CDP pauses each redirect; Playwright route header overrides persist across redirects. - void cdp.send(allowed ? "Fetch.continueRequest" : "Fetch.failRequest", allowed - ? { requestId, headers: Object.entries({ ...request.headers, ...headers }).map(([name, value]) => ({ name, value: String(value) })) } - : { requestId, errorReason: "BlockedByClient" }).catch(onError); + void cdp + .send( + allowed ? "Fetch.continueRequest" : "Fetch.failRequest", + allowed + ? { + requestId, + headers: Object.entries({ ...request.headers, ...headers }).map(([name, value]) => ({ + name, + value: String(value), + })), + } + : { requestId, errorReason: "BlockedByClient" }, + ) + .catch(onError); }); await cdp.send("Fetch.enable", { patterns: [{ urlPattern: "*" }] }); } diff --git a/scripts/package-browser-assets.mjs b/scripts/package-browser-assets.mjs index f06dd39..4d934c1 100644 --- a/scripts/package-browser-assets.mjs +++ b/scripts/package-browser-assets.mjs @@ -2,13 +2,19 @@ import { copyFile, glob, mkdir } from "node:fs/promises"; import { join } from "node:path"; // Eve's dependency tracing omits the archive Stagehand uploads when creating a hosted browser. -const source = new URL("./assets/stagehand-extension.zip", import.meta.resolve("@browserbasehq/stagehand")); +const source = new URL( + "./assets/stagehand-extension.zip", + import.meta.resolve("@browserbasehq/stagehand"), +); const output = process.env.VERCEL ? ".vercel/output" : ".output"; let copied = 0; -for await (const directory of glob("**/node_modules/@browserbasehq/stagehand/dist", { cwd: output })) { +for await (const directory of glob("**/node_modules/@browserbasehq/stagehand/dist", { + cwd: output, +})) { const destination = join(output, directory, "assets"); await mkdir(destination, { recursive: true }); await copyFile(source, join(destination, "stagehand-extension.zip")); copied++; } -if (!copied) throw new Error("The built Eve server is missing Stagehand; browser assets were not packaged."); +if (!copied) + throw new Error("The built Eve server is missing Stagehand; browser assets were not packaged."); diff --git a/scripts/repro-stagehand-extensions.mjs b/scripts/repro-stagehand-extensions.mjs index ea7c29c..abe03e0 100644 --- a/scripts/repro-stagehand-extensions.mjs +++ b/scripts/repro-stagehand-extensions.mjs @@ -13,49 +13,103 @@ if (!apiKey) throw new Error("Set BROWSERBASE_API_KEY to a test project's key.") delete process.env.DEBUG; const sdk = new Browserbase({ apiKey, maxRetries: 0, timeout: 20_000 }); const projectId = process.env.BROWSERBASE_PROJECT_ID?.trim() || undefined; -const runId = randomUUID(), results = [], cleanupErrors = []; +const runId = randomUUID(), + results = [], + cleanupErrors = []; const directory = await mkdtemp(join(tmpdir(), "bell-extension-repro-")); const browserSettings = { recordSession: false, logSession: false, verified: true }; const userMetadata = { purpose: "bell-extension-repro", runId }; let extension; -const sanitized = error => String(error?.message ?? "Unknown failure").replaceAll(apiKey, "[redacted]").replace(/https?:\/\/\S+/g, "[url]"); +const sanitized = (error) => + String(error?.message ?? "Unknown failure") + .replaceAll(apiKey, "[redacted]") + .replace(/https?:\/\/\S+/g, "[url]"); async function check(name, operation) { - try { await operation(); results.push({ name, passed: true }); } - catch (error) { results.push({ name, passed: false, error: sanitized(error) }); } + try { + await operation(); + results.push({ name, passed: true }); + } catch (error) { + results.push({ name, passed: false, error: sanitized(error) }); + } } async function stagehandCheck(extensionId) { let browser, stagehand; try { - browser = await browserbase.launch({ apiKey, projectId, ...(extensionId ? { extensionId } : {}), timeout: 60, keepAlive: false, browserSettings, userMetadata }); + browser = await browserbase.launch({ + apiKey, + projectId, + ...(extensionId ? { extensionId } : {}), + timeout: 60, + keepAlive: false, + browserSettings, + userMetadata, + }); stagehand = await Stagehand.create({ browser, logging: { level: "off" } }); const page = await browser.context.newPage(); await page.goto("data:text/html,Extension repro"); - if (await page.title() !== "Extension repro") throw new Error("Stagehand page operation failed."); + if ((await page.title()) !== "Extension repro") + throw new Error("Stagehand page operation failed."); } finally { - try { await stagehand?.close(); } finally { await browser?.close(); } + try { + await stagehand?.close(); + } finally { + await browser?.close(); + } } } try { - await writeFile(join(directory, "manifest.json"), JSON.stringify({ manifest_version: 3, name: "Bell extension repro", version: "1.0", background: { service_worker: "worker.js" } })); - await writeFile(join(directory, "worker.js"), "chrome.runtime.onInstalled.addListener(() => {});\n"); - execFileSync("zip", ["-q", "extension.zip", "manifest.json", "worker.js"], { cwd: directory, stdio: "pipe" }); - extension = await sdk.extensions.create({ file: createReadStream(join(directory, "extension.zip")) }); + await writeFile( + join(directory, "manifest.json"), + JSON.stringify({ + manifest_version: 3, + name: "Bell extension repro", + version: "1.0", + background: { service_worker: "worker.js" }, + }), + ); + await writeFile( + join(directory, "worker.js"), + "chrome.runtime.onInstalled.addListener(() => {});\n", + ); + execFileSync("zip", ["-q", "extension.zip", "manifest.json", "worker.js"], { + cwd: directory, + stdio: "pipe", + }); + extension = await sdk.extensions.create({ + file: createReadStream(join(directory, "extension.zip")), + }); await check("stagehand_only", () => stagehandCheck()); await check("custom_extension_only", async () => { - const session = await sdk.sessions.create({ projectId, extensionId: extension.id, api_timeout: 60, browserSettings, userMetadata }); + const session = await sdk.sessions.create({ + projectId, + extensionId: extension.id, + api_timeout: 60, + browserSettings, + userMetadata, + }); let browser; try { browser = await chromium.connectOverCDP(session.connectUrl, { timeout: 15_000 }); const context = browser.contexts()[0]; const deadline = Date.now() + 15_000; while (true) { - const names = await Promise.all(context.serviceWorkers().map(worker => worker.evaluate(() => chrome.runtime.getManifest().name).catch(() => ""))); + const names = await Promise.all( + context + .serviceWorkers() + .map((worker) => + worker.evaluate(() => chrome.runtime.getManifest().name).catch(() => ""), + ), + ); if (names.includes("Bell extension repro")) break; if (Date.now() >= deadline) throw new Error("Custom extension did not load."); - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); } } finally { - try { await browser?.close(); } finally { await sdk.sessions.update(session.id, { projectId, status: "REQUEST_RELEASE" }); } + try { + await browser?.close(); + } finally { + await sdk.sessions.update(session.id, { projectId, status: "REQUEST_RELEASE" }); + } } }); await check("stagehand_with_custom_extension", () => stagehandCheck(extension.id)); @@ -64,17 +118,39 @@ try { } finally { try { const sessions = await sdk.sessions.list(); - for (const session of sessions.filter(session => session.userMetadata?.runId === runId && ["PENDING", "RUNNING"].includes(session.status))) { - try { await sdk.sessions.update(session.id, { projectId, status: "REQUEST_RELEASE" }); } - catch { cleanupErrors.push("session_release_failed"); } + for (const session of sessions.filter( + (session) => + session.userMetadata?.runId === runId && ["PENDING", "RUNNING"].includes(session.status), + )) { + try { + await sdk.sessions.update(session.id, { projectId, status: "REQUEST_RELEASE" }); + } catch { + cleanupErrors.push("session_release_failed"); + } } - } catch { cleanupErrors.push("session_cleanup_check_failed"); } + } catch { + cleanupErrors.push("session_cleanup_check_failed"); + } if (extension) { - try { await sdk.extensions.delete(extension.id, { headers: { "Content-Type": null } }); } - catch { cleanupErrors.push("extension_delete_failed"); } + try { + await sdk.extensions.delete(extension.id, { headers: { "Content-Type": null } }); + } catch { + cleanupErrors.push("extension_delete_failed"); + } } await rm(directory, { recursive: true, force: true }); } const manifest = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); -console.log(JSON.stringify({ stagehandDependency: manifest.dependencies["@browserbasehq/stagehand"], runId, results, cleanupErrors }, null, 2)); -if (results.some(result => !result.passed) || cleanupErrors.length) process.exitCode = 1; +console.log( + JSON.stringify( + { + stagehandDependency: manifest.dependencies["@browserbasehq/stagehand"], + runId, + results, + cleanupErrors, + }, + null, + 2, + ), +); +if (results.some((result) => !result.passed) || cleanupErrors.length) process.exitCode = 1; diff --git a/scripts/test-parallel-production.mjs b/scripts/test-parallel-production.mjs index e4d6a06..21485c5 100644 --- a/scripts/test-parallel-production.mjs +++ b/scripts/test-parallel-production.mjs @@ -6,28 +6,56 @@ import { mkdtemp, open, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -assert.equal(process.env.BELL_LIVE_PARALLEL, "1", "Set BELL_LIVE_PARALLEL=1 to run this live production-build test."); -for (const name of ["OPENAI_API_KEY", "PARALLEL_API_KEY", "BROWSERBASE_API_KEY"]) assert.ok(process.env[name], `${name} is required`); +assert.equal( + process.env.BELL_LIVE_PARALLEL, + "1", + "Set BELL_LIVE_PARALLEL=1 to run this live production-build test.", +); +for (const name of ["OPENAI_API_KEY", "PARALLEL_API_KEY", "BROWSERBASE_API_KEY"]) + assert.ok(process.env[name], `${name} is required`); const directory = await mkdtemp(path.join(tmpdir(), "bell-production-test-")); const socket = createServer(); -await new Promise(resolve => socket.listen(0, "127.0.0.1", resolve)); +await new Promise((resolve) => socket.listen(0, "127.0.0.1", resolve)); const port = socket.address().port; -await new Promise(resolve => socket.close(resolve)); +await new Promise((resolve) => socket.close(resolve)); const origin = `http://127.0.0.1:${port}`; -const env = { ...process.env, NODE_ENV: "production", BELL_OPERATOR_USERNAME: "regression", BELL_OPERATOR_PASSWORD: randomBytes(32).toString("hex"), BELL_TEST_REQUIRE_AUTH: "1", BELL_TEST_ORIGIN: origin, BELL_CONTEXT_METADATA_PATH: path.join(directory, "contexts.json"), BROWSERBASE_CONTEXT_ID: "", ONEPASSWORD_ENABLED: "false", STAGEHAND_BROWSER: "browserbase" }; +const env = { + ...process.env, + NODE_ENV: "production", + BELL_OPERATOR_USERNAME: "regression", + BELL_OPERATOR_PASSWORD: randomBytes(32).toString("hex"), + BELL_TEST_REQUIRE_AUTH: "1", + BELL_TEST_ORIGIN: origin, + BELL_CONTEXT_METADATA_PATH: path.join(directory, "contexts.json"), + BROWSERBASE_CONTEXT_ID: "", + ONEPASSWORD_ENABLED: "false", + STAGEHAND_BROWSER: "browserbase", +}; delete env.BELL_TEST_PROTECTION_BYPASS; const routes = JSON.parse(await readFile(".next/routes-manifest.json", "utf8")); -const agentOrigin = new URL(routes.rewrites.beforeFiles.find(route => route.source === "/eve/v1/:path+").destination); -assert.ok(agentOrigin.protocol === "http:" && agentOrigin.hostname === "127.0.0.1" && agentOrigin.port, "Production test requires a build with a loopback Eve rewrite."); +const agentOrigin = new URL( + routes.rewrites.beforeFiles.find((route) => route.source === "/eve/v1/:path+").destination, +); +assert.ok( + agentOrigin.protocol === "http:" && agentOrigin.hostname === "127.0.0.1" && agentOrigin.port, + "Production test requires a build with a loopback Eve rewrite.", +); const reservation = createServer(); -await new Promise((resolve, reject) => { reservation.once("error", reject); reservation.listen(Number(agentOrigin.port), "127.0.0.1", resolve); }); -await new Promise(resolve => reservation.close(resolve)); +await new Promise((resolve, reject) => { + reservation.once("error", reject); + reservation.listen(Number(agentOrigin.port), "127.0.0.1", resolve); +}); +await new Promise((resolve) => reservation.close(resolve)); const log = await open(path.join(directory, "server.log"), "w", 0o600); const eve = JSON.parse(await readFile("node_modules/eve/package.json", "utf8")).bin.eve; const children = []; function launch(args) { - const child = spawn(process.execPath, args, { env, detached: true, stdio: ["ignore", log.fd, log.fd] }); - const stopped = new Promise(resolve => child.once("exit", resolve)); + const child = spawn(process.execPath, args, { + env, + detached: true, + stdio: ["ignore", log.fd, log.fd], + }); + const stopped = new Promise((resolve) => child.once("exit", resolve)); children.push({ child, stopped }); return child; } @@ -35,24 +63,58 @@ async function ready(child, url) { const deadline = Date.now() + 60_000; while (true) { assert.equal(child.exitCode, null, "Production service exited during startup"); - try { if ((await fetch(url, { signal: AbortSignal.timeout(2_000) })).ok) return; } catch { /* Wait for the owned service to bind. */ } + try { + if ((await fetch(url, { signal: AbortSignal.timeout(2_000) })).ok) return; + } catch { + /* Wait for the owned service to bind. */ + } assert.ok(Date.now() < deadline, "Production service did not become ready"); - await new Promise(resolve => setTimeout(resolve, 250)); + await new Promise((resolve) => setTimeout(resolve, 250)); } } try { - const agent = launch([path.join("node_modules/eve", eve), "start", "--host", "127.0.0.1", "--port", agentOrigin.port]); + const agent = launch([ + path.join("node_modules/eve", eve), + "start", + "--host", + "127.0.0.1", + "--port", + agentOrigin.port, + ]); await ready(agent, `${agentOrigin.origin}/eve/v1/health`); - const server = launch(["node_modules/next/dist/bin/next", "start", "--hostname", "127.0.0.1", "--port", String(port)]); + const server = launch([ + "node_modules/next/dist/bin/next", + "start", + "--hostname", + "127.0.0.1", + "--port", + String(port), + ]); await ready(server, origin); - const test = spawn(process.execPath, ["scripts/test-parallel-research.mjs"], { env, stdio: "inherit" }); - const code = await new Promise((resolve, reject) => { test.once("error", reject); test.once("exit", resolve); }); + const test = spawn(process.execPath, ["scripts/test-parallel-research.mjs"], { + env, + stdio: "inherit", + }); + const code = await new Promise((resolve, reject) => { + test.once("error", reject); + test.once("exit", resolve); + }); assert.equal(code, 0, "Production-build browser E2E failed"); - console.log("Production-build authentication and browser E2E passed on loopback; this does not certify a hosted staging deployment."); + console.log( + "Production-build authentication and browser E2E passed on loopback; this does not certify a hosted staging deployment.", + ); } finally { for (const { child, stopped } of children.reverse()) { - try { process.kill(-child.pid, "SIGTERM"); } catch (error) { if (error.code !== "ESRCH") throw error; } - const timeout = setTimeout(() => { try { process.kill(-child.pid, "SIGKILL"); } catch {} }, 10_000); + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + const timeout = setTimeout(() => { + try { + process.kill(-child.pid, "SIGKILL"); + } catch {} + }, 10_000); await stopped; clearTimeout(timeout); } diff --git a/scripts/test-parallel-research.mjs b/scripts/test-parallel-research.mjs index 77d1d08..0602e86 100644 --- a/scripts/test-parallel-research.mjs +++ b/scripts/test-parallel-research.mjs @@ -11,80 +11,152 @@ if (process.env.BELL_LIVE_PARALLEL !== "1" || !process.env.BROWSERBASE_API_KEY) throw new Error("Set BELL_LIVE_PARALLEL=1 and BROWSERBASE_API_KEY for this live test."); const origin = new URL(process.env.BELL_TEST_ORIGIN || "http://127.0.0.1:4320"); const local = origin.protocol === "http:" && ["127.0.0.1", "localhost"].includes(origin.hostname); -assert.ok(!origin.username && !origin.password && origin.pathname === "/" && !origin.search && !origin.hash, "Use a bare test origin without credentials."); -assert.ok(local || (origin.protocol === "https:" && origin.origin === process.env.BELL_TEST_STAGING_ORIGIN), "Remote tests require an exact HTTPS BELL_TEST_STAGING_ORIGIN."); -const username = process.env.BELL_OPERATOR_USERNAME, password = process.env.BELL_OPERATOR_PASSWORD; +assert.ok( + !origin.username && !origin.password && origin.pathname === "/" && !origin.search && !origin.hash, + "Use a bare test origin without credentials.", +); +assert.ok( + local || (origin.protocol === "https:" && origin.origin === process.env.BELL_TEST_STAGING_ORIGIN), + "Remote tests require an exact HTTPS BELL_TEST_STAGING_ORIGIN.", +); +const username = process.env.BELL_OPERATOR_USERNAME, + password = process.env.BELL_OPERATOR_PASSWORD; const requireAuth = !local || process.env.BELL_TEST_REQUIRE_AUTH === "1"; -assert.ok(!requireAuth || (username && !username.includes(":") && password && Buffer.byteLength(password) >= 32), "Authenticated tests require valid BELL_OPERATOR_USERNAME and BELL_OPERATOR_PASSWORD."); -const protection = process.env.BELL_TEST_PROTECTION_BYPASS ? { "x-vercel-protection-bypass": process.env.BELL_TEST_PROTECTION_BYPASS } : {}; -const headers = { ...protection, ...(username && password ? { authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } : {}) }; -const secrets = Object.entries({ ...process.env, BELL_TEST_AUTHORIZATION: headers.authorization }) - .filter(([key, value]) => /(?:KEY|TOKEN|SECRET|PASSWORD|BYPASS|AUTHORIZATION)$/.test(key) && value); +assert.ok( + !requireAuth || + (username && !username.includes(":") && password && Buffer.byteLength(password) >= 32), + "Authenticated tests require valid BELL_OPERATOR_USERNAME and BELL_OPERATOR_PASSWORD.", +); +const protection = process.env.BELL_TEST_PROTECTION_BYPASS + ? { "x-vercel-protection-bypass": process.env.BELL_TEST_PROTECTION_BYPASS } + : {}; +const headers = { + ...protection, + ...(username && password + ? { authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` } + : {}), +}; +const secrets = Object.entries({ + ...process.env, + BELL_TEST_AUTHORIZATION: headers.authorization, +}).filter( + ([key, value]) => /(?:KEY|TOKEN|SECRET|PASSWORD|BYPASS|AUTHORIZATION)$/.test(key) && value, +); function assertNoSecrets(value) { const text = typeof value === "string" ? value : JSON.stringify(value); - for (const [key, secret] of secrets) assert.ok(!text.includes(secret), `${key} must not appear in conversation data or rendered HTML.`); + for (const [key, secret] of secrets) + assert.ok( + !text.includes(secret), + `${key} must not appear in conversation data or rendered HTML.`, + ); } const client = new Client({ host: origin.origin, headers, redirect: "error" }); -const browserbase = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY, timeout: 10_000, maxRetries: 0 }); -const directory = path.resolve(process.env.BELL_TEST_ARTIFACT_DIR || ".bell/parallel-e2e", randomUUID()); +const browserbase = new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY, + timeout: 10_000, + maxRetries: 0, +}); +const directory = path.resolve( + process.env.BELL_TEST_ARTIFACT_DIR || ".bell/parallel-e2e", + randomUUID(), +); await mkdir(directory, { recursive: true, mode: 0o700 }); -const browser = await chromium.launch({ headless: true, ...(process.env.TEST_CHROME_PATH ? { executablePath: process.env.TEST_CHROME_PATH } : { channel: "chrome" }) }); -const pause = () => new Promise(resolve => setTimeout(resolve, 250)); +const browser = await chromium.launch({ + headless: true, + ...(process.env.TEST_CHROME_PATH + ? { executablePath: process.env.TEST_CHROME_PATH } + : { channel: "chrome" }), +}); +const pause = () => new Promise((resolve) => setTimeout(resolve, 250)); async function until(check, message) { const deadline = Date.now() + 30_000; - while (!(await check())) { assert.ok(Date.now() < deadline, message); await pause(); } + while (!(await check())) { + assert.ok(Date.now() < deadline, message); + await pause(); + } } async function verifyAuth(sessionId) { if (!requireAuth) return; const url = `${origin.origin}/eve/v1/session/${encodeURIComponent(sessionId)}/stream`; for (const authorization of [undefined, "Basic invalid"]) { - const response = await fetch(url, { headers: { ...protection, ...(authorization ? { authorization } : {}) }, redirect: "error", signal: AbortSignal.timeout(10_000) }); + const response = await fetch(url, { + headers: { ...protection, ...(authorization ? { authorization } : {}) }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); await response.body?.cancel(); - assert.equal(response.status, 401, "Unauthenticated and invalid-auth session reads must be rejected."); + assert.equal( + response.status, + 401, + "Unauthenticated and invalid-auth session reads must be rejected.", + ); } - const csrf = await fetch(`${origin.origin}/eve/v1/session/${encodeURIComponent(sessionId)}/cancel`, { - method: "POST", headers: { ...headers, origin: "https://untrusted.invalid", "content-type": "application/json" }, - body: "{}", redirect: "error", signal: AbortSignal.timeout(10_000), - }); + const csrf = await fetch( + `${origin.origin}/eve/v1/session/${encodeURIComponent(sessionId)}/cancel`, + { + method: "POST", + headers: { + ...headers, + origin: "https://untrusted.invalid", + "content-type": "application/json", + }, + body: "{}", + redirect: "error", + signal: AbortSignal.timeout(10_000), + }, + ); await csrf.body?.cancel(); assert.equal(csrf.status, 403, "Authenticated cross-origin writes must be rejected."); } async function workflow(index) { - const hostedIds = new Set(), contextIds = new Set(), marker = `research-${randomUUID()}`; + const hostedIds = new Set(), + contextIds = new Set(), + marker = `research-${randomUUID()}`; const context = await browser.newContext({ serviceWorkers: "block" }); const page = await context.newPage(); const pageErrors = []; let streamRequests = 0; - page.on("request", request => { if (new URL(request.url()).pathname.endsWith("/stream")) streamRequests++; }); - if (index === 1) await page.addInitScript(() => { - const fetch = window.fetch.bind(window); - window.streamInterrupted = false; - window.interruptStream = false; - window.fetch = async (...args) => { - const response = await fetch(...args); - if (!new URL(response.url).pathname.endsWith("/stream") || !response.body) return response; - const reader = response.body.getReader(); - return new Response(new ReadableStream({ - async pull(controller) { - try { - const chunk = await reader.read(); - if (chunk.done) return controller.close(); - if (window.interruptStream && !window.streamInterrupted) { - window.streamInterrupted = true; - await reader.cancel(); - return controller.error(new TypeError("Failed to fetch")); - } - controller.enqueue(chunk.value); - } catch (error) { controller.error(error); } - }, - cancel: reason => reader.cancel(reason), - }), { status: response.status, statusText: response.statusText, headers: response.headers }); - }; + page.on("request", (request) => { + if (new URL(request.url()).pathname.endsWith("/stream")) streamRequests++; }); + if (index === 1) + await page.addInitScript(() => { + const fetch = window.fetch.bind(window); + window.streamInterrupted = false; + window.interruptStream = false; + window.fetch = async (...args) => { + const response = await fetch(...args); + if (!new URL(response.url).pathname.endsWith("/stream") || !response.body) return response; + const reader = response.body.getReader(); + return new Response( + new ReadableStream({ + async pull(controller) { + try { + const chunk = await reader.read(); + if (chunk.done) return controller.close(); + if (window.interruptStream && !window.streamInterrupted) { + window.streamInterrupted = true; + await reader.cancel(); + return controller.error(new TypeError("Failed to fetch")); + } + controller.enqueue(chunk.value); + } catch (error) { + controller.error(error); + } + }, + cancel: (reason) => reader.cancel(reason), + }), + { status: response.status, statusText: response.statusText, headers: response.headers }, + ); + }; + }); page.on("pageerror", () => pageErrors.push("Browser JavaScript error")); - await restrictBrowserRequests(context, page, origin.origin, headers, () => pageErrors.push("Browser request interception failed")); - let session, stage = "session creation"; - const remember = events => { + await restrictBrowserRequests(context, page, origin.origin, headers, () => + pageErrors.push("Browser request interception failed"), + ); + let session, + stage = "session creation"; + const remember = (events) => { for (const event of events) { const state = event.data?.result?.output?.workbench?.browser; if (state?.sessionId) hostedIds.add(state.sessionId); @@ -97,104 +169,222 @@ async function workflow(index) { await verifyAuth(session.state.sessionId); stage = "UI submission"; await page.goto(`${origin.origin}/s/${encodeURIComponent(session.state.sessionId)}`); - await page.getByRole("status").filter({ hasText: /^Ready$/ }).waitFor(); - await page.getByLabel("Message Bell", { exact: true }).fill(`Bounded public research test: use web_search to find official sources about the reserved example.com domain. Use web_fetch to read https://example.com/. Then use run to open https://example.com/ and snapshot to verify its title. Reply with a short summary citing at least one exact URL from each research tool (retain query strings), the observed browser title, and verification marker ${marker}. Use exactly one call to each tool. Do not browse other URLs, access credentials, select saved Contexts, or send messages.`); + await page + .getByRole("status") + .filter({ hasText: /^Ready$/ }) + .waitFor(); + await page + .getByLabel("Message Bell", { exact: true }) + .fill( + `Bounded public research test: use web_search to find official sources about the reserved example.com domain. Use web_fetch to read https://example.com/. Then use run to open https://example.com/ and snapshot to verify its title. Reply with a short summary citing at least one exact URL from each research tool (retain query strings), the observed browser title, and verification marker ${marker}. Use exactly one call to each tool. Do not browse other URLs, access credentials, select saved Contexts, or send messages.`, + ); await page.getByRole("button", { name: "Send", exact: true }).click(); - if (index === 1) await page.evaluate(() => { window.interruptStream = true; }); + if (index === 1) + await page.evaluate(() => { + window.interruptStream = true; + }); stage = "agent response"; await Promise.race([ - page.locator(".messages article.assistant").filter({ hasText: marker }).waitFor({ timeout: 180_000 }), - page.locator(".conversation [role=alert]").waitFor({ timeout: 180_000 }).then(() => { throw new Error("Bell displayed a request error"); }), + page + .locator(".messages article.assistant") + .filter({ hasText: marker }) + .waitFor({ timeout: 180_000 }), + page + .locator(".conversation [role=alert]") + .waitFor({ timeout: 180_000 }) + .then(() => { + throw new Error("Bell displayed a request error"); + }), ]); - await page.getByRole("status").filter({ hasText: /^Ready$/ }).waitFor({ timeout: 30_000 }); + await page + .getByRole("status") + .filter({ hasText: /^Ready$/ }) + .waitFor({ timeout: 30_000 }); const snapshot = await session.snapshot({ signal: AbortSignal.timeout(10_000) }); remember(snapshot.events); assertNoSecrets(snapshot); assertNoSecrets(await page.content()); - const calls = snapshot.events.filter(event => event.type === "action.result").map(event => event.data?.result); + const calls = snapshot.events + .filter((event) => event.type === "action.result") + .map((event) => event.data?.result); assert.equal(calls.length, 4, "Only the four requested tools may run."); for (const name of ["web_search", "web_fetch", "run", "snapshot"]) { - const matching = calls.filter(call => call?.toolName === name); + const matching = calls.filter((call) => call?.toolName === name); assert.equal(matching.length, 1, `${name} must run exactly once`); assert.ok(!matching[0].isError, `${name} must succeed`); } const answer = await page.locator(".messages article.assistant").last().innerText(); for (const name of ["web_search", "web_fetch"]) { - const output = calls.find(call => call.toolName === name).output; - assert.ok(output.results?.some(source => source.excerpts?.some(Boolean)), `${name} must return evidence`); + const output = calls.find((call) => call.toolName === name).output; + assert.ok( + output.results?.some((source) => source.excerpts?.some(Boolean)), + `${name} must return evidence`, + ); assert.equal(output.errors?.length, 0, `${name} must not return source errors`); - assert.ok(output.results.some(source => answer.includes(source.url)), `${name} must cite a returned URL verbatim: ${output.results.map(source => source.url).join(", ")}`); + assert.ok( + output.results.some((source) => answer.includes(source.url)), + `${name} must cite a returned URL verbatim: ${output.results.map((source) => source.url).join(", ")}`, + ); } - const observed = calls.find(call => call.toolName === "snapshot").output; + const observed = calls.find((call) => call.toolName === "snapshot").output; assert.equal(new URL(observed.result.url).hostname, "example.com"); assert.equal(observed.workbench.browser.title, "Example Domain"); assert.equal(observed.result.pageStatus, "live"); - assert.equal(observed.workbench.browser.contextStatus, "draft", "Use a disposable draft Context, never a saved Context."); + assert.equal( + observed.workbench.browser.contextStatus, + "draft", + "Use a disposable draft Context, never a saved Context.", + ); assert.equal(hostedIds.size, 1, "Each conversation must use one hosted browser"); assert.equal(contextIds.size, 1, "Each conversation must own one disposable Context"); assert.ok(answer.includes("Example Domain") && answer.includes(marker)); assert.equal(await page.locator(".conversation [role=alert]").count(), 0); assert.equal(pageErrors.length, 0); if (index === 1) { - assert.equal(await page.evaluate(() => window.streamInterrupted), true, "An active response stream must be interrupted"); - assert.ok(streamRequests >= 2 && streamRequests <= 6, "Stream recovery must reconnect within a bounded request budget"); + assert.equal( + await page.evaluate(() => window.streamInterrupted), + true, + "An active response stream must be interrupted", + ); + assert.ok( + streamRequests >= 2 && streamRequests <= 6, + "Stream recovery must reconnect within a bounded request budget", + ); } - await page.screenshot({ path: path.join(directory, `conversation-${index}.png`), fullPage: true }); + await page.screenshot({ + path: path.join(directory, `conversation-${index}.png`), + fullPage: true, + }); stage = "automatic retirement"; await page.getByRole("button", { name: "New conversation", exact: true }).click(); await page.waitForURL(origin.origin + "/"); await until(async () => { const events = (await session.snapshot({ signal: AbortSignal.timeout(10_000) })).events; remember(events); - assert.ok(!events.some(event => event.type === "session.failed"), "Conversation retirement failed"); - return events.some(event => event.type === "session.completed"); + assert.ok( + !events.some((event) => event.type === "session.failed"), + "Conversation retirement failed", + ); + return events.some((event) => event.type === "session.completed"); }, "New conversation must retire the previous Eve session"); for (const id of hostedIds) - await until(async () => (await browserbase.sessions.retrieve(id)).status === "COMPLETED", "New conversation must release its hosted browser without test intervention"); + await until( + async () => (await browserbase.sessions.retrieve(id)).status === "COMPLETED", + "New conversation must release its hosted browser without test intervention", + ); return { hostedIds: [...hostedIds], contextIds: [...contextIds] }; } catch (error) { - await page.screenshot({ path: path.join(directory, `failure-${index}.png`), fullPage: true }).catch(() => {}); + await page + .screenshot({ path: path.join(directory, `failure-${index}.png`), fullPage: true }) + .catch(() => {}); const alert = await page.locator(".conversation [role=alert]").allTextContents(); - const events = session ? (await session.snapshot({ signal: AbortSignal.timeout(10_000) }).catch(() => ({ events: [] }))).events : []; + const events = session + ? ( + await session + .snapshot({ signal: AbortSignal.timeout(10_000) }) + .catch(() => ({ events: [] })) + ).events + : []; remember(events); - throw new Error(`${stage}: ${error.message}\nUI: ${alert.join("; ")}\nEvents: ${events.slice(-12).map(event => event.type).join(", ")}`); + throw new Error( + `${stage}: ${error.message}\nUI: ${alert.join("; ")}\nEvents: ${events + .slice(-12) + .map((event) => event.type) + .join(", ")}`, + ); } finally { const failures = []; if (session) { - try { remember((await session.snapshot({ signal: AbortSignal.timeout(10_000) })).events); } catch { failures.push("Could not recover final session events"); } - try { await session.reset({ reason: "Parallel browser E2E cleanup", signal: AbortSignal.timeout(30_000) }); } catch { failures.push("Session reset failed"); } - } - for (const id of hostedIds) { try { - if ((await browserbase.sessions.retrieve(id)).status !== "COMPLETED") await browserbase.sessions.update(id, { status: "REQUEST_RELEASE" }); - await until(async () => (await browserbase.sessions.retrieve(id)).status === "COMPLETED", "Hosted browser cleanup timed out"); - } catch { failures.push("Hosted browser cleanup failed"); } + remember((await session.snapshot({ signal: AbortSignal.timeout(10_000) })).events); + } catch { + failures.push("Could not recover final session events"); + } + try { + await session.reset({ + reason: "Parallel browser E2E cleanup", + signal: AbortSignal.timeout(30_000), + }); + } catch { + failures.push("Session reset failed"); + } } - if (!failures.length) for (const id of contextIds) { + for (const id of hostedIds) { try { - await browserbase.contexts.delete(id, { headers: { "Content-Type": null } }); - await assert.rejects(browserbase.contexts.retrieve(id), error => error instanceof Browserbase.APIError && error.status === 404); - } catch { failures.push("Temporary Context deletion was not confirmed"); } + if ((await browserbase.sessions.retrieve(id)).status !== "COMPLETED") + await browserbase.sessions.update(id, { status: "REQUEST_RELEASE" }); + await until( + async () => (await browserbase.sessions.retrieve(id)).status === "COMPLETED", + "Hosted browser cleanup timed out", + ); + } catch { + failures.push("Hosted browser cleanup failed"); + } } + if (!failures.length) + for (const id of contextIds) { + try { + await browserbase.contexts.delete(id, { headers: { "Content-Type": null } }); + await assert.rejects( + browserbase.contexts.retrieve(id), + (error) => error instanceof Browserbase.APIError && error.status === 404, + ); + } catch { + failures.push("Temporary Context deletion was not confirmed"); + } + } await context.close().catch(() => failures.push("Local browser context cleanup failed")); assert.equal(failures.length, 0, failures.join("; ")); } } -let passed = false, failures = []; +let passed = false, + failures = []; try { const results = await Promise.allSettled([workflow(1), workflow(2)]); - failures = results.filter(result => result.status === "rejected").map(result => { - let message = String(result.reason?.message || "Workflow failed"); - for (const [, value] of secrets) message = message.replaceAll(value, "[redacted]"); - return message.slice(0, 2_000); - }); - assert.ok(results.every(result => result.status === "fulfilled"), "A conversation or its cleanup failed; inspect the local test report."); - const runs = results.map(result => result.value); - assert.equal(new Set(runs.flatMap(run => run.hostedIds)).size, 2, "Concurrent conversations must not share a hosted browser"); - assert.equal(new Set(runs.flatMap(run => run.contextIds)).size, 2, "Concurrent conversations must not share a Context"); + failures = results + .filter((result) => result.status === "rejected") + .map((result) => { + let message = String(result.reason?.message || "Workflow failed"); + for (const [, value] of secrets) message = message.replaceAll(value, "[redacted]"); + return message.slice(0, 2_000); + }); + assert.ok( + results.every((result) => result.status === "fulfilled"), + "A conversation or its cleanup failed; inspect the local test report.", + ); + const runs = results.map((result) => result.value); + assert.equal( + new Set(runs.flatMap((run) => run.hostedIds)).size, + 2, + "Concurrent conversations must not share a hosted browser", + ); + assert.equal( + new Set(runs.flatMap((run) => run.contextIds)).size, + 2, + "Concurrent conversations must not share a Context", + ); passed = true; } finally { await browser.close(); - await writeFile(path.join(directory, "result.json"), JSON.stringify({ testedAt: new Date().toISOString(), origin: origin.origin, passed, failures, authenticated: requireAuth, conversations: 2, scope: "Browser UI, interrupted response stream recovery, live Search and Extract, citations, hosted browser handoff, credential non-disclosure, automatic retirement, confirmed cleanup" }, null, 2), { mode: 0o600 }); - console.log(`Parallel browser E2E ${passed ? "passed" : "failed"}. Report: ${path.join(directory, "result.json")}`); + await writeFile( + path.join(directory, "result.json"), + JSON.stringify( + { + testedAt: new Date().toISOString(), + origin: origin.origin, + passed, + failures, + authenticated: requireAuth, + conversations: 2, + scope: + "Browser UI, interrupted response stream recovery, live Search and Extract, citations, hosted browser handoff, credential non-disclosure, automatic retirement, confirmed cleanup", + }, + null, + 2, + ), + { mode: 0o600 }, + ); + console.log( + `Parallel browser E2E ${passed ? "passed" : "failed"}. Report: ${path.join(directory, "result.json")}`, + ); } diff --git a/scripts/test-session-retirement.mjs b/scripts/test-session-retirement.mjs index 395b880..07e913e 100644 --- a/scripts/test-session-retirement.mjs +++ b/scripts/test-session-retirement.mjs @@ -23,7 +23,8 @@ const remember = (events) => { try { const created = await client.sessions.create({ - message: "Bounded integration test: use run to visit https://example.com/, then snapshot. Reply with the page title. Do not visit other sites, use saved Contexts or vaults, or send messages.", + message: + "Bounded integration test: use run to visit https://example.com/, then snapshot. Reply with the page title. Do not visit other sites, use saved Contexts or vaults, or send messages.", signal: AbortSignal.timeout(90_000), }); session = created.session; @@ -32,10 +33,12 @@ try { assert.equal(first.status, "waiting"); assert.ok(first.message?.includes("Example Domain")); assert.equal(browserIds.size, 1); - const second = await (await session.send( - "Without navigating, use snapshot to confirm the current page title and URL. Reply briefly.", - { signal: AbortSignal.timeout(45_000) }, - )).result(); + const second = await ( + await session.send( + "Without navigating, use snapshot to confirm the current page title and URL. Reply briefly.", + { signal: AbortSignal.timeout(45_000) }, + ) + ).result(); remember(second.events); assert.equal(second.status, "waiting"); assert.equal(browserIds.size, 1, "Follow-up must reuse the same hosted browser"); @@ -47,7 +50,10 @@ try { const snapshot = await session.snapshot(); completed = snapshot.events.some((event) => event.type === "session.completed"); if (completed) break; - assert.ok(!snapshot.events.some((event) => event.type === "session.failed"), "Session failed during retirement"); + assert.ok( + !snapshot.events.some((event) => event.type === "session.failed"), + "Session failed during retirement", + ); await new Promise((resolve) => setTimeout(resolve, 500)); } while (Date.now() < completionDeadline); assert.ok(completed, "Reset must publish session.completed"); @@ -61,24 +67,38 @@ try { } while (Date.now() < deadline); assert.equal(status, "COMPLETED", "Reset must release the hosted browser"); } - console.log("Live retirement passed: two turns shared one browser; reset emitted completion and Browserbase confirmed COMPLETED."); + console.log( + "Live retirement passed: two turns shared one browser; reset emitted completion and Browserbase confirmed COMPLETED.", + ); } finally { const cleanupErrors = []; if (session) { // Recover IDs even if a model assertion fails. Do not print private events. - try { remember((await session.snapshot()).events); } catch { /* Session may not have started. */ } - try { await session.reset({ reason: "Integration test cleanup" }); } - catch { cleanupErrors.push("Eve session reset failed"); } + try { + remember((await session.snapshot()).events); + } catch { + /* Session may not have started. */ + } + try { + await session.reset({ reason: "Integration test cleanup" }); + } catch { + cleanupErrors.push("Eve session reset failed"); + } } for (const id of browserIds) { try { if ((await browserbase.sessions.retrieve(id)).status !== "COMPLETED") await browserbase.sessions.update(id, { status: "REQUEST_RELEASE" }); - } catch { cleanupErrors.push("Browser release failed"); } + } catch { + cleanupErrors.push("Browser release failed"); + } } for (const id of contextIds) { - try { await browserbase.contexts.delete(id, { headers: { "Content-Type": null } }); } - catch { cleanupErrors.push("Temporary Context deletion failed"); } + try { + await browserbase.contexts.delete(id, { headers: { "Content-Type": null } }); + } catch { + cleanupErrors.push("Temporary Context deletion failed"); + } } if (cleanupErrors.length) throw new Error(cleanupErrors.join("; ")); } diff --git a/server/browser/agent-contexts.ts b/server/browser/agent-contexts.ts index 909c436..89a187e 100644 --- a/server/browser/agent-contexts.ts +++ b/server/browser/agent-contexts.ts @@ -96,7 +96,11 @@ export async function ensureAgentContext( status: current?.status === "draft" ? "draft" : "saved", }; } catch (error) { - if (current?.status !== "draft" || !(error instanceof Browserbase.APIError) || error.status !== 404) + if ( + current?.status !== "draft" || + !(error instanceof Browserbase.APIError) || + error.status !== 404 + ) throw error; await metadata.remove(currentContextId); } diff --git a/server/browser/browser-session.ts b/server/browser/browser-session.ts index 5d1bf4a..2c19161 100644 --- a/server/browser/browser-session.ts +++ b/server/browser/browser-session.ts @@ -52,7 +52,10 @@ export class BrowserSession { ensureContext?: ( currentContextId?: string, ) => Promise<{ id: string; status: "draft" | "saved" }>; - captcha?: { status: "idle" | "solving" | "solved" | "errored" | "timed_out"; startedAt?: string }; + captcha?: { + status: "idle" | "solving" | "solved" | "errored" | "timed_out"; + startedAt?: string; + }; onCaptchaTransition?: (transition: CaptchaTransition) => void; } = {}, ) { @@ -100,8 +103,13 @@ export class BrowserSession { if (requested === "browserbase") { const apiKey = process.env.BROWSERBASE_API_KEY; if (!apiKey) throw new Error("BROWSERBASE_API_KEY is required for Browserbase mode."); - if (personalBrowserEnabled() && (this.options.contextId || process.env.BROWSERBASE_CONTEXT_ID)) - throw new Error("Start a new conversation without a reusable Context for personal 1Password."); + if ( + personalBrowserEnabled() && + (this.options.contextId || process.env.BROWSERBASE_CONTEXT_ID) + ) + throw new Error( + "Start a new conversation without a reusable Context for personal 1Password.", + ); const currentContextId = this.options.contextId ?? process.env.BROWSERBASE_CONTEXT_ID; if (this.options.ensureContext && !personalBrowserEnabled()) { const context = await this.options.ensureContext(currentContextId); @@ -343,56 +351,69 @@ export class BrowserSession { host: string; url: string; }> { - const selectors = [input.usernameTarget, input.passwordTarget, input.submitTarget, - input.totpTarget, input.otpSubmitTarget, input.successTarget]; - if (selectors.some((selector) => selector && (/^\s*(?:xpath=|\/|\()/i.test(selector) || selector.includes(">>")))) - throw new Error("Secure login requires CSS selectors in the top-level page; frame selectors are not allowed."); + const selectors = [ + input.usernameTarget, + input.passwordTarget, + input.submitTarget, + input.totpTarget, + input.otpSubmitTarget, + input.successTarget, + ]; + if ( + selectors.some( + (selector) => + selector && (/^\s*(?:xpath=|\/|\()/i.test(selector) || selector.includes(">>")), + ) + ) + throw new Error( + "Secure login requires CSS selectors in the top-level page; frame selectors are not allowed.", + ); return this.withLock(async () => { try { await this.start(); await this.gateCaptcha(); const started = performance.now(); const result = await this.requireStagehand().experimentalBatch( - async ({ page }, values) => { - const checkDestination = async () => { - const destination = new URL(await page.url()); - if ( - destination.protocol !== "https:" || - destination.username || - destination.password || - !values.allowedHosts.includes(destination.hostname.toLowerCase()) - ) - throw new Error("Unsafe credential destination."); - }; - await checkDestination(); - await page.locator(values.usernameTarget).fill(values.username); - await checkDestination(); - await page.locator(values.passwordTarget).fill(values.password); - if (values.totpTarget && values.totp && !values.totpAfterSubmit) { + async ({ page }, values) => { + const checkDestination = async () => { + const destination = new URL(await page.url()); + if ( + destination.protocol !== "https:" || + destination.username || + destination.password || + !values.allowedHosts.includes(destination.hostname.toLowerCase()) + ) + throw new Error("Unsafe credential destination."); + }; await checkDestination(); - await page.locator(values.totpTarget).fill(values.totp); - } - await checkDestination(); - await page.locator(values.submitTarget).click(); - await page.waitForTimeout(1_000); - if (values.totpTarget && values.totp && values.totpAfterSubmit) { + await page.locator(values.usernameTarget).fill(values.username); await checkDestination(); - await page.locator(values.totpTarget).fill(values.totp); - if (values.otpSubmitTarget) { + await page.locator(values.passwordTarget).fill(values.password); + if (values.totpTarget && values.totp && !values.totpAfterSubmit) { await checkDestination(); - await page.locator(values.otpSubmitTarget).click(); + await page.locator(values.totpTarget).fill(values.totp); } + await checkDestination(); + await page.locator(values.submitTarget).click(); await page.waitForTimeout(1_000); - } - const url = await page.url(), - title = await page.title(); - const success = values.successTarget - ? (await page.locator(values.successTarget).count()) > 0 - : false; - return { url, title, success }; - }, - input, - { timeout: 45_000 }, + if (values.totpTarget && values.totp && values.totpAfterSubmit) { + await checkDestination(); + await page.locator(values.totpTarget).fill(values.totp); + if (values.otpSubmitTarget) { + await checkDestination(); + await page.locator(values.otpSubmitTarget).click(); + } + await page.waitForTimeout(1_000); + } + const url = await page.url(), + title = await page.title(); + const success = values.successTarget + ? (await page.locator(values.successTarget).count()) > 0 + : false; + return { url, title, success }; + }, + input, + { timeout: 45_000 }, ); await this.gateCaptcha(); this.updateBrowserState(result.url, result.title); diff --git a/server/browser/browserbase-launch.ts b/server/browser/browserbase-launch.ts index 81a8ae6..0478c16 100644 --- a/server/browser/browserbase-launch.ts +++ b/server/browser/browserbase-launch.ts @@ -1,5 +1,8 @@ import Browserbase from "@browserbasehq/sdk"; -import { browserbase as stagehandBrowserbase, type StagehandBrowser } from "@browserbasehq/stagehand"; +import { + browserbase as stagehandBrowserbase, + type StagehandBrowser, +} from "@browserbasehq/stagehand"; import { personalBrowserEnabled } from "../onepassword/browser-policy"; import { addTrace } from "./trace"; @@ -29,10 +32,13 @@ export function browserbaseLaunchOptions( ) { const contextId = durableContextId ?? process.env.BROWSERBASE_CONTEXT_ID; const projectId = process.env.BROWSERBASE_PROJECT_ID; - if (personalBrowserEnabled() && contextId) throw new Error("Personal 1Password cannot use a reusable Context."); + if (personalBrowserEnabled() && contextId) + throw new Error("Personal 1Password cannot use a reusable Context."); return { apiKey, - ...(personalBrowserEnabled() ? { extensionId: process.env.BROWSERBASE_1PASSWORD_EXTENSION_ID!.trim() } : {}), + ...(personalBrowserEnabled() + ? { extensionId: process.env.BROWSERBASE_1PASSWORD_EXTENSION_ID!.trim() } + : {}), ...(projectId ? { projectId } : {}), proxies: proxyLocation ? [ @@ -74,13 +80,18 @@ export async function launchHostedBrowser(host: HostedBrowserHost, apiKey: strin for (let attempt = 1; attempt <= 2; attempt += 1) { try { const prior = await client.sessions.retrieve(persistedSessionId); - if (prior.userMetadata?.networkLogging !== "false" || prior.userMetadata?.sessionRecording !== "false") + if ( + prior.userMetadata?.networkLogging !== "false" || + prior.userMetadata?.sessionRecording !== "false" + ) throw new Error("Session logging and recording settings cannot be verified."); const personalSession = - prior.userMetadata?.privateContext === "true" || Boolean(prior.userMetadata?.onepasswordExtension); + prior.userMetadata?.privateContext === "true" || + Boolean(prior.userMetadata?.onepasswordExtension); if ( personalBrowserEnabled() - ? prior.userMetadata?.onepasswordExtension !== process.env.BROWSERBASE_1PASSWORD_EXTENSION_ID || + ? prior.userMetadata?.onepasswordExtension !== + process.env.BROWSERBASE_1PASSWORD_EXTENSION_ID || prior.userMetadata?.privateContext !== "true" : personalSession ) @@ -126,7 +137,8 @@ export async function launchHostedBrowser(host: HostedBrowserHost, apiKey: strin browserbaseLaunchOptions(apiKey, host.contextId, host.proxyLocation), ); await host.attach(launched); - if (!launched.sessionId) throw new Error("Stagehand did not return a Browserbase session ID."); + if (!launched.sessionId) + throw new Error("Stagehand did not return a Browserbase session ID."); host.remoteSessionId = launched.sessionId; host.state.browser.sessionId = launched.sessionId; return; diff --git a/server/browser/captcha-gate.ts b/server/browser/captcha-gate.ts index ecab9a5..79b5c77 100644 --- a/server/browser/captcha-gate.ts +++ b/server/browser/captcha-gate.ts @@ -127,12 +127,24 @@ export class CaptchaGate { this.status = "solving"; this.startedAt = at; this.emit("started", at, page); - addTrace(this.host.state, "browser", "captcha.started", "Browserbase is solving a CAPTCHA.", page); + addTrace( + this.host.state, + "browser", + "captcha.started", + "Browserbase is solving a CAPTCHA.", + page, + ); } else if (signal === "finished" && this.status === "solving") { this.status = "solved"; this.needsObservation = true; this.emit("finished", at, page); - addTrace(this.host.state, "browser", "captcha.finished", "Browserbase solved the CAPTCHA.", page); + addTrace( + this.host.state, + "browser", + "captcha.finished", + "Browserbase solved the CAPTCHA.", + page, + ); } else if (signal === "errored" && this.status === "solving") { this.status = "errored"; this.emit("errored", at, page); diff --git a/server/browser/context-metadata-store.ts b/server/browser/context-metadata-store.ts index 1e3a51f..5394e4e 100644 --- a/server/browser/context-metadata-store.ts +++ b/server/browser/context-metadata-store.ts @@ -5,14 +5,17 @@ import { z } from "zod"; const metadataSchema = z.object({ selectedContextId: z.string().optional(), - contexts: z.record(z.string(), z.object({ - name: z.string(), - createdAt: z.iso.datetime(), - updatedAt: z.iso.datetime(), - status: z.enum(["draft", "saved"]).optional(), - ownerSessionId: z.string().optional(), - expiresAt: z.iso.datetime().optional(), - })), + contexts: z.record( + z.string(), + z.object({ + name: z.string(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), + status: z.enum(["draft", "saved"]).optional(), + ownerSessionId: z.string().optional(), + expiresAt: z.iso.datetime().optional(), + }), + ), }); export type ContextMetadataFile = z.infer; @@ -38,7 +41,9 @@ export class ContextMetadataStore { try { return metadataSchema.parse(JSON.parse(contents)); } catch (error) { - throw new Error("Browser context metadata is invalid; refusing to overwrite it.", { cause: error }); + throw new Error("Browser context metadata is invalid; refusing to overwrite it.", { + cause: error, + }); } } diff --git a/server/browser/handoff.ts b/server/browser/handoff.ts index 04aae47..6230cdb 100644 --- a/server/browser/handoff.ts +++ b/server/browser/handoff.ts @@ -116,7 +116,10 @@ export async function resolveBrowserHandoffLiveViewUrl( if (!apiKey) configurationError(); const browserbase = new Browserbase({ apiKey }); const session = await browserbase.sessions.retrieve(browserSessionId); - if (session.userMetadata?.networkLogging !== "false" || session.userMetadata?.sessionRecording !== "false") + if ( + session.userMetadata?.networkLogging !== "false" || + session.userMetadata?.sessionRecording !== "false" + ) throw new BrowserHandoffError("unavailable", "The browser handoff is not available."); const debug = await browserbase.sessions.debug(browserSessionId); return debug.debuggerFullscreenUrl; diff --git a/server/browser/privacy-lock.ts b/server/browser/privacy-lock.ts index e90320f..48a927a 100644 --- a/server/browser/privacy-lock.ts +++ b/server/browser/privacy-lock.ts @@ -13,29 +13,66 @@ export async function quarantineBrowser(): Promise { await lock(); } -export async function withBrowserPrivacyLock(sessionId: string, operation: () => Promise, database?: Pool, existingBrowser = false): Promise { +export async function withBrowserPrivacyLock( + sessionId: string, + operation: () => Promise, + database?: Pool, + existingBrowser = false, +): Promise { if (!database && isLinkSandbox()) { if (!process.env.LINK_SANDBOX_ACCESS_TOKEN?.trim()) return operation(); let state = sandboxBrowsers.get(sessionId); - if (!state && existingBrowser) throw new Error("The sandbox cannot recover a browser from another process. Use human handoff to inspect the existing purchase; do not retry it."); - if (!state) { state = { tail: Promise.resolve(), quarantined: false }; sandboxBrowsers.set(sessionId, state); } + if (!state && existingBrowser) + throw new Error( + "The sandbox cannot recover a browser from another process. Use human handoff to inspect the existing purchase; do not retry it.", + ); + if (!state) { + state = { tail: Promise.resolve(), quarantined: false }; + sandboxBrowsers.set(sessionId, state); + } const previous = state.tail; let release!: () => void; - state.tail = new Promise(resolve => { release = resolve; }); + state.tail = new Promise((resolve) => { + release = resolve; + }); await previous; try { - if (state.quarantined) throw new Error("Payment details may remain in this browser. Use human handoff; do not retry payment."); - return await current.run(async () => { state.quarantined = true; }, operation); - } finally { release(); } + if (state.quarantined) + throw new Error( + "Payment details may remain in this browser. Use human handoff; do not retry payment.", + ); + return await current.run(async () => { + state.quarantined = true; + }, operation); + } finally { + release(); + } } if (!database && !process.env.LINK_CLIENT_ID?.trim()) return operation(); - if (!database && !process.env.DATABASE_URL) throw new Error("Link checkout requires DATABASE_URL."); - if (!database && !pool) pool = createPool(process.env.DATABASE_URL!, "Browser privacy", { allowExitOnIdle: true }); - return withAdvisoryLock(database ?? pool!, `bell-browser-privacy:${sessionId}`, async (client) => { - if ((await client.query("SELECT 1 FROM bell_browser_privacy_guards WHERE session_id=$1", [sessionId])).rowCount) - throw new Error("Payment details may remain in this browser. Use human handoff to inspect the existing purchase; do not retry it. Start a new conversation for further browser automation."); - return current.run(async () => { - await client.query("INSERT INTO bell_browser_privacy_guards(session_id) VALUES($1) ON CONFLICT DO NOTHING", [sessionId]); - }, operation); - }); + if (!database && !process.env.DATABASE_URL) + throw new Error("Link checkout requires DATABASE_URL."); + if (!database && !pool) + pool = createPool(process.env.DATABASE_URL!, "Browser privacy", { allowExitOnIdle: true }); + return withAdvisoryLock( + database ?? pool!, + `bell-browser-privacy:${sessionId}`, + async (client) => { + if ( + ( + await client.query("SELECT 1 FROM bell_browser_privacy_guards WHERE session_id=$1", [ + sessionId, + ]) + ).rowCount + ) + throw new Error( + "Payment details may remain in this browser. Use human handoff to inspect the existing purchase; do not retry it. Start a new conversation for further browser automation.", + ); + return current.run(async () => { + await client.query( + "INSERT INTO bell_browser_privacy_guards(session_id) VALUES($1) ON CONFLICT DO NOTHING", + [sessionId], + ); + }, operation); + }, + ); } diff --git a/server/browser/redact.ts b/server/browser/redact.ts index 6ba4846..395e6d4 100644 --- a/server/browser/redact.ts +++ b/server/browser/redact.ts @@ -4,7 +4,8 @@ const SECRET_PATTERNS: Array<[RegExp, string]> = [ [/\b(?:Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi, "Bearer [REDACTED_TOKEN]"], [/(password|secret|token|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, "$1=[REDACTED]"], ]; -const SECRET_FIELD = /^(?:password|secret|token|access_?token|refresh_?token|api_?key|authorization|cookie|set-cookie|totp|totpSecret|otp|cvc|cvv|card_?number)$/i; +const SECRET_FIELD = + /^(?:password|secret|token|access_?token|refresh_?token|api_?key|authorization|cookie|set-cookie|totp|totpSecret|otp|cvc|cvv|card_?number)$/i; export function redactText(value: string): string { return SECRET_PATTERNS.reduce( @@ -16,7 +17,8 @@ export function redactText(value: string): string { export function redactValue(value: T): T { const json = JSON.stringify(value, function (key, item) { if (SECRET_FIELD.test(key)) return "[REDACTED]"; - if (key === "value" && ["fill", "type", "select"].includes(this.action)) return "[value hidden]"; + if (key === "value" && ["fill", "type", "select"].includes(this.action)) + return "[value hidden]"; if (key === "key" && this.action === "press") return "[key hidden]"; if (typeof item === "string") return redactText(item); return item; diff --git a/server/browser/vault/native.ts b/server/browser/vault/native.ts index f0e612e..8b4121c 100644 --- a/server/browser/vault/native.ts +++ b/server/browser/vault/native.ts @@ -13,9 +13,20 @@ const recordSchema = z.object({ version: z.literal(1), id: z.string().min(1), label: z.string().min(1), - allowedHosts: z.array(z.string().min(1).refine((value) => { - try { return new URL(`https://${value}`).hostname === value; } catch { return false; } - })).min(1), + allowedHosts: z + .array( + z + .string() + .min(1) + .refine((value) => { + try { + return new URL(`https://${value}`).hostname === value; + } catch { + return false; + } + }), + ) + .min(1), hasTotp: z.boolean(), createdAt: z.string(), updatedAt: z.string(), @@ -23,7 +34,8 @@ const recordSchema = z.object({ wrappedKey: boxSchema, payload: boxSchema, }); -const storeSchema = z.object({ version: z.literal(1), records: z.array(recordSchema) }) +const storeSchema = z + .object({ version: z.literal(1), records: z.array(recordSchema) }) .refine(({ records }) => new Set(records.map(({ id }) => id)).size === records.length); const payloadSchema = z.object({ username: z.string().min(1), @@ -34,7 +46,10 @@ const payloadSchema = z.object({ // The v1 file is trusted operator configuration: only credential fields are encrypted. // Keep this reader for existing vaults; Bell does not expose vault management routes. export class NativeVault { - constructor(private path?: string, private masterKey?: Buffer) {} + constructor( + private path?: string, + private masterKey?: Buffer, + ) {} async list() { this.key(); @@ -55,10 +70,14 @@ export class NativeVault { if (!record) throw new Error("The selected local vault login was not found."); try { const dataKey = openBox(masterKey, record.wrappedKey), - payload = payloadSchema.parse(JSON.parse(openBox(dataKey, record.payload).toString("utf8"))); + payload = payloadSchema.parse( + JSON.parse(openBox(dataKey, record.payload).toString("utf8")), + ); let totp: string | undefined; if (payload.totpSecret) { - try { totp = generateTotp(payload.totpSecret); } catch { + try { + totp = generateTotp(payload.totpSecret); + } catch { // Keep password login usable; the browser requests human help when OTP is unavailable. } } @@ -69,25 +88,35 @@ export class NativeVault { ...(totp ? { totp } : {}), }; } catch { - throw new Error("Could not decrypt the local vault login. Check the vault file and master key."); + throw new Error( + "Could not decrypt the local vault login. Check the vault file and master key.", + ); } } private async read() { try { - const source = await readFile(this.path ?? process.env.BELL_VAULT_FILE ?? ".bell/vault.enc.json", "utf8"); + const source = await readFile( + this.path ?? process.env.BELL_VAULT_FILE ?? ".bell/vault.enc.json", + "utf8", + ); return storeSchema.parse(JSON.parse(source)).records; } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return []; - throw new Error("Could not read the local vault. Check its path, permissions, and v1 file format."); + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") + return []; + throw new Error( + "Could not read the local vault. Check its path, permissions, and v1 file format.", + ); } } private key(): Buffer { const configured = process.env.BELL_VAULT_MASTER_KEY?.trim(), - key = this.masterKey ?? (configured - ? Buffer.from(configured, /^[a-f0-9]{64}$/i.test(configured) ? "hex" : "base64") - : undefined); + key = + this.masterKey ?? + (configured + ? Buffer.from(configured, /^[a-f0-9]{64}$/i.test(configured) ? "hex" : "base64") + : undefined); if (!key || key.length !== 32) throw new Error("Set BELL_VAULT_MASTER_KEY to a 32-byte base64 or 64-character hex key."); return key; diff --git a/server/browser/vault/onepassword-cli.ts b/server/browser/vault/onepassword-cli.ts index f333b19..f9835eb 100644 --- a/server/browser/vault/onepassword-cli.ts +++ b/server/browser/vault/onepassword-cli.ts @@ -22,22 +22,39 @@ const itemSchema = z.object({ title: z.string(), category: z.literal("LOGIN"), fields: z.array(fieldSchema), - urls: z.array(z.object({ - href: z.string(), - autofillBehavior: z.string().optional(), - })).default([]), + urls: z + .array( + z.object({ + href: z.string(), + autofillBehavior: z.string().optional(), + }), + ) + .default([]), }); type CliItem = z.infer; export async function listCliLogins(): Promise { const run = configuredCli(); try { - const vaults = vaultSchema.array().parse(JSON.parse(await run(["vault", "list", "--format=json"]))); + const vaults = vaultSchema + .array() + .parse(JSON.parse(await run(["vault", "list", "--format=json"]))); const result: SafeLoginItem[] = []; for (const vault of vaults) { - const items = z.object({ id }).array().parse(JSON.parse(await run([ - "item", "list", `--vault=${vault.id}`, "--categories=Login", "--format=json", - ]))); + const items = z + .object({ id }) + .array() + .parse( + JSON.parse( + await run([ + "item", + "list", + `--vault=${vault.id}`, + "--categories=Login", + "--format=json", + ]), + ), + ); for (const overview of items) { const item = await readItem(run, vault.id, overview.id); result.push({ @@ -56,7 +73,9 @@ export async function listCliLogins(): Promise { } return result; } catch { - throw new Error("Could not list 1Password CLI logins. Check the CLI installation, configured account, and vault permissions."); + throw new Error( + "Could not list 1Password CLI logins. Check the CLI installation, configured account, and vault permissions.", + ); } } @@ -73,45 +92,55 @@ export async function readCliLogin(vaultId: string, itemId: string): Promise field.type?.toUpperCase() === "OTP")) { // Ask the CLI for a generated code; never pass the stored OTP seed to the browser. const code = await run(["item", "get", `--vault=${vaultId}`, "--otp", "--", itemId]) - .then((value) => value.trim()).catch(() => ""); + .then((value) => value.trim()) + .catch(() => ""); if (/^\d{6,8}$/.test(code)) totp = code; } return { allowedHosts: allowedHosts(item), username, password, ...(totp ? { totp } : {}) }; } catch { - throw new Error("Could not read the 1Password CLI login. Check the item handle, configured account, and vault permissions."); + throw new Error( + "Could not read the 1Password CLI login. Check the item handle, configured account, and vault permissions.", + ); } } async function readItem(run: ReturnType, vaultId: string, itemId: string) { - const item = itemSchema.parse(JSON.parse(await run([ - "item", "get", `--vault=${vaultId}`, "--format=json", "--", itemId, - ]))); + const item = itemSchema.parse( + JSON.parse(await run(["item", "get", `--vault=${vaultId}`, "--format=json", "--", itemId])), + ); if (item.id !== itemId || item.vault.id !== vaultId) throw new Error(); return item; } function fieldValue(item: CliItem, name: "username" | "password") { - return item.fields.find((field) => field.id === name || field.purpose?.toLowerCase() === name)?.value; + return item.fields.find((field) => field.id === name || field.purpose?.toLowerCase() === name) + ?.value; } function allowedHosts(item: CliItem): string[] { // CLI versions may omit autofill policies. This explicit compatibility mode uses saved // website hosts; use the default SDK mode to enforce 1Password's Never autofill policy. - return [...new Set(item.urls.flatMap((site) => { - if (site.autofillBehavior?.toLowerCase() === "never") return []; - try { - return [new URL(site.href).hostname.toLowerCase()].filter(Boolean); - } catch { - return []; - } - }))]; + return [ + ...new Set( + item.urls.flatMap((site) => { + if (site.autofillBehavior?.toLowerCase() === "never") return []; + try { + return [new URL(site.href).hostname.toLowerCase()].filter(Boolean); + } catch { + return []; + } + }), + ), + ]; } function configuredCli() { const token = process.env.OP_SERVICE_ACCOUNT_TOKEN?.trim(), account = process.env.OP_ACCOUNT?.trim(); if (!token && !account) - throw new Error("Set OP_SERVICE_ACCOUNT_TOKEN or an explicit OP_ACCOUNT account ID for 1Password CLI access."); + throw new Error( + "Set OP_SERVICE_ACCOUNT_TOKEN or an explicit OP_ACCOUNT account ID for 1Password CLI access.", + ); const env = { ...process.env }; for (const name of Object.keys(env)) { if (name === "OP_CONNECT_HOST" || name === "OP_CONNECT_TOKEN" || name.startsWith("OP_SESSION")) @@ -122,12 +151,21 @@ function configuredCli() { env.OP_DEBUG = "false"; if (token) env.OP_SERVICE_ACCOUNT_TOKEN = token; const accountArgs = token ? [] : [`--account=${account}`]; - return (args: string[]): Promise => new Promise((resolve, reject) => { - execFile("op", [...accountArgs, ...args], { - env, encoding: "utf8", timeout: 20_000, maxBuffer: 2 * 1024 * 1024, - }, (error, stdout) => { - if (error) reject(new Error("The 1Password CLI request failed.")); - else resolve(stdout); + return (args: string[]): Promise => + new Promise((resolve, reject) => { + execFile( + "op", + [...accountArgs, ...args], + { + env, + encoding: "utf8", + timeout: 20_000, + maxBuffer: 2 * 1024 * 1024, + }, + (error, stdout) => { + if (error) reject(new Error("The 1Password CLI request failed.")); + else resolve(stdout); + }, + ); }); - }); } diff --git a/server/browser/vault/onepassword.ts b/server/browser/vault/onepassword.ts index 014ed77..40d871f 100644 --- a/server/browser/vault/onepassword.ts +++ b/server/browser/vault/onepassword.ts @@ -49,7 +49,9 @@ export class OnePasswordVault { } return items; } catch { - throw new Error("Could not list 1Password logins. Check the configured account and vault permissions."); + throw new Error( + "Could not list 1Password logins. Check the configured account and vault permissions.", + ); } } @@ -61,7 +63,9 @@ export class OnePasswordVault { try { item = await client.items.get(vaultId, itemId); } catch { - throw new Error("Could not read the 1Password login. Check the item handle and vault permissions."); + throw new Error( + "Could not read the 1Password login. Check the item handle and vault permissions.", + ); } if (item.category !== sdk.ItemCategory.Login) throw new Error("The selected 1Password item is not a Login."); @@ -79,7 +83,9 @@ export class OnePasswordVault { const token = process.env.OP_SERVICE_ACCOUNT_TOKEN?.trim(), account = process.env.OP_ACCOUNT?.trim(); if (!token && !account) - throw new Error("Set OP_SERVICE_ACCOUNT_TOKEN for hosted 1Password access or OP_ACCOUNT for the local desktop app."); + throw new Error( + "Set OP_SERVICE_ACCOUNT_TOKEN for hosted 1Password access or OP_ACCOUNT for the local desktop app.", + ); this.client = (async () => { try { const sdk = await import("@1password/sdk"); @@ -90,7 +96,9 @@ export class OnePasswordVault { }); } catch { this.client = undefined; - throw new Error("Could not connect to 1Password. Check the configured account and authorization."); + throw new Error( + "Could not connect to 1Password. Check the configured account and authorization.", + ); } })(); } @@ -108,13 +116,17 @@ function backend(): "sdk" | "cli" { } function allowedHosts(item: Item): string[] { - return [...new Set(item.websites.flatMap((site) => { - if (site.autofillBehavior === "Never") return []; - try { - const host = new URL(site.url).hostname.toLowerCase(); - return host ? [host] : []; - } catch { - return []; - } - }))]; + return [ + ...new Set( + item.websites.flatMap((site) => { + if (site.autofillBehavior === "Never") return []; + try { + const host = new URL(site.url).hostname.toLowerCase(); + return host ? [host] : []; + } catch { + return []; + } + }), + ), + ]; } diff --git a/server/encrypted-record.ts b/server/encrypted-record.ts index b5a889d..db4b536 100644 --- a/server/encrypted-record.ts +++ b/server/encrypted-record.ts @@ -1,6 +1,8 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; -export function hash(value: string): string { return createHash("sha256").update(value).digest("base64url"); } +export function hash(value: string): string { + return createHash("sha256").update(value).digest("base64url"); +} // Bind ciphertext to its purpose and owner so records cannot be swapped between users. export function seal(value: unknown, key: Buffer, context: string): string { @@ -8,7 +10,12 @@ export function seal(value: unknown, key: Buffer, context: string): string { const cipher = createCipheriv("aes-256-gcm", key, nonce); cipher.setAAD(Buffer.from(context)); const body = Buffer.concat([cipher.update(JSON.stringify(value), "utf8"), cipher.final()]); - return ["v1", nonce.toString("base64url"), cipher.getAuthTag().toString("base64url"), body.toString("base64url")].join("."); + return [ + "v1", + nonce.toString("base64url"), + cipher.getAuthTag().toString("base64url"), + body.toString("base64url"), + ].join("."); } export function unseal(value: string, key: Buffer, context: string): T { @@ -18,7 +25,11 @@ export function unseal(value: string, key: Buffer, context: string): T { const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(nonce, "base64url")); decipher.setAAD(Buffer.from(context)); decipher.setAuthTag(Buffer.from(tag, "base64url")); - return JSON.parse(Buffer.concat([decipher.update(Buffer.from(body, "base64url")), decipher.final()]).toString("utf8")) as T; + return JSON.parse( + Buffer.concat([decipher.update(Buffer.from(body, "base64url")), decipher.final()]).toString( + "utf8", + ), + ) as T; } catch { throw new Error("Credentials could not be decrypted."); } diff --git a/server/link/callback.ts b/server/link/callback.ts index 166ef04..9f95eac 100644 --- a/server/link/callback.ts +++ b/server/link/callback.ts @@ -3,8 +3,12 @@ import { isLinkSandbox } from "./sandbox"; const headers = { "cache-control": "no-store", "referrer-policy": "no-referrer" }; -export async function linkCallback(request: Request, service: () => LinkOAuth = getLinkOAuth): Promise { - if (isLinkSandbox()) return new Response("Link OAuth is not enabled in the sandbox.", { status: 404, headers }); +export async function linkCallback( + request: Request, + service: () => LinkOAuth = getLinkOAuth, +): Promise { + if (isLinkSandbox()) + return new Response("Link OAuth is not enabled in the sandbox.", { status: 404, headers }); try { const params = new URL(request.url).searchParams; for (const name of ["state", "code", "error"]) { @@ -13,8 +17,12 @@ export async function linkCallback(request: Request, service: () => LinkOAuth = const target = await safeLinkCall(() => service().forward(Object.fromEntries(params))); return new Response(null, { status: 303, headers: { ...headers, location: target } }); } catch { - return new Response("Link authorization could not be completed. Return to your Bell text conversation and request a new connection link.", { - status: 400, headers: { ...headers, "content-type": "text/plain; charset=utf-8" }, - }); + return new Response( + "Link authorization could not be completed. Return to your Bell text conversation and request a new connection link.", + { + status: 400, + headers: { ...headers, "content-type": "text/plain; charset=utf-8" }, + }, + ); } } diff --git a/server/link/checkout-browser.ts b/server/link/checkout-browser.ts index c74912a..ce59597 100644 --- a/server/link/checkout-browser.ts +++ b/server/link/checkout-browser.ts @@ -4,22 +4,38 @@ import { chromium, type ElementHandle, type Page } from "playwright-core"; import { quarantineBrowser } from "../browser/privacy-lock"; import { httpsOrigin, paymentValues, type CheckoutInput } from "./payment"; -async function element(page: Page, target: { selector: string; frame?: string }, origins: string[]): Promise> { +async function element( + page: Page, + target: { selector: string; frame?: string }, + origins: string[], +): Promise> { if (!origins.includes(httpsOrigin(page.url()))) throw new Error("Unapproved checkout page."); - const frame = target.frame ? await (await page.locator(`css=${target.frame}`).elementHandle())?.contentFrame() : page.mainFrame(); - if (!frame || !origins.includes(httpsOrigin(frame.url()))) throw new Error("Unapproved payment frame."); + const frame = target.frame + ? await (await page.locator(`css=${target.frame}`).elementHandle())?.contentFrame() + : page.mainFrame(); + if (!frame || !origins.includes(httpsOrigin(frame.url()))) + throw new Error("Unapproved payment frame."); const locator = frame.locator(`css=${target.selector}`); - if (await locator.count() !== 1) throw new Error("Payment target is missing or ambiguous."); + if ((await locator.count()) !== 1) throw new Error("Payment target is missing or ambiguous."); const handle = await locator.elementHandle(); if (!handle) throw new Error("Payment target is unavailable."); return handle; } -export async function submitPayment(page: Page, input: CheckoutInput, origins: string[], getCredentials: () => Promise, signal: AbortSignal, quarantine = quarantineBrowser) { +export async function submitPayment( + page: Page, + input: CheckoutInput, + origins: string[], + getCredentials: () => Promise, + signal: AbortSignal, + quarantine = quarantineBrowser, +) { signal.throwIfAborted(); const fields: ElementHandle[] = []; let submit: ElementHandle | undefined; - let sensitive = false, attempted = false, confirmed = false; + let sensitive = false, + attempted = false, + confirmed = false; try { for (const target of input.fields) fields.push(await element(page, target, origins)); submit = await element(page, input.submit, origins); @@ -31,16 +47,34 @@ export async function submitPayment(page: Page, input: CheckoutInput, origins: s for (let i = 0; i < fields.length; i++) { signal.throwIfAborted(); if (!origins.includes(httpsOrigin(page.url()))) throw new Error("Checkout page changed."); - await fields[i].evaluate((node, { value, origins }) => { - if (!node.isConnected || !origins.includes(location.origin) || !(node instanceof HTMLInputElement || node instanceof HTMLSelectElement)) throw new Error("Payment field changed."); - const prototype = node instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLSelectElement.prototype; - Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(node, value); - node.dispatchEvent(new Event("input", { bubbles: true })); - node.dispatchEvent(new Event("change", { bubbles: true })); - }, { value: values[i], origins }); + await fields[i].evaluate( + (node, { value, origins }) => { + if ( + !node.isConnected || + !origins.includes(location.origin) || + !(node instanceof HTMLInputElement || node instanceof HTMLSelectElement) + ) + throw new Error("Payment field changed."); + const prototype = + node instanceof HTMLInputElement + ? HTMLInputElement.prototype + : HTMLSelectElement.prototype; + Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(node, value); + node.dispatchEvent(new Event("input", { bubbles: true })); + node.dispatchEvent(new Event("change", { bubbles: true })); + }, + { value: values[i], origins }, + ); } signal.throwIfAborted(); - if (!origins.includes(httpsOrigin(page.url())) || !await submit.evaluate((node, allowed) => node.isConnected && allowed.includes(location.origin), origins)) throw new Error("Checkout page changed."); + if ( + !origins.includes(httpsOrigin(page.url())) || + !(await submit.evaluate( + (node, allowed) => node.isConnected && allowed.includes(location.origin), + origins, + )) + ) + throw new Error("Checkout page changed."); attempted = true; await submit.click({ timeout: 10_000, noWaitAfter: true }); if (input.confirmation) { @@ -49,34 +83,67 @@ export async function submitPayment(page: Page, input: CheckoutInput, origins: s const receipt = await element(page, input.confirmation, origins); confirmed = await receipt.isVisible(); await receipt.dispose(); - } catch { /* Submission alone does not establish payment success. */ } + } catch { + /* Submission alone does not establish payment success. */ + } } - return { id: input.id, status: confirmed ? "merchant_confirmation_observed" : "submission_attempted", verify_payment: true }; + return { + id: input.id, + status: confirmed ? "merchant_confirmation_observed" : "submission_attempted", + verify_payment: true, + }; } catch { signal.throwIfAborted(); if (attempted) return { id: input.id, status: "outcome_unknown", verify_payment: true }; - throw new Error("Checkout stopped before submission. Check the existing Link request and browser before retrying."); + throw new Error( + "Checkout stopped before submission. Check the existing Link request and browser before retrying.", + ); } } finally { if (sensitive) { - await Promise.all(fields.map(handle => handle.evaluate(node => { - if (node.isConnected && (node instanceof HTMLInputElement || node instanceof HTMLSelectElement)) node.value = ""; - }).catch(() => {}))); + await Promise.all( + fields.map((handle) => + handle + .evaluate((node) => { + if ( + node.isConnected && + (node instanceof HTMLInputElement || node instanceof HTMLSelectElement) + ) + node.value = ""; + }) + .catch(() => {}), + ), + ); } - await Promise.all([...fields, ...(submit ? [submit] : [])].map(handle => handle.dispose().catch(() => {}))); + await Promise.all( + [...fields, ...(submit ? [submit] : [])].map((handle) => handle.dispose().catch(() => {})), + ); } } -export async function checkoutInHostedPage(stagehand: StagehandPage, connectUrl: string, input: CheckoutInput, origins: string[], getCredentials: () => Promise, signal: AbortSignal) { +export async function checkoutInHostedPage( + stagehand: StagehandPage, + connectUrl: string, + input: CheckoutInput, + origins: string[], + getCredentials: () => Promise, + signal: AbortSignal, +) { const marker = `__bell_checkout_${randomUUID().replaceAll("-", "")}`; let bridge: Awaited> | undefined; try { let page: Page; try { - await stagehand.evaluate(name => Reflect.set(globalThis, name, true), marker); + await stagehand.evaluate((name) => Reflect.set(globalThis, name, true), marker); bridge = await chromium.connectOverCDP(connectUrl, { timeout: 15_000 }); - const pages = bridge.contexts().flatMap(context => context.pages()); - const matches = await Promise.all(pages.map(candidate => candidate.evaluate(name => Reflect.get(globalThis, name) === true, marker).catch(() => false))); + const pages = bridge.contexts().flatMap((context) => context.pages()); + const matches = await Promise.all( + pages.map((candidate) => + candidate + .evaluate((name) => Reflect.get(globalThis, name) === true, marker) + .catch(() => false), + ), + ); if (matches.filter(Boolean).length !== 1) throw new Error(); page = pages[matches.indexOf(true)]; } catch { @@ -85,7 +152,9 @@ export async function checkoutInHostedPage(stagehand: StagehandPage, connectUrl: } return await submitPayment(page, input, origins, getCredentials, signal); } finally { - await stagehand.evaluate(name => Reflect.deleteProperty(globalThis, name), marker).catch(() => {}); + await stagehand + .evaluate((name) => Reflect.deleteProperty(globalThis, name), marker) + .catch(() => {}); await bridge?.close().catch(() => {}); } } diff --git a/server/link/config.ts b/server/link/config.ts index 452c0e5..411d707 100644 --- a/server/link/config.ts +++ b/server/link/config.ts @@ -1,10 +1,15 @@ import { unseal as decryptRecord } from "../encrypted-record"; export class LinkSetupError extends Error { - constructor(readonly reason: "missing_configuration" | "invalid_configuration" | "credentials_unreadable", readonly setting?: string) { - super(reason === "credentials_unreadable" - ? "Link credentials could not be decrypted. Check the server encryption key." - : `Check ${setting} in the server configuration.`); + constructor( + readonly reason: "missing_configuration" | "invalid_configuration" | "credentials_unreadable", + readonly setting?: string, + ) { + super( + reason === "credentials_unreadable" + ? "Link credentials could not be decrypted. Check the server encryption key." + : `Check ${setting} in the server configuration.`, + ); this.name = "LinkSetupError"; } } @@ -27,24 +32,37 @@ export function linkConfig(): LinkConfig { }; const origin = required("BELL_PUBLIC_URL"); let url: URL; - try { url = new URL(origin); } catch { throw new LinkSetupError("invalid_configuration", "BELL_PUBLIC_URL"); } + try { + url = new URL(origin); + } catch { + throw new LinkSetupError("invalid_configuration", "BELL_PUBLIC_URL"); + } const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); - if (url.origin !== origin || (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))) + if ( + url.origin !== origin || + (url.protocol !== "https:" && !(loopback && url.protocol === "http:")) + ) throw new LinkSetupError("invalid_configuration", "BELL_PUBLIC_URL"); const encodedKey = required("LINK_TOKEN_ENCRYPTION_KEY"); const encryptionKey = Buffer.from(encodedKey, "base64"); if (encryptionKey.length !== 32 || encryptionKey.toString("base64") !== encodedKey) throw new LinkSetupError("invalid_configuration", "LINK_TOKEN_ENCRYPTION_KEY"); return { - databaseUrl: required("DATABASE_URL"), origin, + databaseUrl: required("DATABASE_URL"), + origin, redirectUri: `${origin}/api/link/oauth/callback`, - clientId: required("LINK_CLIENT_ID"), clientSecret: required("LINK_CLIENT_SECRET"), - publishableKey: required("STRIPE_PUBLISHABLE_KEY"), encryptionKey, + clientId: required("LINK_CLIENT_ID"), + clientSecret: required("LINK_CLIENT_SECRET"), + publishableKey: required("STRIPE_PUBLISHABLE_KEY"), + encryptionKey, }; } export { hash, seal } from "../encrypted-record"; export function unseal(value: string, key: Buffer, context: string): T { - try { return decryptRecord(value, key, context); } - catch { throw new LinkSetupError("credentials_unreadable", "LINK_TOKEN_ENCRYPTION_KEY"); } + try { + return decryptRecord(value, key, context); + } catch { + throw new LinkSetupError("credentials_unreadable", "LINK_TOKEN_ENCRYPTION_KEY"); + } } diff --git a/server/link/oauth.ts b/server/link/oauth.ts index 15e0701..0073632 100644 --- a/server/link/oauth.ts +++ b/server/link/oauth.ts @@ -6,18 +6,43 @@ import { } from "eve/connections"; import { z } from "zod"; import { hash, linkConfig, LinkSetupError, seal, unseal, type LinkConfig } from "./config"; -import { createLinkStore, ownerKey, type Grant, type LinkOwner, type LinkStore, type OwnerStore } from "./store"; +import { + createLinkStore, + ownerKey, + type Grant, + type LinkOwner, + type LinkStore, + type OwnerStore, +} from "./store"; const scopes = ["payment_methods.agentic", "userinfo:read"]; const tokenResponse = z.object({ - access_token: z.string().min(1), refresh_token: z.string().min(1), + access_token: z.string().min(1), + refresh_token: z.string().min(1), token_type: z.string().refine((value) => value.toLowerCase() === "bearer"), - expires_in: z.number().int().positive().max(365 * 24 * 60 * 60), + expires_in: z + .number() + .int() + .positive() + .max(365 * 24 * 60 * 60), scope: z.string(), }); -interface Tokens { accessToken: string; refreshToken: string; expiresAt: number; scopes: string[] } -interface Secrets { state: string; verifier: string; callbackUrl: string } -interface Callback { state: string; code?: string; error?: string } +interface Tokens { + accessToken: string; + refreshToken: string; + expiresAt: number; + scopes: string[]; +} +interface Secrets { + state: string; + verifier: string; + callbackUrl: string; +} +interface Callback { + state: string; + code?: string; + error?: string; +} export type LinkResume = { attemptId: string }; function failed(reason: string): never { @@ -25,17 +50,26 @@ function failed(reason: string): never { } export function linkOwner(principal: ConnectionPrincipal): LinkOwner { - if (principal.type !== "user" || principal.issuer !== "linq" - || !principal.id.startsWith("linq:") || principal.id.length <= 5 || principal.id.length > 512) + if ( + principal.type !== "user" || + principal.issuer !== "linq" || + !principal.id.startsWith("linq:") || + principal.id.length <= 5 || + principal.id.length > 512 + ) return failed("principal_required"); return { issuer: principal.issuer, id: principal.id }; } function parseCallback(params: Readonly>): Callback { const { state, code, error } = params; - if (!state || !/^[A-Za-z0-9_-]{43}$/.test(state) || Boolean(code) === Boolean(error) - || (code !== undefined && (code.length === 0 || code.length > 4096)) - || (error !== undefined && !/^[a-zA-Z0-9_]{1,100}$/.test(error))) + if ( + !state || + !/^[A-Za-z0-9_-]{43}$/.test(state) || + Boolean(code) === Boolean(error) || + (code !== undefined && (code.length === 0 || code.length > 4096)) || + (error !== undefined && !/^[a-zA-Z0-9_]{1,100}$/.test(error)) + ) return failed("invalid_callback"); return { state, ...(code ? { code } : { error }) }; } @@ -48,19 +82,33 @@ export class LinkOAuth { private readonly now: () => number = Date.now, ) {} - private tokenContext(owner: LinkOwner): string { return `link:tokens:${ownerKey(owner)}`; } - private attemptContext(owner: LinkOwner, id: string): string { return `link:attempt:${ownerKey(owner)}:${id}`; } + private tokenContext(owner: LinkOwner): string { + return `link:tokens:${ownerKey(owner)}`; + } + private attemptContext(owner: LinkOwner, id: string): string { + return `link:attempt:${ownerKey(owner)}:${id}`; + } private tokens(owner: LinkOwner, grant: Grant): Tokens { if (!grant.encrypted_tokens) return failed("connection_missing"); - return unseal(grant.encrypted_tokens, this.config.encryptionKey, this.tokenContext(owner)); + return unseal( + grant.encrypted_tokens, + this.config.encryptionKey, + this.tokenContext(owner), + ); } private encryptedTokens(owner: LinkOwner, tokens: Tokens): string { return seal(tokens, this.config.encryptionKey, this.tokenContext(owner)); } private callbackUrl(value: string): URL { const target = new URL(value); - if (!["https:", "http:"].includes(target.protocol) || target.username || target.password || target.hash - || !/^\/eve\/v1\/connections\/[^/]+\/callback\/[^/]+\/[^/]+$/.test(target.pathname)) return failed("invalid_callback_origin"); + if ( + !["https:", "http:"].includes(target.protocol) || + target.username || + target.password || + target.hash || + !/^\/eve\/v1\/connections\/[^/]+\/callback\/[^/]+\/[^/]+$/.test(target.pathname) + ) + return failed("invalid_callback_origin"); // This value comes only from Eve's server-side auth lifecycle, never an HTTP // return URL. withEve uses an internal dev port, and Vercel may use its project // domain. Forward the capability through Bell's configured public router while @@ -72,11 +120,22 @@ export class LinkOAuth { // the response is lost. Never expose upstream bodies or credentials in errors. try { return await this.fetcher(`https://login.link.com/auth/${path}`, { - method: "POST", redirect: "error", signal: AbortSignal.timeout(10_000), - headers: { authorization: `Bearer ${this.config.publishableKey}`, "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ client_id: this.config.clientId, client_secret: this.config.clientSecret, ...fields }), + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(10_000), + headers: { + authorization: `Bearer ${this.config.publishableKey}`, + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + ...fields, + }), }); - } catch { return failed("link_request_failed"); } + } catch { + return failed("link_request_failed"); + } } private async exchange(fields: Record): Promise { const response = await this.post("token", fields); @@ -84,7 +143,8 @@ export class LinkOAuth { const parsed = tokenResponse.safeParse(await response.json().catch(() => null)); if (!parsed.success) return failed("invalid_token_response"); return { - accessToken: parsed.data.access_token, refreshToken: parsed.data.refresh_token, + accessToken: parsed.data.access_token, + refreshToken: parsed.data.refresh_token, expiresAt: this.now() + parsed.data.expires_in * 1000, scopes: parsed.data.scope.split(/[ ,]+/).filter(Boolean), }; @@ -101,13 +161,21 @@ export class LinkOAuth { if (grant.status === "disconnecting") return failed("disconnect_pending"); if (grant.status !== "active") throw new ConnectionAuthorizationRequiredError("link"); let tokens = this.tokens(owner, grant); - if (!scopes.every((scope) => tokens.scopes.includes(scope))) throw new ConnectionAuthorizationRequiredError("link"); + if (!scopes.every((scope) => tokens.scopes.includes(scope))) + throw new ConnectionAuthorizationRequiredError("link"); if (tokens.expiresAt <= this.now() + 60_000) { // Commit before the remote call. A crash leaves the grant blocked, never // apparently active with a refresh token that may already have rotated. await db.saveGrant({ ...grant, status: "reconnect" }); - tokens = await this.exchange({ grant_type: "refresh_token", refresh_token: tokens.refreshToken }); - await db.saveGrant({ ...grant, status: scopes.every((scope) => tokens.scopes.includes(scope)) ? "active" : "reconnect", encrypted_tokens: this.encryptedTokens(owner, tokens) }); + tokens = await this.exchange({ + grant_type: "refresh_token", + refresh_token: tokens.refreshToken, + }); + await db.saveGrant({ + ...grant, + status: scopes.every((scope) => tokens.scopes.includes(scope)) ? "active" : "reconnect", + encrypted_tokens: this.encryptedTokens(owner, tokens), + }); } return this.tokenResult(tokens); }); @@ -129,8 +197,12 @@ export class LinkOAuth { if (grant.status === "disconnecting") return failed("disconnect_pending"); let attempt = await db.attemptForCallback(hash(callbackUrl)); if (attempt) { - if (attempt.generation !== grant.generation || attempt.status !== "pending" - || attempt.expires_at.getTime() <= this.now()) return failed("authorization_expired"); + if ( + attempt.generation !== grant.generation || + attempt.status !== "pending" || + attempt.expires_at.getTime() <= this.now() + ) + return failed("authorization_expired"); } else { // Revoke before requesting a replacement: revoking an old token after a // new consent could also revoke the new grant for the same Link account. @@ -140,24 +212,53 @@ export class LinkOAuth { await db.saveGrant({ ...grant, generation, status: "reconnect" }); await db.clearAttempts(); const id = randomUUID(); - const secrets: Secrets = { state: randomBytes(32).toString("base64url"), verifier: randomBytes(32).toString("base64url"), callbackUrl }; + const secrets: Secrets = { + state: randomBytes(32).toString("base64url"), + verifier: randomBytes(32).toString("base64url"), + callbackUrl, + }; attempt = { - id, issuer: owner.issuer, principal_id: owner.id, generation, - state_hash: hash(secrets.state), callback_hash: hash(callbackUrl), result_hash: null, - encrypted_secrets: seal(secrets, this.config.encryptionKey, this.attemptContext(owner, id)), - status: "pending", expires_at: new Date(this.now() + 10 * 60_000), + id, + issuer: owner.issuer, + principal_id: owner.id, + generation, + state_hash: hash(secrets.state), + callback_hash: hash(callbackUrl), + result_hash: null, + encrypted_secrets: seal( + secrets, + this.config.encryptionKey, + this.attemptContext(owner, id), + ), + status: "pending", + expires_at: new Date(this.now() + 10 * 60_000), }; await db.saveAttempt(attempt); } - const secrets = unseal(attempt.encrypted_secrets!, this.config.encryptionKey, this.attemptContext(owner, attempt.id)); + const secrets = unseal( + attempt.encrypted_secrets!, + this.config.encryptionKey, + this.attemptContext(owner, attempt.id), + ); const url = new URL("https://login.link.com/auth"); url.search = new URLSearchParams({ - key: this.config.publishableKey, client_id: this.config.clientId, redirect_uri: this.config.redirectUri, - response_type: "code", scope: scopes.join(" "), state: secrets.state, - code_challenge: hash(secrets.verifier), code_challenge_method: "S256", + key: this.config.publishableKey, + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + response_type: "code", + scope: scopes.join(" "), + state: secrets.state, + code_challenge: hash(secrets.verifier), + code_challenge_method: "S256", }).toString(); return { - challenge: { displayName: "Link", url: url.href, expiresAt: attempt.expires_at.toISOString(), instructions: "Connect your wallet, then return to this text conversation. Keep this link private." }, + challenge: { + displayName: "Link", + url: url.href, + expiresAt: attempt.expires_at.toISOString(), + instructions: + "Connect your wallet, then return to this text conversation. Keep this link private.", + }, resume: { attemptId: attempt.id }, }; }); @@ -172,11 +273,22 @@ export class LinkOAuth { const attempt = await db.attempt(found.id); const grant = await db.grant(); const resultHash = hash(JSON.stringify(callback)); - if (!attempt || attempt.generation !== grant.generation - || !(attempt.status === "pending" || (attempt.status === "forwarded" && attempt.result_hash === resultHash)) - || attempt.expires_at.getTime() <= this.now() || !attempt.encrypted_secrets) + if ( + !attempt || + attempt.generation !== grant.generation || + !( + attempt.status === "pending" || + (attempt.status === "forwarded" && attempt.result_hash === resultHash) + ) || + attempt.expires_at.getTime() <= this.now() || + !attempt.encrypted_secrets + ) return failed("authorization_expired"); - const secrets = unseal(attempt.encrypted_secrets, this.config.encryptionKey, this.attemptContext(owner, attempt.id)); + const secrets = unseal( + attempt.encrypted_secrets, + this.config.encryptionKey, + this.attemptContext(owner, attempt.id), + ); const target = this.callbackUrl(secrets.callbackUrl); for (const [key, value] of Object.entries(callback)) target.searchParams.set(key, value); await db.saveAttempt({ ...attempt, status: "forwarded", result_hash: resultHash }); @@ -184,15 +296,25 @@ export class LinkOAuth { }); } - async complete(principal: ConnectionPrincipal, callbackUrl: string, resume: LinkResume | undefined, params: Readonly>) { + async complete( + principal: ConnectionPrincipal, + callbackUrl: string, + resume: LinkResume | undefined, + params: Readonly>, + ) { const owner = linkOwner(principal); const callback = parseCallback(params); if (!resume || !z.uuid().safeParse(resume.attemptId).success) return failed("invalid_state"); return this.store.withOwner(owner, async (db) => { const attempt = await db.attempt(resume.attemptId); const grant = await db.grant(); - if (!attempt || attempt.generation !== grant.generation || attempt.state_hash !== hash(callback.state) - || attempt.callback_hash !== hash(callbackUrl) || attempt.result_hash !== hash(JSON.stringify(callback))) + if ( + !attempt || + attempt.generation !== grant.generation || + attempt.state_hash !== hash(callback.state) || + attempt.callback_hash !== hash(callbackUrl) || + attempt.result_hash !== hash(JSON.stringify(callback)) + ) return failed("invalid_state"); // Durable step replay after successful persistence must not exchange again. if (attempt.status === "completed" && grant.status === "active") { @@ -200,19 +322,31 @@ export class LinkOAuth { if (tokens.expiresAt <= this.now()) return failed("authorization_expired"); return this.tokenResult(tokens); } - if (attempt.status !== "forwarded" || attempt.expires_at.getTime() <= this.now() || !attempt.encrypted_secrets) + if ( + attempt.status !== "forwarded" || + attempt.expires_at.getTime() <= this.now() || + !attempt.encrypted_secrets + ) return failed("authorization_expired"); if (callback.error) { await db.saveAttempt({ ...attempt, status: "failed", encrypted_secrets: null }); - return failed(callback.error === "access_denied" ? "access_denied" : "authorization_failed"); + return failed( + callback.error === "access_denied" ? "access_denied" : "authorization_failed", + ); } - const secrets = unseal(attempt.encrypted_secrets, this.config.encryptionKey, this.attemptContext(owner, attempt.id)); + const secrets = unseal( + attempt.encrypted_secrets, + this.config.encryptionKey, + this.attemptContext(owner, attempt.id), + ); await db.saveAttempt({ ...attempt, status: "exchanging" }); let tokens: Tokens; try { tokens = await this.exchange({ - grant_type: "authorization_code", redirect_uri: this.config.redirectUri, - code: callback.code!, code_verifier: secrets.verifier, + grant_type: "authorization_code", + redirect_uri: this.config.redirectUri, + code: callback.code!, + code_verifier: secrets.verifier, }); } catch (error) { await db.saveAttempt({ ...attempt, status: "failed", encrypted_secrets: null }); @@ -220,7 +354,11 @@ export class LinkOAuth { } const authorized = scopes.every((scope) => tokens.scopes.includes(scope)); await db.saveAuthorization( - { ...grant, status: authorized ? "active" : "reconnect", encrypted_tokens: this.encryptedTokens(owner, tokens) }, + { + ...grant, + status: authorized ? "active" : "reconnect", + encrypted_tokens: this.encryptedTokens(owner, tokens), + }, { ...attempt, status: authorized ? "completed" : "failed", encrypted_secrets: null }, ); return this.tokenResult(tokens); @@ -241,7 +379,10 @@ export class LinkOAuth { await db.clearAttempts(); if (grant.encrypted_tokens) { const tokens = this.tokens(owner, grant); - const response = await this.post("revoke", { token: tokens.refreshToken, token_type_hint: "refresh_token" }); + const response = await this.post("revoke", { + token: tokens.refreshToken, + token_type_hint: "refresh_token", + }); if (!response.ok) return failed("revocation_failed"); } const disconnected: Grant = { ...blocked, status: "disconnected", encrypted_tokens: null }; @@ -260,20 +401,51 @@ export function getLinkOAuth(): LinkOAuth { } export async function safeLinkCall(operation: () => Promise): Promise { - try { return await operation(); } catch (error) { - if (error instanceof Error && ["ConnectionAuthorizationRequiredError", "ConnectionAuthorizationFailedError"].includes(error.name)) throw error; + try { + return await operation(); + } catch (error) { + if ( + error instanceof Error && + ["ConnectionAuthorizationRequiredError", "ConnectionAuthorizationFailedError"].includes( + error.name, + ) + ) + throw error; // Database, URL, and transport errors must not disclose connection strings or tokens. const code = error && typeof error === "object" && "code" in error ? error.code : undefined; - const reason = error instanceof LinkSetupError ? error.reason - : code === "42P01" || code === "42703" ? "database_migration_required" - : ["ECONNREFUSED", "ETIMEDOUT", "ENOTFOUND", "ECONNRESET", "28P01", "28000", "53300", "57P01", "57P02", "57P03", "08001", "08006"].includes(String(code)) - ? "database_unavailable" : "unexpected_error"; - console.error(JSON.stringify({ event: "link.error", reason, - ...(error instanceof LinkSetupError && error.setting ? { setting: error.setting } : {}), - })); + const reason = + error instanceof LinkSetupError + ? error.reason + : code === "42P01" || code === "42703" + ? "database_migration_required" + : [ + "ECONNREFUSED", + "ETIMEDOUT", + "ENOTFOUND", + "ECONNRESET", + "28P01", + "28000", + "53300", + "57P01", + "57P02", + "57P03", + "08001", + "08006", + ].includes(String(code)) + ? "database_unavailable" + : "unexpected_error"; + console.error( + JSON.stringify({ + event: "link.error", + reason, + ...(error instanceof LinkSetupError && error.setting ? { setting: error.setting } : {}), + }), + ); throw new ConnectionAuthorizationFailedError("link", { - reason: "link_unavailable", retryable: false, - message: "Link is unavailable. Ask the operator to check the server's link.error diagnostic and Link setup guide.", + reason: "link_unavailable", + retryable: false, + message: + "Link is unavailable. Ask the operator to check the server's link.error diagnostic and Link setup guide.", }); } } diff --git a/server/link/payment.ts b/server/link/payment.ts index d42d7b6..234ba42 100644 --- a/server/link/payment.ts +++ b/server/link/payment.ts @@ -1,27 +1,83 @@ import { z } from "zod"; -const selector = z.string().min(1).max(500).refine(value => !value.includes(">>") && !/^(xpath|text|role)=|^\//i.test(value), "Use an exact CSS selector."); +const selector = z + .string() + .min(1) + .max(500) + .refine( + (value) => !value.includes(">>") && !/^(xpath|text|role)=|^\//i.test(value), + "Use an exact CSS selector.", + ); const target = z.strictObject({ selector, frame: selector.optional() }); export const checkoutInput = z.strictObject({ - id: z.string().min(1).max(255).regex(/^[A-Za-z0-9_-]+$/), - amount: z.number().int().positive(), currency: z.string().regex(/^[a-z]{3}$/), + id: z + .string() + .min(1) + .max(255) + .regex(/^[A-Za-z0-9_-]+$/), + amount: z.number().int().positive(), + currency: z.string().regex(/^[a-z]{3}$/), credential: z.literal("card"), - fields: z.array(target.extend({ field: z.enum(["number", "cvc", "exp_month", "exp_year", "expiry", "name", "line1", "line2", "city", "state", "postal_code", "country"]) })).min(1).max(16), - submit: target, confirmation: target.optional(), + fields: z + .array( + target.extend({ + field: z.enum([ + "number", + "cvc", + "exp_month", + "exp_year", + "expiry", + "name", + "line1", + "line2", + "city", + "state", + "postal_code", + "country", + ]), + }), + ) + .min(1) + .max(16), + submit: target, + confirmation: target.optional(), }); export type CheckoutInput = z.infer; const request = z.object({ - id: z.string(), status: z.string(), merchant_url: z.string().url(), amount: z.number().int().positive(), - currency: z.string(), expires_at: z.number().optional(), credential_type: z.string().optional(), + id: z.string(), + status: z.string(), + merchant_url: z.string().url(), + amount: z.number().int().positive(), + currency: z.string(), + expires_at: z.number().optional(), + credential_type: z.string().optional(), +}); +const address = z.object({ + name: z.string(), + line1: z.string(), + line2: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + postal_code: z.string().optional(), + country: z.string(), }); -const address = z.object({ name: z.string(), line1: z.string(), line2: z.string().optional(), city: z.string().optional(), state: z.string().optional(), postal_code: z.string().optional(), country: z.string() }); const credentials = z.object({ - card: z.object({ number: z.string().min(1), cvc: z.string().optional(), exp_month: z.number().int().min(1).max(12), exp_year: z.number().int().min(2000), billing_address: address.optional(), valid_until: z.string().optional() }).optional(), + card: z + .object({ + number: z.string().min(1), + cvc: z.string().optional(), + exp_month: z.number().int().min(1).max(12), + exp_year: z.number().int().min(2000), + billing_address: address.optional(), + valid_until: z.string().optional(), + }) + .optional(), }); export function httpsOrigin(value: string): string { const url = new URL(value); - if (url.protocol !== "https:" || url.username || url.password) throw new Error("Checkout requires an approved HTTPS origin."); + if (url.protocol !== "https:" || url.username || url.password) + throw new Error("Checkout requires an approved HTTPS origin."); return url.origin; } @@ -29,12 +85,21 @@ export function paymentOrigins(value: unknown, input: CheckoutInput): string[] { const parsed = request.safeParse(value); if (!parsed.success) throw new Error("Link did not return complete purchase metadata."); const data = parsed.data; - if (data.id !== input.id || data.status !== "approved" || data.amount !== input.amount || data.currency.toLowerCase() !== input.currency - || (data.credential_type && data.credential_type !== input.credential) - || (data.expires_at !== undefined && data.expires_at * 1000 <= Date.now())) - throw new Error("The current Link approval does not match this checkout. Verify the existing request before proceeding."); + if ( + data.id !== input.id || + data.status !== "approved" || + data.amount !== input.amount || + data.currency.toLowerCase() !== input.currency || + (data.credential_type && data.credential_type !== input.credential) || + (data.expires_at !== undefined && data.expires_at * 1000 <= Date.now()) + ) + throw new Error( + "The current Link approval does not match this checkout. Verify the existing request before proceeding.", + ); const merchant = httpsOrigin(data.merchant_url); - const configured = z.record(z.string(), z.array(z.string())).parse(JSON.parse(process.env.LINK_CHECKOUT_ORIGINS ?? "{}")); + const configured = z + .record(z.string(), z.array(z.string())) + .parse(JSON.parse(process.env.LINK_CHECKOUT_ORIGINS ?? "{}")); return [...new Set([merchant, ...(configured[merchant] ?? []).map(httpsOrigin)])]; } @@ -43,16 +108,23 @@ export function paymentValues(value: unknown, input: CheckoutInput): string[] { if (!parsed.success) throw new Error("Link returned an unsupported payment credential."); const card = parsed.data.card; if (!card) throw new Error("The approved payment credential is unavailable."); - if (card.valid_until && (!Number.isFinite(Date.parse(card.valid_until)) || Date.parse(card.valid_until) <= Date.now())) + if ( + card.valid_until && + (!Number.isFinite(Date.parse(card.valid_until)) || Date.parse(card.valid_until) <= Date.now()) + ) throw new Error("The approved payment credential has expired."); const values: Record = { - ...card.billing_address, number: card.number, cvc: card.cvc, - exp_month: String(card.exp_month).padStart(2, "0"), exp_year: String(card.exp_year), + ...card.billing_address, + number: card.number, + cvc: card.cvc, + exp_month: String(card.exp_month).padStart(2, "0"), + exp_year: String(card.exp_year), expiry: `${String(card.exp_month).padStart(2, "0")}/${String(card.exp_year).slice(-2)}`, }; return input.fields.map(({ field }) => { const resolved = values[field]; - if (!resolved || resolved.length > 4096) throw new Error("A required payment field is unavailable."); + if (!resolved || resolved.length > 4096) + throw new Error("A required payment field is unavailable."); return resolved; }); } diff --git a/server/link/sandbox.ts b/server/link/sandbox.ts index 78c73a5..30543f0 100644 --- a/server/link/sandbox.ts +++ b/server/link/sandbox.ts @@ -7,16 +7,24 @@ export const isLinkSandbox = () => process.env.LINK_SANDBOX === "1"; export function sandboxToken(principal: ConnectionPrincipal): { token: string } { const token = process.env.LINK_SANDBOX_ACCESS_TOKEN?.trim(); - if (principal.type !== "user" || principal.issuer !== "linq" - || !principal.id.startsWith("linq:") || principal.id !== process.env.LINK_SANDBOX_PRINCIPAL_ID?.trim()) + if ( + principal.type !== "user" || + principal.issuer !== "linq" || + !principal.id.startsWith("linq:") || + principal.id !== process.env.LINK_SANDBOX_PRINCIPAL_ID?.trim() + ) throw new Error("This sender is not the configured Link sandbox operator."); if (!token) throw new Error("Inject LINK_SANDBOX_ACCESS_TOKEN into the sandbox process."); return { token }; } -export function sandboxPurchase(input: T): T { +export function sandboxPurchase( + input: T, +): T { if (!isLinkSandbox()) return input; - const key = createHash("sha256").update(input.idempotency_key ?? randomUUID()).digest("hex"); + const key = createHash("sha256") + .update(input.idempotency_key ?? randomUUID()) + .digest("hex"); return { ...input, test: true, idempotency_key: `${run}:${key}` }; } @@ -29,5 +37,8 @@ export function isSandboxRequest(id: string): boolean { } export function assertSandboxRequest(id: string): void { - if (!isSandboxRequest(id)) throw new Error("Sandbox access is limited to test requests created by this process. Do not retry an uncertain purchase after restarting."); + if (!isSandboxRequest(id)) + throw new Error( + "Sandbox access is limited to test requests created by this process. Do not retry an uncertain purchase after restarting.", + ); } diff --git a/server/link/store.ts b/server/link/store.ts index 33ebef7..04386cb 100644 --- a/server/link/store.ts +++ b/server/link/store.ts @@ -1,7 +1,10 @@ import { Pool, type PoolClient } from "pg"; import { createPool, withAdvisoryLock } from "../postgres"; -export interface LinkOwner { issuer: string; id: string } +export interface LinkOwner { + issuer: string; + id: string; +} export interface Grant { generation: number; status: "active" | "reconnect" | "disconnecting" | "disconnected"; @@ -25,7 +28,10 @@ export function ownerKey(owner: LinkOwner): string { } export class OwnerStore { - constructor(private readonly client: PoolClient, readonly owner: LinkOwner) {} + constructor( + private readonly client: PoolClient, + readonly owner: LinkOwner, + ) {} async grant(): Promise { await this.client.query( @@ -59,17 +65,21 @@ export class OwnerStore { } async attempt(id: string): Promise { - return (await this.client.query( - "SELECT * FROM bell_link_oauth_attempts WHERE id = $1 AND issuer = $2 AND principal_id = $3", - [id, this.owner.issuer, this.owner.id], - )).rows[0]; + return ( + await this.client.query( + "SELECT * FROM bell_link_oauth_attempts WHERE id = $1 AND issuer = $2 AND principal_id = $3", + [id, this.owner.issuer, this.owner.id], + ) + ).rows[0]; } async attemptForCallback(callbackHash: string): Promise { - return (await this.client.query( - "SELECT * FROM bell_link_oauth_attempts WHERE callback_hash = $1 AND issuer = $2 AND principal_id = $3", - [callbackHash, this.owner.issuer, this.owner.id], - )).rows[0]; + return ( + await this.client.query( + "SELECT * FROM bell_link_oauth_attempts WHERE callback_hash = $1 AND issuer = $2 AND principal_id = $3", + [callbackHash, this.owner.issuer, this.owner.id], + ) + ).rows[0]; } async saveAttempt(attempt: Attempt): Promise { @@ -80,8 +90,18 @@ export class OwnerStore { ON CONFLICT (id) DO UPDATE SET result_hash = EXCLUDED.result_hash, encrypted_secrets = EXCLUDED.encrypted_secrets, status = EXCLUDED.status WHERE bell_link_oauth_attempts.issuer = EXCLUDED.issuer AND bell_link_oauth_attempts.principal_id = EXCLUDED.principal_id`, - [attempt.id, this.owner.issuer, this.owner.id, attempt.generation, attempt.state_hash, - attempt.callback_hash, attempt.result_hash, attempt.encrypted_secrets, attempt.status, attempt.expires_at], + [ + attempt.id, + this.owner.issuer, + this.owner.id, + attempt.generation, + attempt.state_hash, + attempt.callback_hash, + attempt.result_hash, + attempt.encrypted_secrets, + attempt.status, + attempt.expires_at, + ], ); } @@ -97,13 +117,18 @@ export class LinkStore { constructor(readonly pool: Pool) {} async findAttempt(stateHash: string): Promise { - return (await this.pool.query( - "SELECT * FROM bell_link_oauth_attempts WHERE state_hash = $1", [stateHash], - )).rows[0]; + return ( + await this.pool.query( + "SELECT * FROM bell_link_oauth_attempts WHERE state_hash = $1", + [stateHash], + ) + ).rows[0]; } async withOwner(owner: LinkOwner, operation: (store: OwnerStore) => Promise): Promise { - return withAdvisoryLock(this.pool, ownerKey(owner), (client) => operation(new OwnerStore(client, owner))); + return withAdvisoryLock(this.pool, ownerKey(owner), (client) => + operation(new OwnerStore(client, owner)), + ); } } diff --git a/server/monitors/config.ts b/server/monitors/config.ts index 26be3ef..98cf14d 100644 --- a/server/monitors/config.ts +++ b/server/monitors/config.ts @@ -1,15 +1,34 @@ import { z } from "zod"; import { publicUrl } from "../parallel"; -export interface Owner { issuer: string; id: string } -export interface Destination { chat: string; sender: string; line: string } +export interface Owner { + issuer: string; + id: string; +} +export interface Destination { + chat: string; + sender: string; + line: string; +} export const queryInput = z.string().trim().min(1).max(2_000); const phone = z.string().regex(/^\+[1-9]\d{1,14}$/); -export const destinationSchema = z.object({ chat: z.string().min(1).max(256), sender: phone, line: phone }); +export const destinationSchema = z.object({ + chat: z.string().min(1).max(256), + sender: phone, + line: phone, +}); export function allowed(destination: Destination): boolean { const line = phone.safeParse(process.env.BELL_LINQ_PHONE_NUMBER?.trim()); - const senders = z.array(phone).nonempty().safeParse((process.env.BELL_LINQ_ALLOWED_SENDERS ?? "").split(",").map(s => s.trim())); - return line.success && senders.success && line.data === destination.line && senders.data.includes(destination.sender); + const senders = z + .array(phone) + .nonempty() + .safeParse((process.env.BELL_LINQ_ALLOWED_SENDERS ?? "").split(",").map((s) => s.trim())); + return ( + line.success && + senders.success && + line.data === destination.line && + senders.data.includes(destination.sender) + ); } export function required(name: string): string { const value = process.env[name]?.trim(); @@ -17,26 +36,53 @@ export function required(name: string): string { return value; } export function creationOrigin(): string { - for (const name of ["DATABASE_URL", "PARALLEL_API_KEY", "PARALLEL_WEBHOOK_SECRET", "CRON_SECRET", "LINQ_API_KEY"]) required(name); - const origin = required("BELL_PUBLIC_URL"), url = new URL(origin); - if (!publicUrl.safeParse(origin).success || url.protocol !== "https:" || url.origin !== origin || !url.hostname.includes(".") || /(^|\.)(localhost|local|internal|test|invalid)$/.test(url.hostname)) throw new Error("Watches require a public HTTPS BELL_PUBLIC_URL origin."); + for (const name of [ + "DATABASE_URL", + "PARALLEL_API_KEY", + "PARALLEL_WEBHOOK_SECRET", + "CRON_SECRET", + "LINQ_API_KEY", + ]) + required(name); + const origin = required("BELL_PUBLIC_URL"), + url = new URL(origin); + if ( + !publicUrl.safeParse(origin).success || + url.protocol !== "https:" || + url.origin !== origin || + !url.hostname.includes(".") || + /(^|\.)(localhost|local|internal|test|invalid)$/.test(url.hostname) + ) + throw new Error("Watches require a public HTTPS BELL_PUBLIC_URL origin."); return origin; } export function assertOwner(owner: Owner): void { - if (owner.issuer !== "linq" || !/^linq:.{1,507}$/.test(owner.id)) throw new Error("Watches require an authenticated Linq sender."); + if (owner.issuer !== "linq" || !/^linq:.{1,507}$/.test(owner.id)) + throw new Error("Watches require an authenticated Linq sender."); } -export const frequencyInput = z.string().regex(/^[1-9]\d{0,2}[hdw]$/, "Use whole hours, days, or weeks, between 1h and 30d.").refine(value => { - const hours = Number(value.slice(0, -1)) * ({ h: 1, d: 24, w: 168 }[value.slice(-1)] ?? 0); - return hours >= 1 && hours <= 720; -}, "Frequency must be between 1h and 30d.").transform(value => { - const hours = Number(value.slice(0, -1)) * ({ h: 1, d: 24, w: 168 }[value.slice(-1)]!); - return hours % 168 === 0 ? `${hours / 168}w` : hours % 24 === 0 ? `${hours / 24}d` : `${hours}h`; -}); +export const frequencyInput = z + .string() + .regex(/^[1-9]\d{0,2}[hdw]$/, "Use whole hours, days, or weeks, between 1h and 30d.") + .refine((value) => { + const hours = Number(value.slice(0, -1)) * ({ h: 1, d: 24, w: 168 }[value.slice(-1)] ?? 0); + return hours >= 1 && hours <= 720; + }, "Frequency must be between 1h and 30d.") + .transform((value) => { + const hours = Number(value.slice(0, -1)) * { h: 1, d: 24, w: 168 }[value.slice(-1)]!; + return hours % 168 === 0 + ? `${hours / 168}w` + : hours % 24 === 0 + ? `${hours / 24}d` + : `${hours}h`; + }); -export const monitorTimestamp = z.string().datetime({ offset: true }).transform(value => { - // Preserve sub-millisecond precision while giving equivalent instants one recovery identity. - const fraction = (value.match(/\.(\d+)/)?.[1] ?? "").replace(/0+$/, ""); - const seconds = new Date(value.replace(/\.\d+/, "")).toISOString().slice(0, 19); - return `${seconds}${fraction ? `.${fraction}` : ""}Z`; -}); +export const monitorTimestamp = z + .string() + .datetime({ offset: true }) + .transform((value) => { + // Preserve sub-millisecond precision while giving equivalent instants one recovery identity. + const fraction = (value.match(/\.(\d+)/)?.[1] ?? "").replace(/0+$/, ""); + const seconds = new Date(value.replace(/\.\d+/, "")).toISOString().slice(0, 19); + return `${seconds}${fraction ? `.${fraction}` : ""}Z`; + }); diff --git a/server/monitors/http.ts b/server/monitors/http.ts index b1f349d..eb9428f 100644 --- a/server/monitors/http.ts +++ b/server/monitors/http.ts @@ -4,23 +4,38 @@ import { ZodError } from "zod"; import { monitors, type MonitorService } from "./service"; import { readBounded } from "./provider"; -export async function receiveWebhook(request: Request, service: () => MonitorService = monitors): Promise { - if (!process.env.PARALLEL_WEBHOOK_SECRET?.trim()) return new Response("Unavailable", { status: 503 }); - if (Number(request.headers.get("content-length")) > 65_536) return new Response("Too large", { status: 413 }); +export async function receiveWebhook( + request: Request, + service: () => MonitorService = monitors, +): Promise { + if (!process.env.PARALLEL_WEBHOOK_SECRET?.trim()) + return new Response("Unavailable", { status: 503 }); + if (Number(request.headers.get("content-length")) > 65_536) + return new Response("Too large", { status: 413 }); try { const body = await readBounded(new Response(request.body), 65_536); await service().ingest(body, Object.fromEntries(request.headers)); return new Response(null, { status: 204 }); } catch (error) { - if (error instanceof WebhookVerificationError) return new Response("Invalid signature", { status: 401 }); - if (error instanceof ZodError || error instanceof SyntaxError) return new Response("Invalid event", { status: 400 }); + if (error instanceof WebhookVerificationError) + return new Response("Invalid signature", { status: 401 }); + if (error instanceof ZodError || error instanceof SyntaxError) + return new Response("Invalid event", { status: 400 }); return new Response("Unavailable", { status: 503 }); } } -export async function processMonitors(request: Request, service: () => MonitorService = monitors): Promise { - const secret = process.env.CRON_SECRET?.trim(), supplied = Buffer.from(request.headers.get("authorization") ?? ""); +export async function processMonitors( + request: Request, + service: () => MonitorService = monitors, +): Promise { + const secret = process.env.CRON_SECRET?.trim(), + supplied = Buffer.from(request.headers.get("authorization") ?? ""); const expected = Buffer.from(`Bearer ${secret}`); - if (!secret || expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) return new Response("Unauthorized", { status: 401 }); - try { return Response.json(await service().process(), { headers: { "Cache-Control": "no-store" } }); } - catch { return new Response("Unavailable", { status: 503 }); } + if (!secret || expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) + return new Response("Unauthorized", { status: 401 }); + try { + return Response.json(await service().process(), { headers: { "Cache-Control": "no-store" } }); + } catch { + return new Response("Unavailable", { status: 503 }); + } } diff --git a/server/monitors/provider.ts b/server/monitors/provider.ts index 1cfd619..9f63d1c 100644 --- a/server/monitors/provider.ts +++ b/server/monitors/provider.ts @@ -2,72 +2,175 @@ import { z } from "zod"; import { publicUrl } from "../parallel"; import { type Destination, required, frequencyInput, monitorTimestamp } from "./config"; -const remoteSchema = z.object({ monitor_id: z.string().min(1), status: z.enum(["active", "cancelled"]), frequency: frequencyInput, metadata: z.record(z.string(), z.string()).nullish() }); +const remoteSchema = z.object({ + monitor_id: z.string().min(1), + status: z.enum(["active", "cancelled"]), + frequency: frequencyInput, + metadata: z.record(z.string(), z.string()).nullish(), +}); const citation = z.object({ url: publicUrl.nullable().catch(null) }); export const eventSchema = z.discriminatedUnion("event_type", [ - z.object({ event_type: z.literal("event_stream"), event_id: z.string(), event_group_id: z.string(), event_date: z.string().nullish(), output: z.object({ type: z.enum(["text", "json"]), content: z.union([z.string(), z.record(z.string(), z.unknown())]), basis: z.array(z.object({ citations: z.array(citation).default([]) })).default([]) }) }), + z.object({ + event_type: z.literal("event_stream"), + event_id: z.string(), + event_group_id: z.string(), + event_date: z.string().nullish(), + output: z.object({ + type: z.enum(["text", "json"]), + content: z.union([z.string(), z.record(z.string(), z.unknown())]), + basis: z.array(z.object({ citations: z.array(citation).default([]) })).default([]), + }), + }), z.object({ event_type: z.literal("completion"), timestamp: monitorTimestamp }), - z.object({ event_type: z.literal("error"), timestamp: monitorTimestamp, error_message: z.string() }), + z.object({ + event_type: z.literal("error"), + timestamp: monitorTimestamp, + error_message: z.string(), + }), ]); export type MonitorEvent = z.infer; -export interface RemoteMonitor { monitor_id: string; status: string; frequency: string; metadata?: Record | null } +export interface RemoteMonitor { + monitor_id: string; + status: string; + frequency: string; + metadata?: Record | null; +} export interface MonitorProvider { - create(query: string, watchId: string, origin: string, frequency?: string): Promise; + create( + query: string, + watchId: string, + origin: string, + frequency?: string, + ): Promise; list(cursor?: string): Promise<{ monitors: RemoteMonitor[]; next_cursor?: string | null }>; - events(id: string, group?: string, cursor?: string): Promise<{ events: unknown[]; next_cursor?: string | null }>; + events( + id: string, + group?: string, + cursor?: string, + ): Promise<{ events: unknown[]; next_cursor?: string | null }>; cancel(id: string): Promise; update(id: string, frequency: string): Promise; } export class ProviderError extends Error { - constructor(readonly status: number, readonly retryAfter = 0) { super(`Provider HTTP ${status}`); } + constructor( + readonly status: number, + readonly retryAfter = 0, + ) { + super(`Provider HTTP ${status}`); + } } export async function readBounded(response: Response, maxBytes = 1_048_576): Promise { const reader = response.body?.getReader(); if (!reader) return ""; - const chunks: Uint8Array[] = []; let bytes = 0; + const chunks: Uint8Array[] = []; + let bytes = 0; try { while (true) { - const { done, value } = await reader.read(); if (done) break; + const { done, value } = await reader.read(); + if (done) break; bytes += value.byteLength; if (bytes > maxBytes) throw new Error("Response exceeds size limit"); chunks.push(value); } return Buffer.concat(chunks).toString("utf8"); - } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } } -export async function request(url: string, key: string, method = "GET", body?: unknown, bearer = false): Promise { - const response = await fetch(url, { method, headers: { "Content-Type": "application/json", ...(bearer ? { Authorization: `Bearer ${key}` } : { "x-api-key": key }) }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(8_000), redirect: "error", cache: "no-store" }); +export async function request( + url: string, + key: string, + method = "GET", + body?: unknown, + bearer = false, +): Promise { + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + ...(bearer ? { Authorization: `Bearer ${key}` } : { "x-api-key": key }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(8_000), + redirect: "error", + cache: "no-store", + }); if (!response.ok) { await response.body?.cancel(); - const value = response.headers.get("retry-after"), seconds = Number(value); - const delay = value ? (Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now()) : 0; + const value = response.headers.get("retry-after"), + seconds = Number(value); + const delay = value + ? Number.isFinite(seconds) + ? seconds * 1000 + : Date.parse(value) - Date.now() + : 0; throw new ProviderError(response.status, Math.max(0, Math.min(delay || 0, 86_400_000))); } const text = await readBounded(response); return text ? JSON.parse(text) : null; } const path = (id: string) => `/monitors/${encodeURIComponent(id)}`; -const call = (endpoint: string, method = "GET", body?: unknown) => request(`https://api.parallel.ai/v1${endpoint}`, required("PARALLEL_API_KEY"), method, body); +const call = (endpoint: string, method = "GET", body?: unknown) => + request(`https://api.parallel.ai/v1${endpoint}`, required("PARALLEL_API_KEY"), method, body); export const parallelMonitors: MonitorProvider = { async create(query, watchId, origin, frequency = "1d") { - return remoteSchema.parse(await call("/monitors", "POST", { type: "event_stream", frequency: frequencyInput.parse(frequency), processor: "lite", settings: { query, include_backfill: false }, metadata: { bell_watch_id: watchId }, webhook: { url: `${origin}/api/parallel/monitors/webhook`, event_types: ["monitor.event.detected", "monitor.execution.completed", "monitor.execution.failed"] } })); + return remoteSchema.parse( + await call("/monitors", "POST", { + type: "event_stream", + frequency: frequencyInput.parse(frequency), + processor: "lite", + settings: { query, include_backfill: false }, + metadata: { bell_watch_id: watchId }, + webhook: { + url: `${origin}/api/parallel/monitors/webhook`, + event_types: [ + "monitor.event.detected", + "monitor.execution.completed", + "monitor.execution.failed", + ], + }, + }), + ); }, async list(cursor) { const params = new URLSearchParams({ type: "event_stream", limit: "100" }); - params.append("status", "active"); params.append("status", "cancelled"); if (cursor) params.set("cursor", cursor); - return z.object({ monitors: z.array(remoteSchema), next_cursor: z.string().nullish() }).parse(await call(`/monitors?${params}`)); + params.append("status", "active"); + params.append("status", "cancelled"); + if (cursor) params.set("cursor", cursor); + return z + .object({ monitors: z.array(remoteSchema), next_cursor: z.string().nullish() }) + .parse(await call(`/monitors?${params}`)); }, async events(id, group, cursor) { - const params = new URLSearchParams(group ? { event_group_id: group } : { include_completions: "true", limit: "100" }); + const params = new URLSearchParams( + group ? { event_group_id: group } : { include_completions: "true", limit: "100" }, + ); if (cursor) params.set("cursor", cursor); - return z.object({ events: z.array(z.unknown()), next_cursor: z.string().nullish() }).parse(await call(`${path(id)}/events?${params}`)); + return z + .object({ events: z.array(z.unknown()), next_cursor: z.string().nullish() }) + .parse(await call(`${path(id)}/events?${params}`)); }, async update(id, frequency) { - return remoteSchema.parse(await call(`${path(id)}/update`, "POST", { frequency: frequencyInput.parse(frequency) })); + return remoteSchema.parse( + await call(`${path(id)}/update`, "POST", { frequency: frequencyInput.parse(frequency) }), + ); + }, + async cancel(id) { + await call(`${path(id)}/cancel`, "POST"); }, - async cancel(id) { await call(`${path(id)}/cancel`, "POST"); }, }; -export async function sendLinq(destination: Destination, text: string, key: string): Promise { - const response = await request(`https://api.linqapp.com/api/partner/v3/chats/${encodeURIComponent(destination.chat)}/messages`, required("LINQ_API_KEY"), "POST", { message: { parts: [{ type: "text", value: text }], idempotency_key: key } }, true); +export async function sendLinq( + destination: Destination, + text: string, + key: string, +): Promise { + const response = await request( + `https://api.linqapp.com/api/partner/v3/chats/${encodeURIComponent(destination.chat)}/messages`, + required("LINQ_API_KEY"), + "POST", + { message: { parts: [{ type: "text", value: text }], idempotency_key: key } }, + true, + ); return z.object({ message: z.object({ id: z.string() }) }).parse(response).message.id; } diff --git a/server/monitors/service.ts b/server/monitors/service.ts index 3bb6b9d..ff2d07e 100644 --- a/server/monitors/service.ts +++ b/server/monitors/service.ts @@ -1,82 +1,196 @@ import { createHash } from "node:crypto"; import { Webhook } from "standardwebhooks"; import { z } from "zod"; -import { allowed, assertOwner, creationOrigin, destinationSchema, type Destination, type Owner, queryInput, required, frequencyInput, monitorTimestamp } from "./config"; -import { eventSchema, type MonitorProvider, parallelMonitors, ProviderError, sendLinq } from "./provider"; +import { + allowed, + assertOwner, + creationOrigin, + destinationSchema, + type Destination, + type Owner, + queryInput, + required, + frequencyInput, + monitorTimestamp, +} from "./config"; +import { + eventSchema, + type MonitorProvider, + parallelMonitors, + ProviderError, + sendLinq, +} from "./provider"; import { type Job, type Watch, MonitorStore, monitorStore, watchFilter } from "./store"; const callbackTimestamp = monitorTimestamp; class InvalidMonitorEvent extends Error {} -const callbackData = z.object({ monitor_id: z.string(), metadata: z.record(z.string(), z.string()).nullish() }); +const callbackData = z.object({ + monitor_id: z.string(), + metadata: z.record(z.string(), z.string()).nullish(), +}); const callbackSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("monitor.event.detected"), timestamp: callbackTimestamp, data: callbackData.extend({ event: z.object({ event_group_id: z.string() }) }) }), - z.object({ type: z.literal("monitor.execution.completed"), timestamp: callbackTimestamp, data: callbackData.extend({ event: z.object({ type: z.literal("completion"), monitor_ts: z.string() }) }) }), - z.object({ type: z.literal("monitor.execution.failed"), timestamp: callbackTimestamp, data: callbackData.extend({ event: z.object({ type: z.literal("error"), error: z.string(), id: z.string(), date: callbackTimestamp }) }) }), + z.object({ + type: z.literal("monitor.event.detected"), + timestamp: callbackTimestamp, + data: callbackData.extend({ event: z.object({ event_group_id: z.string() }) }), + }), + z.object({ + type: z.literal("monitor.execution.completed"), + timestamp: callbackTimestamp, + data: callbackData.extend({ + event: z.object({ type: z.literal("completion"), monitor_ts: z.string() }), + }), + }), + z.object({ + type: z.literal("monitor.execution.failed"), + timestamp: callbackTimestamp, + data: callbackData.extend({ + event: z.object({ + type: z.literal("error"), + error: z.string(), + id: z.string(), + date: callbackTimestamp, + }), + }), + }), ]); const hash = (value: string) => createHash("sha256").update(value).digest("hex"); // Saved place of a recovery scan, so a run stopped by the work budget resumes instead of refetching from the newest page. const syncProgress = z.object({ - group: z.string().optional(), cursor: z.string().optional(), started: z.string().datetime().optional(), anchor: z.string().optional(), - anchor_seen: z.boolean().default(false), runs: z.array(z.string()).default([]), invalid: z.boolean().default(false), resumes: z.number().int().min(0).default(0), + group: z.string().optional(), + cursor: z.string().optional(), + started: z.string().datetime().optional(), + anchor: z.string().optional(), + anchor_seen: z.boolean().default(false), + runs: z.array(z.string()).default([]), + invalid: z.boolean().default(false), + resumes: z.number().int().min(0).default(0), }); const resumeLimit = 20; export class MonitorService { - constructor(readonly store: MonitorStore, readonly provider: MonitorProvider = parallelMonitors, readonly send: (destination: Destination, text: string, key: string) => Promise = sendLinq, readonly budget = 20_000) {} - async create(owner: Owner, destination: Destination, operation: string, input: string, cadence = "1d") { - assertOwner(owner); destinationSchema.parse(destination); + constructor( + readonly store: MonitorStore, + readonly provider: MonitorProvider = parallelMonitors, + readonly send: ( + destination: Destination, + text: string, + key: string, + ) => Promise = sendLinq, + readonly budget = 20_000, + ) {} + async create( + owner: Owner, + destination: Destination, + operation: string, + input: string, + cadence = "1d", + ) { + assertOwner(owner); + destinationSchema.parse(destination); if (!allowed(destination)) throw new Error("This Linq destination is no longer allowed."); - const origin = creationOrigin(), query = queryInput.parse(input), frequency = frequencyInput.parse(cadence); + const origin = creationOrigin(), + query = queryInput.parse(input), + frequency = frequencyInput.parse(cadence); const watch = await this.store.prepare(owner, destination, operation, query, frequency); if (await this.store.dispatch(watch.id)) { try { const remote = await this.provider.create(query, watch.id, origin, frequency); - await this.store.bind(watch.id, remote.monitor_id, remote.status === "cancelled", remote.frequency); + await this.store.bind( + watch.id, + remote.monitor_id, + remote.status === "cancelled", + remote.frequency, + ); } catch (error) { - const rejected = error instanceof ProviderError && [400,401,403,422].includes(error.status); + const rejected = + error instanceof ProviderError && [400, 401, 403, 422].includes(error.status); await this.store.markDispatchUnresolved(watch.id, rejected ? "failed" : "uncertain"); } } const result = await this.store.get(watch.id); - return { id: result.id, status: result.status, frequency: result.frequency, pending_frequency: result.pending_frequency, processor: "lite" }; + return { + id: result.id, + status: result.status, + frequency: result.frequency, + pending_frequency: result.pending_frequency, + processor: "lite", + }; } async list(owner: Owner, options: { status?: string; cursor?: string } = {}) { assertOwner(owner); const status = watchFilter.parse(options.status ?? "all"); const page = await this.store.list(owner, status, options.cursor); let remaining = 12_000; - const watches = page.watches.map(w => { + const watches = page.watches.map((w) => { const updates: unknown[] = []; for (const raw of w.updates) { if (remaining <= 0) break; const event = eventSchema.parse(raw); if (event.event_type !== "event_stream") continue; - const text = typeof event.output.content === "string" ? event.output.content : JSON.stringify(event.output.content); + const text = + typeof event.output.content === "string" + ? event.output.content + : JSON.stringify(event.output.content); const content = text.slice(0, Math.min(600, remaining)); remaining -= content.length; const citations: string[] = []; - for (const url of [...new Set(event.output.basis.flatMap(b => b.citations.flatMap(c => c.url ? [c.url] : [])))].slice(0, 3)) { - if (url.length <= remaining) { citations.push(url); remaining -= url.length; } + for (const url of [ + ...new Set( + event.output.basis.flatMap((b) => b.citations.flatMap((c) => (c.url ? [c.url] : []))), + ), + ].slice(0, 3)) { + if (url.length <= remaining) { + citations.push(url); + remaining -= url.length; + } } - updates.push({ event_id: event.event_id, event_date: event.event_date, content, citations, truncated: content.length < text.length }); + updates.push({ + event_id: event.event_id, + event_date: event.event_date, + content, + citations, + truncated: content.length < text.length, + }); } - return { id: w.id, query: w.query.slice(0, 400), status: w.status, frequency: w.frequency, pending_frequency: w.pending_frequency, health: w.health, health_at: w.health_at, recovery_gap: w.recovery_gap, last_scan_at: w.last_scan_at, updates, updates_truncated: updates.length < w.updates.length, problems: w.problems }; + return { + id: w.id, + query: w.query.slice(0, 400), + status: w.status, + frequency: w.frequency, + pending_frequency: w.pending_frequency, + health: w.health, + health_at: w.health_at, + recovery_gap: w.recovery_gap, + last_scan_at: w.last_scan_at, + updates, + updates_truncated: updates.length < w.updates.length, + problems: w.problems, + }; }); return { watches, next_cursor: page.next_cursor }; } async update(owner: Owner, id: string, cadence: string) { - assertOwner(owner); z.string().uuid().parse(id); + assertOwner(owner); + z.string().uuid().parse(id); const watch = await this.store.requestFrequency(owner, id, frequencyInput.parse(cadence)); - return { id: watch.id, status: watch.status, frequency: watch.frequency, pending_frequency: watch.pending_frequency }; + return { + id: watch.id, + status: watch.status, + frequency: watch.frequency, + pending_frequency: watch.pending_frequency, + }; } async stop(owner: Owner, id: string) { - assertOwner(owner); z.string().uuid().parse(id); + assertOwner(owner); + z.string().uuid().parse(id); await this.store.stop(owner, id); return { id, status: (await this.store.get(id)).status }; } async ingest(body: string, headers: Record) { if (Buffer.byteLength(body) > 65_536) throw new Error("Webhook too large"); const parsed = new Webhook(required("PARALLEL_WEBHOOK_SECRET")).verify(body, headers); - const event = callbackSchema.parse(parsed), id = headers["webhook-id"]; + const event = callbackSchema.parse(parsed), + id = headers["webhook-id"]; if (!id || id.length > 256) throw new Error("Invalid webhook identity"); await this.store.acceptInbox(id, event); } @@ -85,17 +199,34 @@ export class MonitorService { await this.store.schedule(); let processed = 0; while (processed < 30 && Date.now() < deadline) { - const job = await this.store.claim(); if (!job) break; + const job = await this.store.claim(); + if (!job) break; try { const resume = await this.run(job, deadline); - if (resume) await this.store.defer(job, resume); else await this.store.finish(job); - } - catch (error) { + if (resume) await this.store.defer(job, resume); + else await this.store.finish(job); + } catch (error) { const provider = error instanceof ProviderError ? error : undefined; - const terminal = !!provider && [400,401,402,403,404,422].includes(provider.status); - const delay = Math.max(provider?.retryAfter ?? 0, Math.min(3_600_000, 1000 * 2 ** job.attempts) + Math.floor(Math.random() * 1000)); - await this.store.retry(job, provider ? `provider_http_${provider.status}` : error instanceof InvalidMonitorEvent ? "invalid_monitor_event" : "processing_failed", terminal, delay); - console.warn("bell_monitor_job_failed", { kind: job.kind, attempt: job.attempts, terminal: terminal || job.attempts >= 8 }); + const terminal = !!provider && [400, 401, 402, 403, 404, 422].includes(provider.status); + const delay = Math.max( + provider?.retryAfter ?? 0, + Math.min(3_600_000, 1000 * 2 ** job.attempts) + Math.floor(Math.random() * 1000), + ); + await this.store.retry( + job, + provider + ? `provider_http_${provider.status}` + : error instanceof InvalidMonitorEvent + ? "invalid_monitor_event" + : "processing_failed", + terminal, + delay, + ); + console.warn("bell_monitor_job_failed", { + kind: job.kind, + attempt: job.attempts, + terminal: terminal || job.attempts >= 8, + }); } processed++; } @@ -115,9 +246,18 @@ export class MonitorService { // Early callbacks wait for API-backed reconciliation; metadata is not ownership proof. throw new Error("Monitor identity not reconciled yet"); } - await this.store.health(watch.id, event.type === "monitor.execution.failed" ? "failed" : "completed", event.timestamp); + await this.store.health( + watch.id, + event.type === "monitor.execution.failed" ? "failed" : "completed", + event.timestamp, + ); if (!watch.stopped && watch.status === "active" && event.type === "monitor.event.detected") { - await this.store.enqueue("sync", `group:${watch.id}:${hash(event.data.event.event_group_id)}`, watch.id, { group: event.data.event.event_group_id }); + await this.store.enqueue( + "sync", + `group:${watch.id}:${hash(event.data.event.event_group_id)}`, + watch.id, + { group: event.data.event.event_group_id }, + ); } return; } @@ -127,9 +267,18 @@ export class MonitorService { let cursor = typeof job.payload.cursor === "string" ? job.payload.cursor : undefined; for (let page = 0; page < 5 && Date.now() < deadline; page++) { const result = await this.provider.list(cursor); - const matches = result.monitors.filter(m => m.metadata?.bell_watch_id === watch.id); - if (matches.length > 1) throw new Error("Multiple remote matches require operator reconciliation"); - if (matches.length) { await this.store.bind(watch.id, matches[0].monitor_id, matches[0].status === "cancelled", matches[0].frequency); return; } + const matches = result.monitors.filter((m) => m.metadata?.bell_watch_id === watch.id); + if (matches.length > 1) + throw new Error("Multiple remote matches require operator reconciliation"); + if (matches.length) { + await this.store.bind( + watch.id, + matches[0].monitor_id, + matches[0].status === "cancelled", + matches[0].frequency, + ); + return; + } cursor = result.next_cursor ?? undefined; if (!cursor) break; } @@ -139,21 +288,29 @@ export class MonitorService { if (job.kind === "cancel") { if (!watch.remote_id) throw new Error("Creation unresolved"); await this.provider.cancel(watch.remote_id); - await this.store.markCancelled(watch.id); return; + await this.store.markCancelled(watch.id); + return; } if (watch.stopped || watch.status !== "active") return; if (job.kind === "update") { const frequency = frequencyInput.parse(job.payload.frequency); - await this.store.applyFrequency(watch.id, frequency, async id => { + await this.store.applyFrequency(watch.id, frequency, async (id) => { const remote = await this.provider.update(id, frequency); - if (remote.monitor_id !== id || remote.status !== "active" || remote.frequency !== frequency) throw new Error("Monitor frequency not confirmed"); + if ( + remote.monitor_id !== id || + remote.status !== "active" || + remote.frequency !== frequency + ) + throw new Error("Monitor frequency not confirmed"); return remote.frequency; }); return; } if (job.kind === "send") { - const payload = z.object({ text: z.string(), key: z.string(), eventId: z.string() }).parse(job.payload); - await this.store.deliver(watch.id, payload.eventId, async current => { + const payload = z + .object({ text: z.string(), key: z.string(), eventId: z.string() }) + .parse(job.payload); + await this.store.deliver(watch.id, payload.eventId, async (current) => { if (!allowed(current.destination)) throw new ProviderError(403); return this.send(current.destination, payload.text, payload.key); }); @@ -164,29 +321,40 @@ export class MonitorService { const saved = syncProgress.parse(job.payload); const group = saved.group; const started = saved.started ?? new Date().toISOString(); - let cursor = saved.cursor, anchor = saved.anchor, previousAnchorSeen = saved.anchor_seen, invalidEvents = saved.invalid; + let cursor = saved.cursor, + anchor = saved.anchor, + previousAnchorSeen = saved.anchor_seen, + invalidEvents = saved.invalid; const runs = new Set(saved.runs); const prior = watch.scan_anchor?.match(/^(completion|error):(.*)$/); const priorTimestamp = prior ? monitorTimestamp.safeParse(prior[2]) : undefined; - const previousAnchor = prior && priorTimestamp?.success ? `${prior[1]}:${priorTimestamp.data}` : watch.scan_anchor; + const previousAnchor = + prior && priorTimestamp?.success ? `${prior[1]}:${priorTimestamp.data}` : watch.scan_anchor; // Each claim fetches at least one page, then stops on a page boundary so the saved cursor never splits a page. for (let page = 0; page < 10; page++) { if (page && Date.now() >= deadline) break; const result = await this.provider.events(watch.remote_id, group, cursor); - const events = result.events.flatMap(raw => { + const events = result.events.flatMap((raw) => { const parsed = eventSchema.safeParse(raw); if (parsed.success) return [parsed.data]; invalidEvents = true; return []; }); - const ids = events.flatMap(event => event.event_type === "event_stream" ? [event.event_id] : []); + const ids = events.flatMap((event) => + event.event_type === "event_stream" ? [event.event_id] : [], + ); const known = await this.store.knownEventIds(watch.id, ids); for (const event of events) { - const identity = event.event_type === "event_stream" ? event.event_id : `${event.event_type}:${event.timestamp}`; + const identity = + event.event_type === "event_stream" + ? event.event_id + : `${event.event_type}:${event.timestamp}`; anchor ??= identity; if (identity === previousAnchor) previousAnchorSeen = true; - if (runs.size < 100) runs.add(event.event_type === "event_stream" ? event.event_group_id : identity); - if (event.event_type !== "event_stream" || !known.has(event.event_id)) await this.event(watch, event); + if (runs.size < 100) + runs.add(event.event_type === "event_stream" ? event.event_group_id : identity); + if (event.event_type !== "event_stream" || !known.has(event.event_id)) + await this.event(watch, event); } const next = result.next_cursor ?? undefined; if (!next) { @@ -204,7 +372,16 @@ export class MonitorService { } // Only resume while the scan keeps moving forward, so a stalled cursor still surfaces as a gap instead of looping. if (cursor && cursor !== saved.cursor && saved.resumes < resumeLimit) { - return { group, cursor, started, anchor, anchor_seen: previousAnchorSeen, runs: [...runs], invalid: invalidEvents, resumes: saved.resumes + 1 }; + return { + group, + cursor, + started, + anchor, + anchor_seen: previousAnchorSeen, + runs: [...runs], + invalid: invalidEvents, + resumes: saved.resumes + 1, + }; } await this.store.markRecoveryGap(watch.id); throw new Error("Recovery scan exceeded work budget"); @@ -213,13 +390,33 @@ export class MonitorService { private async event(watch: Watch, raw: unknown) { const event = eventSchema.parse(raw); if (event.event_type !== "event_stream") { - await this.store.health(watch.id, event.event_type === "completion" ? "completed" : "failed", event.timestamp); return; + await this.store.health( + watch.id, + event.event_type === "completion" ? "completed" : "failed", + event.timestamp, + ); + return; } - const content = typeof event.output.content === "string" ? event.output.content : JSON.stringify(event.output.content); - const urls = [...new Set(event.output.basis.flatMap(b => b.citations.map(c => c.url).filter((u): u is string => !!u)))].slice(0, 3); + const content = + typeof event.output.content === "string" + ? event.output.content + : JSON.stringify(event.output.content); + const urls = [ + ...new Set( + event.output.basis.flatMap((b) => + b.citations.map((c) => c.url).filter((u): u is string => !!u), + ), + ), + ].slice(0, 3); const text = `Watch update: ${watch.query.slice(0, 120)}\n${content.slice(0, 1200)}${content.length > 1200 ? "…" : ""}\n${urls.join("\n")}`; const key = `bell-watch-${hash(`${watch.id}:${event.event_id}`)}`; - await this.store.recordEvent(watch.id, event.event_id, event, key, { text, key, eventId: event.event_id }); + await this.store.recordEvent(watch.id, event.event_id, event, key, { + text, + key, + eventId: event.event_id, + }); } } -export function monitors() { return new MonitorService(monitorStore()); } +export function monitors() { + return new MonitorService(monitorStore()); +} diff --git a/server/monitors/store.ts b/server/monitors/store.ts index dde66d9..8117706 100644 --- a/server/monitors/store.ts +++ b/server/monitors/store.ts @@ -5,11 +5,33 @@ import { z } from "zod"; import { type Destination, type Owner, required } from "./config"; export interface Watch { - id: string; issuer: string; owner_id: string; operation: string; query: string; creation_frequency: string; frequency: string; pending_frequency: string | null; destination: Destination; - remote_id: string | null; status: string; stopped: boolean; health: string | null; health_at: Date | null; - created_at: Date; last_scan_at: Date | null; scan_anchor: string | null; recovery_gap: boolean; + id: string; + issuer: string; + owner_id: string; + operation: string; + query: string; + creation_frequency: string; + frequency: string; + pending_frequency: string | null; + destination: Destination; + remote_id: string | null; + status: string; + stopped: boolean; + health: string | null; + health_at: Date | null; + created_at: Date; + last_scan_at: Date | null; + scan_anchor: string | null; + recovery_gap: boolean; +} +export interface Job { + id: string; + watch_id: string | null; + kind: string; + payload: Record; + attempts: number; + lease_token: string; } -export interface Job { id: string; watch_id: string | null; kind: string; payload: Record; attempts: number; lease_token: string } export type ListedWatch = Watch & { updates: unknown[]; problems: unknown[] }; /** `open` keeps watches that may still run or still need repair; `closed` is everything already cancelled or failed. */ export const watchFilter = z.enum(["all", "open", "closed"]); @@ -17,180 +39,370 @@ export type WatchFilter = z.infer; export const watchPageSize = 50; const closedStatuses = "'cancelled','failed'"; // Keyset page marker for the (rank, created_at DESC, id DESC) order, opaque to the agent so the ordering can change. -const cursorSchema = z.object({ rank: z.int().min(0).max(1), created_at: z.string().datetime(), id: z.uuid() }); -const encodeCursor = (value: z.infer) => Buffer.from(JSON.stringify(value)).toString("base64url"); +const cursorSchema = z.object({ + rank: z.int().min(0).max(1), + created_at: z.string().datetime(), + id: z.uuid(), +}); +const encodeCursor = (value: z.infer) => + Buffer.from(JSON.stringify(value)).toString("base64url"); function decodeCursor(cursor: string) { try { if (cursor.length > 512) throw new Error("Cursor too long"); return cursorSchema.parse(JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"))); } catch { - throw new Error("Watch operation received an unusable page cursor. List watches again without a cursor."); + throw new Error( + "Watch operation received an unusable page cursor. List watches again without a cursor.", + ); } } export class MonitorStore { constructor(readonly pool: Pool) {} async transaction(fn: (client: PoolClient) => Promise): Promise { const client = await this.pool.connect(); - try { await client.query("BEGIN"); const value = await fn(client); await client.query("COMMIT"); return value; } - catch (error) { await client.query("ROLLBACK"); throw error; } - finally { client.release(); } - } - async prepare(owner: Owner, destination: Destination, operation: string, query: string, frequency = "1d"): Promise { - await this.pool.query("INSERT INTO bell_monitors (id,issuer,owner_id,operation,query,destination,status,creation_frequency,frequency) VALUES ($1,$2,$3,$4,$5,$6,'prepared',$7,$7) ON CONFLICT (issuer,owner_id,operation) DO NOTHING", [randomUUID(), owner.issuer, owner.id, operation, query, destination, frequency]); - const watch = (await this.pool.query("SELECT * FROM bell_monitors WHERE issuer=$1 AND owner_id=$2 AND operation=$3", [owner.issuer, owner.id, operation])).rows[0]; - if (watch.creation_frequency !== frequency || watch.query !== query || watch.destination.chat !== destination.chat || watch.destination.sender !== destination.sender || watch.destination.line !== destination.line) throw new Error("Watch operation has different input."); + try { + await client.query("BEGIN"); + const value = await fn(client); + await client.query("COMMIT"); + return value; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async prepare( + owner: Owner, + destination: Destination, + operation: string, + query: string, + frequency = "1d", + ): Promise { + await this.pool.query( + "INSERT INTO bell_monitors (id,issuer,owner_id,operation,query,destination,status,creation_frequency,frequency) VALUES ($1,$2,$3,$4,$5,$6,'prepared',$7,$7) ON CONFLICT (issuer,owner_id,operation) DO NOTHING", + [randomUUID(), owner.issuer, owner.id, operation, query, destination, frequency], + ); + const watch = ( + await this.pool.query( + "SELECT * FROM bell_monitors WHERE issuer=$1 AND owner_id=$2 AND operation=$3", + [owner.issuer, owner.id, operation], + ) + ).rows[0]; + if ( + watch.creation_frequency !== frequency || + watch.query !== query || + watch.destination.chat !== destination.chat || + watch.destination.sender !== destination.sender || + watch.destination.line !== destination.line + ) + throw new Error("Watch operation has different input."); return watch; } async get(id: string): Promise { - const watch = (await this.pool.query("SELECT * FROM bell_monitors WHERE id=$1", [id])).rows[0]; - if (!watch) throw new Error("Watch not found."); return watch; + const watch = (await this.pool.query("SELECT * FROM bell_monitors WHERE id=$1", [id])) + .rows[0]; + if (!watch) throw new Error("Watch not found."); + return watch; } - async enqueue(kind: string, id: string, watchId: string | null, payload: Record = {}, client: Pool | PoolClient = this.pool) { - await client.query("INSERT INTO bell_monitor_jobs (id,watch_id,kind,payload) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING", [id, watchId, kind, payload]); + async enqueue( + kind: string, + id: string, + watchId: string | null, + payload: Record = {}, + client: Pool | PoolClient = this.pool, + ) { + await client.query( + "INSERT INTO bell_monitor_jobs (id,watch_id,kind,payload) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING", + [id, watchId, kind, payload], + ); } async dispatch(id: string): Promise { - return this.transaction(async client => { - const result = await client.query("UPDATE bell_monitors SET status='dispatched' WHERE id=$1 AND status='prepared' AND NOT stopped RETURNING id", [id]); + return this.transaction(async (client) => { + const result = await client.query( + "UPDATE bell_monitors SET status='dispatched' WHERE id=$1 AND status='prepared' AND NOT stopped RETURNING id", + [id], + ); if (!result.rowCount) return false; await this.enqueue("reconcile", `reconcile:${id}`, id, {}, client); - await client.query("UPDATE bell_monitor_jobs SET next_at=now()+interval '1 minute' WHERE id=$1", [`reconcile:${id}`]); + await client.query( + "UPDATE bell_monitor_jobs SET next_at=now()+interval '1 minute' WHERE id=$1", + [`reconcile:${id}`], + ); return true; }); } async bind(id: string, remoteId: string, cancelled = false, frequency = "1d") { - await this.transaction(async client => { - const watch = (await client.query("SELECT * FROM bell_monitors WHERE id=$1 FOR UPDATE", [id])).rows[0]; + await this.transaction(async (client) => { + const watch = ( + await client.query("SELECT * FROM bell_monitors WHERE id=$1 FOR UPDATE", [id]) + ).rows[0]; if (watch.remote_id) { if (watch.remote_id !== remoteId) throw new Error("Conflicting monitor identity"); return; } - await client.query("UPDATE bell_monitors SET remote_id=$2,status=$3,frequency=$4 WHERE id=$1", [id, remoteId, cancelled ? "cancelled" : watch.stopped ? "cancel_pending" : "active", frequency]); + await client.query( + "UPDATE bell_monitors SET remote_id=$2,status=$3,frequency=$4 WHERE id=$1", + [ + id, + remoteId, + cancelled ? "cancelled" : watch.stopped ? "cancel_pending" : "active", + frequency, + ], + ); if (watch.stopped && !cancelled) await this.enqueue("cancel", `cancel:${id}`, id, {}, client); }); } async stop(owner: Owner, id: string) { - await this.transaction(async client => { - const watch = (await client.query("SELECT * FROM bell_monitors WHERE id=$1 AND issuer=$2 AND owner_id=$3 FOR UPDATE", [id, owner.issuer, owner.id])).rows[0]; + await this.transaction(async (client) => { + const watch = ( + await client.query( + "SELECT * FROM bell_monitors WHERE id=$1 AND issuer=$2 AND owner_id=$3 FOR UPDATE", + [id, owner.issuer, owner.id], + ) + ).rows[0]; if (!watch) throw new Error("Watch not found."); const localOnly = watch.status === "prepared" || watch.status === "failed"; - await client.query("UPDATE bell_monitors SET stopped=true,pending_frequency=NULL,status=$2 WHERE id=$1", [id, watch.status === "cancelled" || localOnly ? "cancelled" : "cancel_pending"]); - await client.query("DELETE FROM bell_monitor_jobs WHERE watch_id=$1 AND kind IN ('send','update') AND lease_until IS NULL", [id]); - if (watch.remote_id && watch.status !== "cancelled") await this.enqueue("cancel", `cancel:${id}`, id, {}, client); + await client.query( + "UPDATE bell_monitors SET stopped=true,pending_frequency=NULL,status=$2 WHERE id=$1", + [id, watch.status === "cancelled" || localOnly ? "cancelled" : "cancel_pending"], + ); + await client.query( + "DELETE FROM bell_monitor_jobs WHERE watch_id=$1 AND kind IN ('send','update') AND lease_until IS NULL", + [id], + ); + if (watch.remote_id && watch.status !== "cancelled") + await this.enqueue("cancel", `cancel:${id}`, id, {}, client); }); } async requestFrequency(owner: Owner, id: string, frequency: string): Promise { - return this.transaction(async client => { - const watch = (await client.query("SELECT * FROM bell_monitors WHERE id=$1 AND issuer=$2 AND owner_id=$3 FOR UPDATE", [id, owner.issuer, owner.id])).rows[0]; + return this.transaction(async (client) => { + const watch = ( + await client.query( + "SELECT * FROM bell_monitors WHERE id=$1 AND issuer=$2 AND owner_id=$3 FOR UPDATE", + [id, owner.issuer, owner.id], + ) + ).rows[0]; if (!watch) throw new Error("Watch not found."); - if (watch.stopped || watch.status !== "active" || !watch.remote_id) throw new Error("Watch operation requires an active watch."); + if (watch.stopped || watch.status !== "active" || !watch.remote_id) + throw new Error("Watch operation requires an active watch."); if (watch.pending_frequency) { - if (watch.pending_frequency !== frequency) throw new Error("Watch operation has a pending frequency change. List watches before requesting another change."); + if (watch.pending_frequency !== frequency) + throw new Error( + "Watch operation has a pending frequency change. List watches before requesting another change.", + ); return watch; } if (watch.frequency === frequency) return watch; - await client.query("UPDATE bell_monitors SET pending_frequency=$2 WHERE id=$1", [id, frequency]); + await client.query("UPDATE bell_monitors SET pending_frequency=$2 WHERE id=$1", [ + id, + frequency, + ]); await this.enqueue("update", `update:${id}:${randomUUID()}`, id, { frequency }, client); return { ...watch, pending_frequency: frequency }; }); } - async applyFrequency(id: string, frequency: string, update: (remoteId: string) => Promise) { - await this.transaction(async client => { - const watch = (await client.query("SELECT * FROM bell_monitors WHERE id=$1 FOR UPDATE", [id])).rows[0]; - if (!watch || watch.stopped || watch.status !== "active" || !watch.remote_id || watch.pending_frequency !== frequency) return; + async applyFrequency( + id: string, + frequency: string, + update: (remoteId: string) => Promise, + ) { + await this.transaction(async (client) => { + const watch = ( + await client.query("SELECT * FROM bell_monitors WHERE id=$1 FOR UPDATE", [id]) + ).rows[0]; + if ( + !watch || + watch.stopped || + watch.status !== "active" || + !watch.remote_id || + watch.pending_frequency !== frequency + ) + return; const confirmed = await update(watch.remote_id); - await client.query("UPDATE bell_monitors SET frequency=$2,pending_frequency=NULL WHERE id=$1", [id, confirmed]); + await client.query( + "UPDATE bell_monitors SET frequency=$2,pending_frequency=NULL WHERE id=$1", + [id, confirmed], + ); }); } async deliver(id: string, eventId: string, send: (watch: Watch) => Promise) { - await this.transaction(async client => { + await this.transaction(async (client) => { // Share stop's row lock through dispatch so suppression cannot race a stale read. - const watch = (await client.query("SELECT * FROM bell_monitors WHERE id=$1 FOR UPDATE", [id])).rows[0]; + const watch = ( + await client.query("SELECT * FROM bell_monitors WHERE id=$1 FOR UPDATE", [id]) + ).rows[0]; if (!watch || watch.stopped || watch.status !== "active") return; const accepted = await send(watch); - await client.query("UPDATE bell_monitor_events SET payload=jsonb_set(payload,'{delivery}', $3::jsonb) WHERE watch_id=$1 AND event_id=$2", [id, eventId, JSON.stringify({ status: "accepted", message_id: accepted })]); + await client.query( + "UPDATE bell_monitor_events SET payload=jsonb_set(payload,'{delivery}', $3::jsonb) WHERE watch_id=$1 AND event_id=$2", + [id, eventId, JSON.stringify({ status: "accepted", message_id: accepted })], + ); }); } - async list(owner: Owner, filter: WatchFilter = "all", cursor?: string): Promise<{ watches: ListedWatch[]; next_cursor: string | null }> { + async list( + owner: Owner, + filter: WatchFilter = "all", + cursor?: string, + ): Promise<{ watches: ListedWatch[]; next_cursor: string | null }> { const from = cursor ? decodeCursor(cursor) : undefined; - const rows = (await this.pool.query(`WITH scoped AS ( + const rows = ( + await this.pool.query( + `WITH scoped AS ( SELECT w.*, (CASE WHEN w.status IN (${closedStatuses}) THEN 1 ELSE 0 END) AS rank FROM bell_monitors w WHERE issuer=$1 AND owner_id=$2) SELECT w.*, to_char(w.created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_created_at, COALESCE((SELECT jsonb_agg(e.payload) FROM (SELECT payload FROM bell_monitor_events WHERE watch_id=w.id ORDER BY received_at DESC LIMIT 5) e),'[]') AS updates, COALESCE((SELECT jsonb_agg(jsonb_build_object('kind',kind,'error',last_error,'failed',failed)) FROM bell_monitor_jobs WHERE watch_id=w.id AND last_error IS NOT NULL),'[]') AS problems FROM scoped w WHERE ($3::int IS NULL OR w.rank=$3) AND ($4::int IS NULL OR w.rank>$4 OR (w.rank=$4 AND (w.created_at<$5::timestamptz OR (w.created_at=$5::timestamptz AND w.id<$6::uuid)))) ORDER BY w.rank, w.created_at DESC, w.id DESC LIMIT ${watchPageSize + 1}`, - [owner.issuer, owner.id, filter === "all" ? null : filter === "closed" ? 1 : 0, from?.rank ?? null, from?.created_at ?? null, from?.id ?? null])).rows; + [ + owner.issuer, + owner.id, + filter === "all" ? null : filter === "closed" ? 1 : 0, + from?.rank ?? null, + from?.created_at ?? null, + from?.id ?? null, + ], + ) + ).rows; const watches = rows.slice(0, watchPageSize); const last = rows.length > watchPageSize ? watches[watches.length - 1] : undefined; - return { watches, next_cursor: last ? encodeCursor({ rank: last.rank, created_at: last.cursor_created_at, id: last.id }) : null }; + return { + watches, + next_cursor: last + ? encodeCursor({ rank: last.rank, created_at: last.cursor_created_at, id: last.id }) + : null, + }; } async claim(): Promise { - return (await this.pool.query(`UPDATE bell_monitor_jobs SET lease_until=now()+interval '60 seconds',lease_token=$1,attempts=attempts+1 WHERE id=( + return ( + await this.pool.query( + `UPDATE bell_monitor_jobs SET lease_until=now()+interval '60 seconds',lease_token=$1,attempts=attempts+1 WHERE id=( SELECT id FROM bell_monitor_jobs WHERE NOT failed AND next_at<=now() AND (lease_until IS NULL OR lease_until) { // Running out of work budget is not a failure: keep the saved place, release the attempt, and resume on the next claim. - await this.pool.query("UPDATE bell_monitor_jobs SET lease_until=NULL,lease_token=NULL,last_error=NULL,payload=$3,attempts=$4,next_at=now() WHERE id=$1 AND lease_token=$2", [job.id, job.lease_token, payload, Math.max(0, job.attempts - 1)]); + await this.pool.query( + "UPDATE bell_monitor_jobs SET lease_until=NULL,lease_token=NULL,last_error=NULL,payload=$3,attempts=$4,next_at=now() WHERE id=$1 AND lease_token=$2", + [job.id, job.lease_token, payload, Math.max(0, job.attempts - 1)], + ); } async retry(job: Job, reason: string, terminal: boolean, delay: number) { - await this.pool.query("UPDATE bell_monitor_jobs SET lease_until=NULL,lease_token=NULL,last_error=$3,failed=$4,next_at=now()+$5*interval '1 millisecond' WHERE id=$1 AND lease_token=$2", [job.id, job.lease_token, reason, terminal || job.attempts >= 8, delay]); + await this.pool.query( + "UPDATE bell_monitor_jobs SET lease_until=NULL,lease_token=NULL,last_error=$3,failed=$4,next_at=now()+$5*interval '1 millisecond' WHERE id=$1 AND lease_token=$2", + [job.id, job.lease_token, reason, terminal || job.attempts >= 8, delay], + ); } async schedule() { - await this.transaction(async client => { - const watches = (await client.query<{ id: string }>("UPDATE bell_monitors SET next_scan_at=now()+interval '1 hour' WHERE status='active' AND NOT stopped AND next_scan_at<=now() RETURNING id")).rows; + await this.transaction(async (client) => { + const watches = ( + await client.query<{ id: string }>( + "UPDATE bell_monitors SET next_scan_at=now()+interval '1 hour' WHERE status='active' AND NOT stopped AND next_scan_at<=now() RETURNING id", + ) + ).rows; for (const watch of watches) { await this.enqueue("sync", `sync:${watch.id}`, watch.id, {}, client); // Recheck malformed records from the start; other failures retain progress with a renewed resume budget. - await client.query("UPDATE bell_monitor_jobs SET failed=false,attempts=0,next_at=now(),lease_until=NULL,lease_token=NULL,payload=CASE WHEN last_error='invalid_monitor_event' THEN '{}'::jsonb ELSE payload-'resumes' END WHERE id=$1 AND failed AND (lease_until IS NULL OR lease_until { - await client.query("UPDATE bell_monitors SET status=$2 WHERE id=$1 AND status='dispatched'", [id, status]); - await client.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE id=$1", [`reconcile:${id}`]); + await this.transaction(async (client) => { + await client.query("UPDATE bell_monitors SET status=$2 WHERE id=$1 AND status='dispatched'", [ + id, + status, + ]); + await client.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE id=$1", [ + `reconcile:${id}`, + ]); }); } async acceptInbox(id: string, event: unknown) { - await this.transaction(async client => { - const inserted = await client.query("INSERT INTO bell_monitor_inbox(id,payload) VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING id", [id, event]); + await this.transaction(async (client) => { + const inserted = await client.query( + "INSERT INTO bell_monitor_inbox(id,payload) VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING id", + [id, event], + ); if (inserted.rowCount) await this.enqueue("inbox", `inbox:${id}`, null, { id }, client); }); } async inboxPayload(id: unknown): Promise { - return (await this.pool.query("SELECT payload FROM bell_monitor_inbox WHERE id=$1", [id])).rows[0]?.payload; + return (await this.pool.query("SELECT payload FROM bell_monitor_inbox WHERE id=$1", [id])) + .rows[0]?.payload; } async findByRemoteId(remoteId: string): Promise { - return (await this.pool.query("SELECT * FROM bell_monitors WHERE remote_id=$1", [remoteId])).rows[0]; + return ( + await this.pool.query("SELECT * FROM bell_monitors WHERE remote_id=$1", [remoteId]) + ).rows[0]; } async findPendingIdentity(id: string): Promise { - return (await this.pool.query("SELECT * FROM bell_monitors WHERE id=$1 AND status IN ('dispatched','uncertain','cancel_pending')", [id])).rows[0]; + return ( + await this.pool.query( + "SELECT * FROM bell_monitors WHERE id=$1 AND status IN ('dispatched','uncertain','cancel_pending')", + [id], + ) + ).rows[0]; } async saveLeasePayload(id: string, leaseToken: string, payload: Record) { - await this.pool.query("UPDATE bell_monitor_jobs SET payload=$3 WHERE id=$1 AND lease_token=$2", [id, leaseToken, payload]); + await this.pool.query( + "UPDATE bell_monitor_jobs SET payload=$3 WHERE id=$1 AND lease_token=$2", + [id, leaseToken, payload], + ); } async markCancelled(id: string) { await this.pool.query("UPDATE bell_monitors SET status='cancelled' WHERE id=$1", [id]); } async knownEventIds(watchId: string, ids: string[]): Promise> { - const rows = (await this.pool.query<{ event_id: string }>("SELECT event_id FROM bell_monitor_events WHERE watch_id=$1 AND event_id=ANY($2::text[])", [watchId, ids])).rows; - return new Set(rows.map(row => row.event_id)); + const rows = ( + await this.pool.query<{ event_id: string }>( + "SELECT event_id FROM bell_monitor_events WHERE watch_id=$1 AND event_id=ANY($2::text[])", + [watchId, ids], + ) + ).rows; + return new Set(rows.map((row) => row.event_id)); } async markRecoveryGap(id: string) { await this.pool.query("UPDATE bell_monitors SET recovery_gap=true WHERE id=$1", [id]); } async completeScan(id: string, started: string, anchor: string | null, gap: boolean) { - await this.pool.query("UPDATE bell_monitors SET last_scan_at=$2,scan_anchor=COALESCE($3,scan_anchor),recovery_gap=recovery_gap OR $4 WHERE id=$1", [id, started, anchor, gap]); + await this.pool.query( + "UPDATE bell_monitors SET last_scan_at=$2,scan_anchor=COALESCE($3,scan_anchor),recovery_gap=recovery_gap OR $4 WHERE id=$1", + [id, started, anchor, gap], + ); } - async recordEvent(watchId: string, eventId: string, event: unknown, jobId: string, payload: Record) { - await this.transaction(async client => { - const inserted = await client.query("INSERT INTO bell_monitor_events (watch_id,event_id,payload) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING event_id", [watchId, eventId, event]); + async recordEvent( + watchId: string, + eventId: string, + event: unknown, + jobId: string, + payload: Record, + ) { + await this.transaction(async (client) => { + const inserted = await client.query( + "INSERT INTO bell_monitor_events (watch_id,event_id,payload) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING event_id", + [watchId, eventId, event], + ); if (inserted.rowCount) await this.enqueue("send", jobId, watchId, payload, client); }); } @@ -198,7 +410,13 @@ export class MonitorStore { let singleton: MonitorStore | undefined; export function monitorStore(): MonitorStore { if (!singleton) { - singleton = new MonitorStore(createPool(required("DATABASE_URL"), "Monitor", { connectionTimeoutMillis: 3_000, idleTimeoutMillis: 10_000, statement_timeout: 5_000 })); + singleton = new MonitorStore( + createPool(required("DATABASE_URL"), "Monitor", { + connectionTimeoutMillis: 3_000, + idleTimeoutMillis: 10_000, + statement_timeout: 5_000, + }), + ); } return singleton; } diff --git a/server/onepassword/approval-http.ts b/server/onepassword/approval-http.ts index fcbe840..b270fdd 100644 --- a/server/onepassword/approval-http.ts +++ b/server/onepassword/approval-http.ts @@ -1,22 +1,45 @@ import { randomBytes } from "node:crypto"; import { z } from "zod"; import { getOnePasswordLogin, type OnePasswordLogin } from "./login"; -const headers = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" }; -const input = z.object({ cap: z.string().regex(/^[A-Za-z0-9_-]{43}$/), manual: z.boolean().optional() }).strict(); -export async function approvalPost(request: Request, action: string, service: () => OnePasswordLogin = getOnePasswordLogin) { +const headers = { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", +}; +const input = z + .object({ cap: z.string().regex(/^[A-Za-z0-9_-]{43}$/), manual: z.boolean().optional() }) + .strict(); +export async function approvalPost( + request: Request, + action: string, + service: () => OnePasswordLogin = getOnePasswordLogin, +) { try { const login = service(); - if (request.headers.get("origin") !== login.oauth.config.origin || request.headers.get("content-type")?.split(";")[0] !== "application/json") throw new Error(); + if ( + request.headers.get("origin") !== login.oauth.config.origin || + request.headers.get("content-type")?.split(";")[0] !== "application/json" + ) + throw new Error(); const raw = await request.text(); if (raw.length > 512) throw new Error(); const parsed = input.parse(JSON.parse(raw)); - const result = action === "approval-open" ? await login.opened(parsed.cap) : await login.poll(parsed.cap, parsed.manual ?? false); + const result = + action === "approval-open" + ? await login.opened(parsed.cap) + : await login.poll(parsed.cap, parsed.manual ?? false); return Response.json(result, { headers }); - } catch { return Response.json({ error: "Approval could not continue. Return to Bell and start a new login request." }, { status: 400, headers }); } + } catch { + return Response.json( + { error: "Approval could not continue. Return to Bell and start a new login request." }, + { status: 400, headers }, + ); + } } export function approvalPage(): Response { const nonce = randomBytes(18).toString("base64"); - return new Response(`Approve a Bell login

Approve a Bell login

Use the computer running your 1Password desktop app. Check the requested website before approving.

`, { headers: { ...headers, "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'` } }); +`, + { + headers: { + ...headers, + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'`, + }, + }, + ); } diff --git a/server/onepassword/browser-policy.ts b/server/onepassword/browser-policy.ts index 1c19784..cd27320 100644 --- a/server/onepassword/browser-policy.ts +++ b/server/onepassword/browser-policy.ts @@ -6,8 +6,15 @@ const activeLock = new AsyncLocalStorage<{ client: PoolClient; sessionId: string export async function markSecretFill(blocked: boolean): Promise { const lock = activeLock.getStore(); if (!lock) throw new Error("A private browser lock is required."); - if (blocked) await lock.client.query("INSERT INTO bell_onepassword_browser_guards(session_id) VALUES($1) ON CONFLICT DO NOTHING", [lock.sessionId]); - else await lock.client.query("DELETE FROM bell_onepassword_browser_guards WHERE session_id=$1", [lock.sessionId]); + if (blocked) + await lock.client.query( + "INSERT INTO bell_onepassword_browser_guards(session_id) VALUES($1) ON CONFLICT DO NOTHING", + [lock.sessionId], + ); + else + await lock.client.query("DELETE FROM bell_onepassword_browser_guards WHERE session_id=$1", [ + lock.sessionId, + ]); } function extensionConfigured(): boolean { return Boolean(process.env.BROWSERBASE_1PASSWORD_EXTENSION_ID?.trim()); @@ -16,15 +23,28 @@ export function personalBrowserEnabled(): boolean { return personalConnectionsEnabled() && extensionConfigured(); } export function requireReusableContexts(): void { - if (personalBrowserEnabled()) throw new Error("Reusable browser Contexts are disabled for personal 1Password. Start a new private conversation instead."); + if (personalBrowserEnabled()) + throw new Error( + "Reusable browser Contexts are disabled for personal 1Password. Start a new private conversation instead.", + ); } let pool: ReturnType | undefined; -export async function withPersonalBrowserLock(sessionId: string, operation: () => Promise): Promise { +export async function withPersonalBrowserLock( + sessionId: string, + operation: () => Promise, +): Promise { if (!extensionConfigured()) return operation(); if (!process.env.DATABASE_URL) throw new Error("Personal 1Password requires DATABASE_URL."); pool ??= createPool(process.env.DATABASE_URL, "Private browser", { allowExitOnIdle: true }); return withAdvisoryLock(pool, `bell-private-browser:${sessionId}`, async (client) => { - if ((await client.query("SELECT 1 FROM bell_onepassword_browser_guards WHERE session_id=$1", [sessionId])).rowCount) throw new Error("A private login did not finish safely. Start a new conversation."); + if ( + ( + await client.query("SELECT 1 FROM bell_onepassword_browser_guards WHERE session_id=$1", [ + sessionId, + ]) + ).rowCount + ) + throw new Error("A private login did not finish safely. Start a new conversation."); return activeLock.run({ client, sessionId }, operation); }); } diff --git a/server/onepassword/config.ts b/server/onepassword/config.ts index 2e05981..b4233f5 100644 --- a/server/onepassword/config.ts +++ b/server/onepassword/config.ts @@ -2,41 +2,93 @@ import { ConnectionAuthorizationFailedError, type ConnectionPrincipal } from "ev import { z } from "zod"; export const scopes = "brokered-credentials:request-access brokered-credentials:read"; -export interface Config { origin: string; redirectUri: string; clientId: string; clientSecret: string; databaseUrl: string; encryptionKey: Buffer } +export interface Config { + origin: string; + redirectUri: string; + clientId: string; + clientSecret: string; + databaseUrl: string; + encryptionKey: Buffer; +} export function failed(reason: string): never { - throw new ConnectionAuthorizationFailedError("onepassword", { reason, retryable: false, message: `1Password: ${reason}.` }); + throw new ConnectionAuthorizationFailedError("onepassword", { + reason, + retryable: false, + message: `1Password: ${reason}.`, + }); +} +export function personalConnectionsEnabled(): boolean { + return process.env.ONEPASSWORD_ENABLED === "true"; } -export function personalConnectionsEnabled(): boolean { return process.env.ONEPASSWORD_ENABLED === "true"; } export function requirePersonalConnections(): void { if (!personalConnectionsEnabled()) failed("personal_1password_disabled"); } export function ownerOf(principal: ConnectionPrincipal): string { - if (principal.type !== "user" || principal.issuer !== "linq" || !/^linq:.{1,507}$/.test(principal.id)) return failed("authenticated_sender_required"); + if ( + principal.type !== "user" || + principal.issuer !== "linq" || + !/^linq:.{1,507}$/.test(principal.id) + ) + return failed("authenticated_sender_required"); return JSON.stringify([principal.issuer, principal.id]); } export function configuration(): Config { - const required = (name: string) => { const value = process.env[name]?.trim(); if (!value) return failed(`configure_${name}`); return value; }; + const required = (name: string) => { + const value = process.env[name]?.trim(); + if (!value) return failed(`configure_${name}`); + return value; + }; const origin = required("BELL_PUBLIC_URL"); const parsed = new URL(origin); - if (parsed.protocol !== "https:" || parsed.origin !== origin) return failed("invalid_public_origin"); + if (parsed.protocol !== "https:" || parsed.origin !== origin) + return failed("invalid_public_origin"); const redirectUri = `${origin}/api/onepassword/callback`; if (required("ONEPASSWORD_REDIRECT_URI") !== redirectUri) return failed("invalid_redirect_uri"); - const encoded = required("ONEPASSWORD_TOKEN_ENCRYPTION_KEY"), encryptionKey = Buffer.from(encoded, "base64"); - if (encryptionKey.length !== 32 || encryptionKey.toString("base64") !== encoded) return failed("invalid_encryption_key"); - return { origin, redirectUri, encryptionKey, databaseUrl: required("DATABASE_URL"), clientId: required("ONEPASSWORD_CLIENT_ID"), clientSecret: required("ONEPASSWORD_CLIENT_SECRET") }; + const encoded = required("ONEPASSWORD_TOKEN_ENCRYPTION_KEY"), + encryptionKey = Buffer.from(encoded, "base64"); + if (encryptionKey.length !== 32 || encryptionKey.toString("base64") !== encoded) + return failed("invalid_encryption_key"); + return { + origin, + redirectUri, + encryptionKey, + databaseUrl: required("DATABASE_URL"), + clientId: required("ONEPASSWORD_CLIENT_ID"), + clientSecret: required("ONEPASSWORD_CLIENT_SECRET"), + }; } -const keyEnvelope = z.object({ ver: z.literal(1), typ: z.literal("oauth_integration_key"), key: z.string().regex(/^[a-fA-F0-9]{64}$/), path: z.string() }); +const keyEnvelope = z.object({ + ver: z.literal(1), + typ: z.literal("oauth_integration_key"), + key: z.string().regex(/^[a-fA-F0-9]{64}$/), + path: z.string(), +}); export function integrationIdentity(value: string, clientId: string) { if (!/^ops_[A-Za-z0-9_-]{1,4096}$/.test(value)) return failed("invalid_integration_key"); let parsed: z.infer; - try { parsed = keyEnvelope.parse(JSON.parse(Buffer.from(value.slice(4), "base64url").toString("utf8"))); } - catch { return failed("invalid_integration_key"); } - const parts = /^\/\/api\.1password\.com\/oauth-clients\/([A-Za-z0-9_-]+)\/accounts\/([A-Za-z0-9_-]+)\/users\/([A-Za-z0-9_-]+)$/.exec(parsed.path); + try { + parsed = keyEnvelope.parse( + JSON.parse(Buffer.from(value.slice(4), "base64url").toString("utf8")), + ); + } catch { + return failed("invalid_integration_key"); + } + const parts = + /^\/\/api\.1password\.com\/oauth-clients\/([A-Za-z0-9_-]+)\/accounts\/([A-Za-z0-9_-]+)\/users\/([A-Za-z0-9_-]+)$/.exec( + parsed.path, + ); if (!parts || parts[1] !== clientId) return failed("integration_key_connection_mismatch"); return { path: parsed.path, accountId: parts[2], userId: parts[3] }; } export function frameworkCallback(value: string, origin: string): URL { const url = new URL(value); - if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash || !/^\/eve\/v1\/connections\/[^/]+\/callback\/[^/]+\/[^/]+$/.test(url.pathname)) return failed("invalid_callback"); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password || + url.hash || + !/^\/eve\/v1\/connections\/[^/]+\/callback\/[^/]+\/[^/]+$/.test(url.pathname) + ) + return failed("invalid_callback"); return new URL(url.pathname + url.search, origin); } diff --git a/server/onepassword/extension.ts b/server/onepassword/extension.ts index a646f6a..221d383 100644 --- a/server/onepassword/extension.ts +++ b/server/onepassword/extension.ts @@ -5,18 +5,39 @@ import { failed } from "./config"; type Result = { success: boolean; result?: unknown; error?: { code?: string } }; type ExtensionGlobal = typeof globalThis & { - chrome: { runtime: { getManifest(): { short_name?: string; version: string } }; tabs: { query(query: object): Promise<{ id?: number; url?: string }[]> } }; - api: { initialization: { v1: { whenSettled(): Promise } }; agenticMode: { v1: { enable(): Promise } }; agenticAutofill: { v1: Record) => Promise> } }; + chrome: { + runtime: { getManifest(): { short_name?: string; version: string } }; + tabs: { query(query: object): Promise<{ id?: number; url?: string }[]> }; + }; + api: { + initialization: { v1: { whenSettled(): Promise } }; + agenticMode: { v1: { enable(): Promise } }; + agenticAutofill: { v1: Record) => Promise> }; + }; }; export class ExtensionError extends Error { - constructor(readonly code: "rateLimitExceeded" | "timeout" | "networkError" | "operation_failed") { super(`1Password ${code}`); } + constructor( + readonly code: "rateLimitExceeded" | "timeout" | "networkError" | "operation_failed", + ) { + super(`1Password ${code}`); + } } export function unwrap(value: unknown): unknown { - const parsed = z.object({ success: z.boolean(), result: z.unknown().optional(), error: z.object({ code: z.string().optional() }).optional() }).safeParse(value); + const parsed = z + .object({ + success: z.boolean(), + result: z.unknown().optional(), + error: z.object({ code: z.string().optional() }).optional(), + }) + .safeParse(value); if (!parsed.success) return failed("invalid_extension_response"); if (!parsed.data.success) { const code = parsed.data.error?.code; - throw new ExtensionError(code === "rateLimitExceeded" || code === "timeout" || code === "networkError" ? code : "operation_failed"); + throw new ExtensionError( + code === "rateLimitExceeded" || code === "timeout" || code === "networkError" + ? code + : "operation_failed", + ); } return parsed.data.result; } @@ -26,13 +47,29 @@ async function workerReady(worker: Worker): Promise { if (env.chrome.runtime.getManifest().short_name !== "1Password") return false; let timer: ReturnType | undefined; try { - const ready = await Promise.race([env.api?.initialization?.v1?.whenSettled(), new Promise(resolve => { timer = setTimeout(() => resolve("timeout"), 15_000); })]); - if (ready !== "ready" || !["createAccessRequest", "getAccessRequestStatus", "fillCredential"].every(method => typeof env.api?.agenticAutofill?.v1?.[method] === "function")) return false; + const ready = await Promise.race([ + env.api?.initialization?.v1?.whenSettled(), + new Promise((resolve) => { + timer = setTimeout(() => resolve("timeout"), 15_000); + }), + ]); + if ( + ready !== "ready" || + !["createAccessRequest", "getAccessRequestStatus", "fillCredential"].every( + (method) => typeof env.api?.agenticAutofill?.v1?.[method] === "function", + ) + ) + return false; return (await env.api.agenticMode.v1.enable()).success; - } finally { clearTimeout(timer); } + } finally { + clearTimeout(timer); + } }); } -export async function withExtension(connectUrl: string, operation: (worker: Worker, pages: Page[]) => Promise): Promise { +export async function withExtension( + connectUrl: string, + operation: (worker: Worker, pages: Page[]) => Promise, +): Promise { const browser = await chromium.connectOverCDP(connectUrl, { timeout: 15_000 }); try { const context = browser.contexts()[0]; @@ -41,50 +78,142 @@ export async function withExtension(connectUrl: string, operation: (worker: W while (Date.now() < deadline) { for (const worker of context.serviceWorkers()) { if (!worker.url().startsWith("chrome-extension://")) continue; - if (await workerReady(worker).catch(() => false)) return await operation(worker, context.pages()); + if (await workerReady(worker).catch(() => false)) + return await operation(worker, context.pages()); } - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); } return failed("agentic_autofill_extension_unavailable"); - } finally { await browser.close(); } + } finally { + await browser.close(); + } } -async function call(worker: Worker, method: string, accessToken: string, integrationKey: string, args: Record) { - const value = await worker.evaluate(async ({ method, accessToken, integrationKey, args }) => { - const env = globalThis as ExtensionGlobal; - let timer: ReturnType | undefined; - try { - return await Promise.race([env.api.agenticAutofill.v1[method]({ ...args, accessToken, integrationKey }), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("1Password timed out")), 20_000); })]); - } finally { clearTimeout(timer); } - }, { method, accessToken, integrationKey, args }); +async function call( + worker: Worker, + method: string, + accessToken: string, + integrationKey: string, + args: Record, +) { + const value = await worker.evaluate( + async ({ method, accessToken, integrationKey, args }) => { + const env = globalThis as ExtensionGlobal; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + env.api.agenticAutofill.v1[method]({ ...args, accessToken, integrationKey }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("1Password timed out")), 20_000); + }), + ]); + } finally { + clearTimeout(timer); + } + }, + { method, accessToken, integrationKey, args }, + ); return unwrap(value); } -const createdSchema = z.object({ appLink: z.string().min(1).max(16384), accessRequest: z.object({ id: z.string().min(1), entries: z.array(z.object({ id: z.string().min(1) })).length(1) }) }); -export async function createAccess(connectUrl: string, accessToken: string, integrationKey: string, website: string) { - return withExtension(connectUrl, async worker => { - const value = createdSchema.parse(await call(worker, "createAccessRequest", accessToken, integrationKey, { requests: { version: 2, goal: "Sign in for this Bell conversation", entries: [{ type: "login", parameters: { website }, reason: "Sign in to continue the requested task" }] } })); +const createdSchema = z.object({ + appLink: z.string().min(1).max(16384), + accessRequest: z.object({ + id: z.string().min(1), + entries: z.array(z.object({ id: z.string().min(1) })).length(1), + }), +}); +export async function createAccess( + connectUrl: string, + accessToken: string, + integrationKey: string, + website: string, +) { + return withExtension(connectUrl, async (worker) => { + const value = createdSchema.parse( + await call(worker, "createAccessRequest", accessToken, integrationKey, { + requests: { + version: 2, + goal: "Sign in for this Bell conversation", + entries: [ + { + type: "login", + parameters: { website }, + reason: "Sign in to continue the requested task", + }, + ], + }, + }), + ); const link = new URL(value.appLink); - if (!["https:", "onepassword:"].includes(link.protocol) || link.username || link.password) return failed("invalid_approval_link"); - return { requestId: value.accessRequest.id, entryId: value.accessRequest.entries[0].id, appLink: value.appLink }; + if (!["https:", "onepassword:"].includes(link.protocol) || link.username || link.password) + return failed("invalid_approval_link"); + return { + requestId: value.accessRequest.id, + entryId: value.accessRequest.entries[0].id, + appLink: value.appLink, + }; }); } -export async function checkAccess(browserSessionId: string, accessToken: string, integrationKey: string, requestId: string) { - const session = await new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY }).sessions.retrieve(browserSessionId); - if (session.userMetadata?.onepasswordExtension !== process.env.BROWSERBASE_1PASSWORD_EXTENSION_ID || session.userMetadata?.networkLogging !== "false" || session.userMetadata?.sessionRecording !== "false" || session.userMetadata?.privateContext !== "true") return failed("unsafe_browser_session"); +export async function checkAccess( + browserSessionId: string, + accessToken: string, + integrationKey: string, + requestId: string, +) { + const session = await new Browserbase({ + apiKey: process.env.BROWSERBASE_API_KEY, + }).sessions.retrieve(browserSessionId); + if ( + session.userMetadata?.onepasswordExtension !== process.env.BROWSERBASE_1PASSWORD_EXTENSION_ID || + session.userMetadata?.networkLogging !== "false" || + session.userMetadata?.sessionRecording !== "false" || + session.userMetadata?.privateContext !== "true" + ) + return failed("unsafe_browser_session"); if (!session.connectUrl) return failed("browser_connection_missing"); - return withExtension(session.connectUrl, async worker => z.object({ state: z.string(), resolved: z.array(z.object({ entryId: z.string(), reference: z.string().min(1) })).optional() }).parse(await call(worker, "getAccessRequestStatus", accessToken, integrationKey, { accessRequestUUID: requestId }))); + return withExtension(session.connectUrl, async (worker) => + z + .object({ + state: z.string(), + resolved: z + .array(z.object({ entryId: z.string(), reference: z.string().min(1) })) + .optional(), + }) + .parse( + await call(worker, "getAccessRequestStatus", accessToken, integrationKey, { + accessRequestUUID: requestId, + }), + ), + ); } -export async function fillAccess(connectUrl: string, accessToken: string, integrationKey: string, reference: string, loginUrl: string, successUrl: string): Promise { +export async function fillAccess( + connectUrl: string, + accessToken: string, + integrationKey: string, + reference: string, + loginUrl: string, + successUrl: string, +): Promise { await withExtension(connectUrl, async (worker, pages) => { - const matches = pages.filter(page => page.url() === loginUrl); + const matches = pages.filter((page) => page.url() === loginUrl); if (matches.length !== 1) return failed("login_page_changed"); - const tabId = await worker.evaluate(async url => { - const tabs = (await (globalThis as ExtensionGlobal).chrome.tabs.query({})).filter(tab => tab.url === url); + const tabId = await worker.evaluate(async (url) => { + const tabs = (await (globalThis as ExtensionGlobal).chrome.tabs.query({})).filter( + (tab) => tab.url === url, + ); return tabs.length === 1 ? tabs[0].id : undefined; }, loginUrl); if (tabId === undefined) return failed("login_tab_ambiguous"); - const result = z.object({ status: z.literal("fill_submitted") }).parse(await call(worker, "fillCredential", accessToken, integrationKey, { resourcePath: reference, tabId })); + const result = z.object({ status: z.literal("fill_submitted") }).parse( + await call(worker, "fillCredential", accessToken, integrationKey, { + resourcePath: reference, + tabId, + }), + ); if (result.status !== "fill_submitted") return failed("fill_failed"); const expected = new URL(successUrl); - await matches[0].waitForURL(url => url.origin === expected.origin && url.pathname === expected.pathname, { timeout: 20_000, waitUntil: "domcontentloaded" }); + await matches[0].waitForURL( + (url) => url.origin === expected.origin && url.pathname === expected.pathname, + { timeout: 20_000, waitUntil: "domcontentloaded" }, + ); }); } diff --git a/server/onepassword/http.ts b/server/onepassword/http.ts index 4c4b0fa..38dc32e 100644 --- a/server/onepassword/http.ts +++ b/server/onepassword/http.ts @@ -4,37 +4,88 @@ import { getOnePassword, type OnePasswordOAuth } from "./oauth"; import { personalConnectionsEnabled } from "./config"; export const COOKIE = "__Secure-bell-op-browser"; -const headers = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" }; -const callbackInput = z.object({ state: z.string().regex(/^[A-Za-z0-9_-]{43}$/), code: z.string().min(1).max(4096).optional(), error: z.string().max(100).optional(), integrationKey: z.string().max(4100).optional() }).strict(); -const cookie = (value: string) => `${COOKIE}=${value}; Path=/api/onepassword; HttpOnly; Secure; SameSite=Lax; Max-Age=600`; -const browserNonce = (request: Request) => request.headers.get("cookie")?.split(";").map(value => value.trim()).find(value => value.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1) ?? ""; -function invalid() { return new Response("1Password could not connect. Return to Bell to reconnect or disconnect and try again.", { status: 400, headers }); } -export async function onePasswordGet(request: Request, action: string, service: () => OnePasswordOAuth = getOnePassword): Promise { +const headers = { + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", +}; +const callbackInput = z + .object({ + state: z.string().regex(/^[A-Za-z0-9_-]{43}$/), + code: z.string().min(1).max(4096).optional(), + error: z.string().max(100).optional(), + integrationKey: z.string().max(4100).optional(), + }) + .strict(); +const cookie = (value: string) => + `${COOKIE}=${value}; Path=/api/onepassword; HttpOnly; Secure; SameSite=Lax; Max-Age=600`; +const browserNonce = (request: Request) => + request.headers + .get("cookie") + ?.split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith(`${COOKIE}=`)) + ?.slice(COOKIE.length + 1) ?? ""; +function invalid() { + return new Response( + "1Password could not connect. Return to Bell to reconnect or disconnect and try again.", + { status: 400, headers }, + ); +} +export async function onePasswordGet( + request: Request, + action: string, + service: () => OnePasswordOAuth = getOnePassword, +): Promise { if (action === "callback") return callbackPage(); if (action !== "start") return new Response(null, { status: 404, headers }); - if (!personalConnectionsEnabled()) return new Response("Personal 1Password connections are not available yet.", { status: 503, headers }); + if (!personalConnectionsEnabled()) + return new Response("Personal 1Password connections are not available yet.", { + status: 503, + headers, + }); try { const params = new URL(request.url).searchParams; if (params.getAll("state").length !== 1) return invalid(); const nonce = randomBytes(32).toString("base64url"); - return new Response(null, { status: 303, headers: { ...headers, Location: await service().open(params.get("state")!, nonce), "Set-Cookie": cookie(nonce) } }); - } catch { return invalid(); } + return new Response(null, { + status: 303, + headers: { + ...headers, + Location: await service().open(params.get("state")!, nonce), + "Set-Cookie": cookie(nonce), + }, + }); + } catch { + return invalid(); + } } -export async function onePasswordPost(request: Request, action: string, service: () => OnePasswordOAuth = getOnePassword): Promise { +export async function onePasswordPost( + request: Request, + action: string, + service: () => OnePasswordOAuth = getOnePassword, +): Promise { if (action !== "complete") return new Response(null, { status: 404, headers }); try { const provider = service(); - if (request.headers.get("origin") !== provider.config.origin || request.headers.get("content-type")?.split(";")[0] !== "application/json") return invalid(); + if ( + request.headers.get("origin") !== provider.config.origin || + request.headers.get("content-type")?.split(";")[0] !== "application/json" + ) + return invalid(); const raw = await request.text(); if (raw.length > 10_000) return invalid(); const input = callbackInput.parse(JSON.parse(raw)); const location = await provider.capture(input, browserNonce(request)); return Response.json({ location }, { headers }); - } catch { return invalid(); } + } catch { + return invalid(); + } } export function callbackPage(): Response { const nonce = randomBytes(18).toString("base64"); - return new Response(`Connect 1Password to Bell

Connecting your 1Password account…

`, { headers: { ...headers, "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'` } }); +`, + { + headers: { + ...headers, + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'`, + }, + }, + ); } diff --git a/server/onepassword/login.ts b/server/onepassword/login.ts index afac5b9..898c6ae 100644 --- a/server/onepassword/login.ts +++ b/server/onepassword/login.ts @@ -6,58 +6,128 @@ import { checkAccess, ExtensionError } from "./extension"; import { getOnePassword, type OnePasswordOAuth } from "./oauth"; import type { ConnectionState, LoginRequest, RecordStore } from "./store"; -export interface LoginInput { loginUrl: string; successUrl: string } +export interface LoginInput { + loginUrl: string; + successUrl: string; +} export function validateLogin(input: LoginInput): LoginInput { - const login = new URL(input.loginUrl), success = new URL(input.successUrl); - if ([login, success].some(url => url.protocol !== "https:" || url.username || url.password || url.hash || url.href.length > 2048) || login.origin !== success.origin || login.pathname === success.pathname) return failed("invalid_login_destination"); + const login = new URL(input.loginUrl), + success = new URL(input.successUrl); + if ( + [login, success].some( + (url) => + url.protocol !== "https:" || + url.username || + url.password || + url.hash || + url.href.length > 2048, + ) || + login.origin !== success.origin || + login.pathname === success.pathname + ) + return failed("invalid_login_destination"); return { loginUrl: login.href, successUrl: success.href }; } -export function loginId(sessionId: string, callId: string): string { return hash(JSON.stringify([sessionId, callId])); } +export function loginId(sessionId: string, callId: string): string { + return hash(JSON.stringify([sessionId, callId])); +} function validLogin(record: ConnectionState, id: string, now: number): LoginRequest { const login = record.login; - if (!login || login.id !== id || login.generation !== record.generation || login.expiresAt <= now || record.status !== "active") return failed("login_request_expired"); + if ( + !login || + login.id !== id || + login.generation !== record.generation || + login.expiresAt <= now || + record.status !== "active" + ) + return failed("login_request_expired"); return login; } export class OnePasswordLogin { - constructor(readonly oauth: OnePasswordOAuth, private readonly check = checkAccess, private readonly now: () => number = Date.now) {} + constructor( + readonly oauth: OnePasswordOAuth, + private readonly check = checkAccess, + private readonly now: () => number = Date.now, + ) {} async getToken(principal: ConnectionPrincipal, id: string) { - return this.oauth.store.withOwner(ownerOf(principal), async db => { + return this.oauth.store.withOwner(ownerOf(principal), async (db) => { const record = await db.read(); - if (!record.login || record.login.id !== id) throw new ConnectionAuthorizationRequiredError("onepassword-login"); + if (!record.login || record.login.id !== id) + throw new ConnectionAuthorizationRequiredError("onepassword-login"); const login = validLogin(record, id, this.now()); - if (login.status === "pending") throw new ConnectionAuthorizationRequiredError("onepassword-login"); - if (login.status !== "approved" && login.status !== "filled") return failed("login_request_failed_start_again"); + if (login.status === "pending") + throw new ConnectionAuthorizationRequiredError("onepassword-login"); + if (login.status !== "approved" && login.status !== "filled") + return failed("login_request_failed_start_again"); return { token: `approved-${id}` }; }); } - async start(principal: ConnectionPrincipal, sessionId: string, id: string, input: LoginInput, callbackUrl: string, create: (credentials: { accessToken: string; integrationKey: string }) => Promise<{ browserSessionId: string; requestId: string; entryId: string; appLink: string }>) { + async start( + principal: ConnectionPrincipal, + sessionId: string, + id: string, + input: LoginInput, + callbackUrl: string, + create: (credentials: { accessToken: string; integrationKey: string }) => Promise<{ + browserSessionId: string; + requestId: string; + entryId: string; + appLink: string; + }>, + ) { const validated = validateLogin(input); frameworkCallback(callbackUrl, this.oauth.config.origin); - return this.oauth.store.withOwner(ownerOf(principal), async db => { + return this.oauth.store.withOwner(ownerOf(principal), async (db) => { const record = await db.read(); let login = record.login; if (login?.id === id) { validLogin(record, id, this.now()); - if (login.status !== "pending" || login.callbackUrl !== callbackUrl) return failed("login_request_failed_start_again"); + if (login.status !== "pending" || login.callbackUrl !== callbackUrl) + return failed("login_request_failed_start_again"); } else { - if (login && ["creating", "pending", "approved"].includes(login.status) && login.expiresAt > this.now()) return failed("another_login_is_pending"); + if ( + login && + ["creating", "pending", "approved"].includes(login.status) && + login.expiresAt > this.now() + ) + return failed("another_login_is_pending"); const credentials = await this.oauth.activeCredentials(db, record); - login = { id, sessionId, ...validated, callbackUrl, cap: randomBytes(32).toString("base64url"), generation: record.generation, expiresAt: this.now() + 600_000, status: "creating" }; + login = { + id, + sessionId, + ...validated, + callbackUrl, + cap: randomBytes(32).toString("base64url"), + generation: record.generation, + expiresAt: this.now() + 600_000, + status: "creating", + }; record.login = login; await db.write(record); const created = await create(credentials); Object.assign(login, created, { status: "pending" }); - } await db.saveTicket(record, hash(login.cap), login.expiresAt); - return { challenge: { displayName: "1Password login approval", url: `${this.oauth.config.origin}/api/onepassword/approval#${login.cap}`, expiresAt: new Date(login.expiresAt).toISOString(), instructions: "Open this private page on the computer running 1Password. Approve the requested website, then return to Bell." }, resume: { id } }; + return { + challenge: { + displayName: "1Password login approval", + url: `${this.oauth.config.origin}/api/onepassword/approval#${login.cap}`, + expiresAt: new Date(login.expiresAt).toISOString(), + instructions: + "Open this private page on the computer running 1Password. Approve the requested website, then return to Bell.", + }, + resume: { id }, + }; }); } - private async ticket(cap: string, operation: (db: RecordStore, record: ConnectionState, login: LoginRequest) => Promise): Promise { + private async ticket( + cap: string, + operation: (db: RecordStore, record: ConnectionState, login: LoginRequest) => Promise, + ): Promise { if (!/^[A-Za-z0-9_-]{43}$/.test(cap)) return failed("invalid_approval"); const owner = await this.oauth.store.ticketOwner(hash(cap)); if (!owner) return failed("invalid_approval"); - return this.oauth.store.withOwner(owner, async db => { + return this.oauth.store.withOwner(owner, async (db) => { const record = await db.read(); if (!record.login || record.login.cap !== cap) return failed("invalid_approval"); return operation(db, record, validLogin(record, record.login.id, this.now())); @@ -75,18 +145,37 @@ export class OnePasswordLogin { async poll(cap: string, manual: boolean) { return this.ticket(cap, async (db, record, login) => { if (login.status === "approved") return { status: "approved", location: this.forward(login) }; - if (login.status !== "pending" || !login.openedAt || !login.requestId || !login.browserSessionId) return failed("approval_not_opened"); + if ( + login.status !== "pending" || + !login.openedAt || + !login.requestId || + !login.browserSessionId + ) + return failed("approval_not_opened"); const elapsed = this.now() - login.openedAt; - if (this.now() < (login.nextCheckAt ?? 0)) return { status: "pending", retryAfter: Math.ceil((login.nextCheckAt! - this.now()) / 1000) }; + if (this.now() < (login.nextCheckAt ?? 0)) + return { + status: "pending", + retryAfter: Math.ceil((login.nextCheckAt! - this.now()) / 1000), + }; if (elapsed >= 120_000 && !manual) return { status: "waiting_for_user" }; const interval = elapsed < 30_000 ? 2000 : elapsed < 60_000 ? 5000 : 10_000; login.nextCheckAt = this.now() + interval; await db.write(record); const credentials = await this.oauth.activeCredentials(db, record); let status: Awaited>; - try { status = await this.check(login.browserSessionId, credentials.accessToken, credentials.integrationKey, login.requestId); } - catch (error) { - if (error instanceof ExtensionError && ["rateLimitExceeded", "timeout", "networkError"].includes(error.code)) { + try { + status = await this.check( + login.browserSessionId, + credentials.accessToken, + credentials.integrationKey, + login.requestId, + ); + } catch (error) { + if ( + error instanceof ExtensionError && + ["rateLimitExceeded", "timeout", "networkError"].includes(error.code) + ) { const retryAfter = error.code === "rateLimitExceeded" ? 60 : interval / 1000; login.nextCheckAt = this.now() + retryAfter * 1000; await db.write(record); @@ -105,7 +194,7 @@ export class OnePasswordLogin { } return { status: "pending", retryAfter: interval / 1000 }; } - const matches = status.resolved?.filter(entry => entry.entryId === login.entryId); + const matches = status.resolved?.filter((entry) => entry.entryId === login.entryId); login.appLink = undefined; if (status.state !== "resolved" || matches?.length !== 1) { login.status = "failed"; @@ -125,19 +214,42 @@ export class OnePasswordLogin { url.searchParams.set("result", login.result!); return url.href; } - async complete(principal: ConnectionPrincipal, id: string, callbackUrl: string, resume: { id: string } | undefined, params: Readonly>) { - return this.oauth.store.withOwner(ownerOf(principal), async db => { + async complete( + principal: ConnectionPrincipal, + id: string, + callbackUrl: string, + resume: { id: string } | undefined, + params: Readonly>, + ) { + return this.oauth.store.withOwner(ownerOf(principal), async (db) => { const login = validLogin(await db.read(), id, this.now()); - if (resume?.id !== id || login.callbackUrl !== callbackUrl || !params.result || params.result !== login.result) return failed("invalid_approval_completion"); - if (login.status !== "approved" && login.status !== "filled") return failed("login_not_approved"); + if ( + resume?.id !== id || + login.callbackUrl !== callbackUrl || + !params.result || + params.result !== login.result + ) + return failed("invalid_approval_completion"); + if (login.status !== "approved" && login.status !== "filled") + return failed("login_not_approved"); return { token: `approved-${id}` }; }); } - async fill(principal: ConnectionPrincipal, id: string, sessionId: string, fill: (credentials: { accessToken: string; integrationKey: string }, login: LoginRequest) => Promise) { - return this.oauth.store.withOwner(ownerOf(principal), async db => { - const record = await db.read(), login = validLogin(record, id, this.now()); + async fill( + principal: ConnectionPrincipal, + id: string, + sessionId: string, + fill: ( + credentials: { accessToken: string; integrationKey: string }, + login: LoginRequest, + ) => Promise, + ) { + return this.oauth.store.withOwner(ownerOf(principal), async (db) => { + const record = await db.read(), + login = validLogin(record, id, this.now()); if (login.sessionId !== sessionId) return failed("wrong_conversation"); - if (login.status === "filled") return { status: "destination_verified", url: login.successUrl }; + if (login.status === "filled") + return { status: "destination_verified", url: login.successUrl }; if (login.status !== "approved" || !login.reference) return failed("login_not_approved"); const credentials = await this.oauth.activeCredentials(db, record); login.status = "failed"; diff --git a/server/onepassword/oauth.ts b/server/onepassword/oauth.ts index 3740e00..1e01768 100644 --- a/server/onepassword/oauth.ts +++ b/server/onepassword/oauth.ts @@ -3,49 +3,100 @@ import { ConnectionAuthorizationRequiredError, type ConnectionPrincipal } from " import { z } from "zod"; import { hash } from "../encrypted-record"; import { createPool } from "../postgres"; -import { configuration, failed, frameworkCallback, integrationIdentity, ownerOf, scopes, type Config } from "./config"; -import { OnePasswordStore, type ConnectionState, type OAuthAttempt, type RecordStore, type Tokens } from "./store"; +import { + configuration, + failed, + frameworkCallback, + integrationIdentity, + ownerOf, + scopes, + type Config, +} from "./config"; +import { + OnePasswordStore, + type ConnectionState, + type OAuthAttempt, + type RecordStore, + type Tokens, +} from "./store"; const tokenResponse = z.object({ - access_token: z.string().min(1).max(16384), refresh_token: z.string().min(1).max(16384), - token_type: z.string().refine(value => value.toLowerCase() === "bearer"), - expires_in: z.number().int().positive().max(86400), scope: z.string().optional(), - user_id: z.string().min(1).optional(), account_id: z.string().min(1).optional(), + access_token: z.string().min(1).max(16384), + refresh_token: z.string().min(1).max(16384), + token_type: z.string().refine((value) => value.toLowerCase() === "bearer"), + expires_in: z.number().int().positive().max(86400), + scope: z.string().optional(), + user_id: z.string().min(1).optional(), + account_id: z.string().min(1).optional(), }); export type Resume = { attemptId: string }; export class OnePasswordOAuth { - constructor(readonly config: Config, readonly store: OnePasswordStore, private readonly fetcher: typeof fetch = fetch, private readonly now: () => number = Date.now) {} + constructor( + readonly config: Config, + readonly store: OnePasswordStore, + private readonly fetcher: typeof fetch = fetch, + private readonly now: () => number = Date.now, + ) {} private async exchange(fields: Record): Promise { let response: Response; try { response = await this.fetcher("https://api.1password.com/v1/oauth/token", { - method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000), - headers: { Authorization: `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64")}`, "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, body: new URLSearchParams(fields), }); - } catch { return failed("token_exchange_failed_reconnect"); } + } catch { + return failed("token_exchange_failed_reconnect"); + } const parsed = tokenResponse.safeParse(await response.json().catch(() => null)); if (!response.ok || !parsed.success) return failed("token_exchange_failed_reconnect"); const value = parsed.data; - if (value.scope && !scopes.split(" ").every(scope => value.scope!.split(" ").includes(scope))) return failed("missing_scopes"); - return { accessToken: value.access_token, refreshToken: value.refresh_token, expiresAt: this.now() + value.expires_in * 1000, userId: value.user_id, accountId: value.account_id }; + if (value.scope && !scopes.split(" ").every((scope) => value.scope!.split(" ").includes(scope))) + return failed("missing_scopes"); + return { + accessToken: value.access_token, + refreshToken: value.refresh_token, + expiresAt: this.now() + value.expires_in * 1000, + userId: value.user_id, + accountId: value.account_id, + }; } - private marker(state: ConnectionState) { return { token: `onepassword-connected-${state.generation}` }; } - private verifyIdentity(tokens: Tokens, identity: NonNullable, requireUser = false): void { - if ((requireUser && !tokens.userId) || (tokens.userId && tokens.userId !== identity.userId) || (tokens.accountId && tokens.accountId !== identity.accountId)) return failed("account_mismatch_disconnect_and_reconnect"); + private marker(state: ConnectionState) { + return { token: `onepassword-connected-${state.generation}` }; + } + private verifyIdentity( + tokens: Tokens, + identity: NonNullable, + requireUser = false, + ): void { + if ( + (requireUser && !tokens.userId) || + (tokens.userId && tokens.userId !== identity.userId) || + (tokens.accountId && tokens.accountId !== identity.accountId) + ) + return failed("account_mismatch_disconnect_and_reconnect"); } async status(principal: ConnectionPrincipal) { - return this.store.withOwner(ownerOf(principal), async db => ({ status: (await db.read()).status })); + return this.store.withOwner(ownerOf(principal), async (db) => ({ + status: (await db.read()).status, + })); } async getToken(principal: ConnectionPrincipal) { - return this.store.withOwner(ownerOf(principal), async db => { + return this.store.withOwner(ownerOf(principal), async (db) => { const state = await db.read(); await this.activeCredentials(db, state); return this.marker(state); }); } async activeCredentials(db: RecordStore, state: ConnectionState) { - if (state.status !== "active" || !state.tokens || !state.integrationKey || !state.identity) throw new ConnectionAuthorizationRequiredError("onepassword"); + if (state.status !== "active" || !state.tokens || !state.integrationKey || !state.identity) + throw new ConnectionAuthorizationRequiredError("onepassword"); await this.refresh(db, state); return { accessToken: state.tokens.accessToken, integrationKey: state.integrationKey }; } @@ -55,7 +106,11 @@ export class OnePasswordOAuth { const previousStatus = state.status; // Commit before calling a provider that rotates single-use refresh tokens. await db.write({ ...state, status: "refreshing" }); - const tokens = await this.exchange({ grant_type: "refresh_token", refresh_token: state.tokens.refreshToken, scope: scopes }); + const tokens = await this.exchange({ + grant_type: "refresh_token", + refresh_token: state.tokens.refreshToken, + scope: scopes, + }); if (state.identity) this.verifyIdentity(tokens, state.identity); state.tokens = tokens; state.status = previousStatus; @@ -63,28 +118,53 @@ export class OnePasswordOAuth { } async start(principal: ConnectionPrincipal, callbackUrl: string) { frameworkCallback(callbackUrl, this.config.origin); - return this.store.withOwner(ownerOf(principal), async db => { + return this.store.withOwner(ownerOf(principal), async (db) => { const state = await db.read(); if (state.status === "disconnecting") return failed("disconnect_pending"); let attempt = state.attempt; - if (!attempt || attempt.callbackUrl !== callbackUrl || attempt.expiresAt <= this.now() || attempt.status !== "pending") { - attempt = { id: randomUUID(), state: randomBytes(32).toString("base64url"), verifier: randomBytes(32).toString("base64url"), callbackUrl, expiresAt: this.now() + 600_000, status: "pending" }; + if ( + !attempt || + attempt.callbackUrl !== callbackUrl || + attempt.expiresAt <= this.now() || + attempt.status !== "pending" + ) { + attempt = { + id: randomUUID(), + state: randomBytes(32).toString("base64url"), + verifier: randomBytes(32).toString("base64url"), + callbackUrl, + expiresAt: this.now() + 600_000, + status: "pending", + }; state.attempt = attempt; state.login = undefined; state.status = "reconnect"; - } await db.saveTicket(state, hash(attempt.state), attempt.expiresAt); - return { challenge: { displayName: "1Password", url: `${this.config.origin}/api/onepassword/start?state=${attempt.state}`, expiresAt: new Date(attempt.expiresAt).toISOString(), instructions: "Connect your personal 1Password account. Keep this link private, then return to this conversation." }, resume: { attemptId: attempt.id } }; + return { + challenge: { + displayName: "1Password", + url: `${this.config.origin}/api/onepassword/start?state=${attempt.state}`, + expiresAt: new Date(attempt.expiresAt).toISOString(), + instructions: + "Connect your personal 1Password account. Keep this link private, then return to this conversation.", + }, + resume: { attemptId: attempt.id }, + }; }); } - private async ticket(state: string, operation: (db: RecordStore, record: ConnectionState, attempt: OAuthAttempt) => Promise): Promise { + private async ticket( + state: string, + operation: (db: RecordStore, record: ConnectionState, attempt: OAuthAttempt) => Promise, + ): Promise { if (!/^[A-Za-z0-9_-]{43}$/.test(state)) return failed("invalid_state"); const owner = await this.store.ticketOwner(hash(state)); if (!owner) return failed("invalid_state"); - return this.store.withOwner(owner, async db => { - const record = await db.read(), attempt = record.attempt; - if (!attempt || attempt.state !== state || attempt.expiresAt <= this.now()) return failed("authorization_expired"); + return this.store.withOwner(owner, async (db) => { + const record = await db.read(), + attempt = record.attempt; + if (!attempt || attempt.state !== state || attempt.expiresAt <= this.now()) + return failed("authorization_expired"); return operation(db, record, attempt); }); } @@ -94,14 +174,27 @@ export class OnePasswordOAuth { attempt.browserHash = hash(browserNonce); await db.write(record); const url = new URL("https://my.1password.com/oauth/authorize"); - url.search = new URLSearchParams({ client_id: this.config.clientId, redirect_uri: this.config.redirectUri, response_type: "code", scope: scopes, state, code_challenge: hash(attempt.verifier), code_challenge_method: "S256" }).toString(); + url.search = new URLSearchParams({ + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + response_type: "code", + scope: scopes, + state, + code_challenge: hash(attempt.verifier), + code_challenge_method: "S256", + }).toString(); return url.href; }); } - async capture(input: { state: string; code?: string; error?: string; integrationKey?: string }, browserNonce: string) { + async capture( + input: { state: string; code?: string; error?: string; integrationKey?: string }, + browserNonce: string, + ) { return this.ticket(input.state, async (db, record, attempt) => { - if (!browserNonce || attempt.browserHash !== hash(browserNonce)) return failed("browser_binding_mismatch"); - if ((attempt.status === "completed" || attempt.status === "failed") && attempt.result) return this.forward(attempt); + if (!browserNonce || attempt.browserHash !== hash(browserNonce)) + return failed("browser_binding_mismatch"); + if ((attempt.status === "completed" || attempt.status === "failed") && attempt.result) + return this.forward(attempt); if (attempt.status !== "pending") return failed("authorization_expired"); if (input.error || !input.code || input.code.length > 4096) { attempt.status = "failed"; @@ -112,7 +205,8 @@ export class OnePasswordOAuth { } if (input.integrationKey) { const identity = integrationIdentity(input.integrationKey, this.config.clientId); - if (record.identity && identity.path !== record.identity.path) return failed("disconnect_before_changing_account"); + if (record.identity && identity.path !== record.identity.path) + return failed("disconnect_before_changing_account"); attempt.integrationKey = input.integrationKey; record.integrationKey = input.integrationKey; record.identity = identity; @@ -120,7 +214,12 @@ export class OnePasswordOAuth { // Save the one-time fragment before exchanging the code. Failed exchanges retain it for recovery. attempt.status = "exchanging"; await db.write(record); - const tokens = await this.exchange({ grant_type: "authorization_code", code: input.code, code_verifier: attempt.verifier, redirect_uri: this.config.redirectUri }); + const tokens = await this.exchange({ + grant_type: "authorization_code", + code: input.code, + code_verifier: attempt.verifier, + redirect_uri: this.config.redirectUri, + }); const integrationKey = attempt.integrationKey ?? record.integrationKey; if (!integrationKey) { record.tokens = tokens; @@ -149,21 +248,40 @@ export class OnePasswordOAuth { url.searchParams.set("result", attempt.result!); return url.href; } - async complete(principal: ConnectionPrincipal, callbackUrl: string, resume: Resume | undefined, params: Readonly>) { - return this.store.withOwner(ownerOf(principal), async db => { - const record = await db.read(), attempt = record.attempt; - if (!resume || !attempt || attempt.id !== resume.attemptId || attempt.callbackUrl !== callbackUrl || !params.result || params.result !== attempt.result) return failed("invalid_completion"); + async complete( + principal: ConnectionPrincipal, + callbackUrl: string, + resume: Resume | undefined, + params: Readonly>, + ) { + return this.store.withOwner(ownerOf(principal), async (db) => { + const record = await db.read(), + attempt = record.attempt; + if ( + !resume || + !attempt || + attempt.id !== resume.attemptId || + attempt.callbackUrl !== callbackUrl || + !params.result || + params.result !== attempt.result + ) + return failed("invalid_completion"); if (attempt.status === "failed") return failed("authorization_denied"); - if (attempt.status !== "completed" || record.status !== "active") return failed("invalid_completion"); + if (attempt.status !== "completed" || record.status !== "active") + return failed("invalid_completion"); return this.marker(record); }); } async disconnect(principal: ConnectionPrincipal) { - return this.store.withOwner(ownerOf(principal), async db => { + return this.store.withOwner(ownerOf(principal), async (db) => { const state = await db.read(); if (!state.tokens && state.integrationKey) return failed("reconnect_before_disconnect"); - if (!state.tokens) { await db.write({ status: "disconnected", generation: state.generation + 1 }); return { status: "disconnected" }; } - if (state.status === "refreshing") return failed("refresh_uncertain_reconnect_before_disconnect"); + if (!state.tokens) { + await db.write({ status: "disconnected", generation: state.generation + 1 }); + return { status: "disconnected" }; + } + if (state.status === "refreshing") + return failed("refresh_uncertain_reconnect_before_disconnect"); state.status = "disconnecting"; state.attempt = undefined; state.login = undefined; @@ -171,8 +289,22 @@ export class OnePasswordOAuth { await this.refresh(db, state); let response: Response; try { - response = await this.fetcher("https://api.1password.com/v1alpha1/oauth-integrations/self:revoke", { method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${state.tokens.accessToken}`, "Content-Type": "application/json" }, body: "{}" }); - } catch { return failed("disconnect_failed_retry"); } + response = await this.fetcher( + "https://api.1password.com/v1alpha1/oauth-integrations/self:revoke", + { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(15_000), + headers: { + Authorization: `Bearer ${state.tokens.accessToken}`, + "Content-Type": "application/json", + }, + body: "{}", + }, + ); + } catch { + return failed("disconnect_failed_retry"); + } if (!response.ok) return failed("disconnect_failed_retry"); await db.write({ status: "disconnected", generation: state.generation + 1 }); return { status: "disconnected" }; @@ -183,7 +315,10 @@ let singleton: OnePasswordOAuth | undefined; export function getOnePassword(): OnePasswordOAuth { if (!singleton) { const config = configuration(); - singleton = new OnePasswordOAuth(config, new OnePasswordStore(createPool(config.databaseUrl, "1Password"), config.encryptionKey)); + singleton = new OnePasswordOAuth( + config, + new OnePasswordStore(createPool(config.databaseUrl, "1Password"), config.encryptionKey), + ); } return singleton; } diff --git a/server/onepassword/store.ts b/server/onepassword/store.ts index 6d6c578..59fbf88 100644 --- a/server/onepassword/store.ts +++ b/server/onepassword/store.ts @@ -3,17 +3,42 @@ import { withAdvisoryLock } from "../postgres"; import { seal, unseal } from "../encrypted-record"; import { failed } from "./config"; -export interface Tokens { accessToken: string; refreshToken: string; expiresAt: number; userId?: string; accountId?: string } +export interface Tokens { + accessToken: string; + refreshToken: string; + expiresAt: number; + userId?: string; + accountId?: string; +} export interface OAuthAttempt { - id: string; state: string; verifier: string; callbackUrl: string; expiresAt: number; + id: string; + state: string; + verifier: string; + callbackUrl: string; + expiresAt: number; status: "pending" | "exchanging" | "completed" | "failed"; - browserHash?: string; result?: string; integrationKey?: string; + browserHash?: string; + result?: string; + integrationKey?: string; } export interface LoginRequest { - id: string; sessionId: string; generation: number; loginUrl: string; successUrl: string; - callbackUrl: string; cap: string; expiresAt: number; status: "creating" | "pending" | "approved" | "filled" | "failed"; - browserSessionId?: string; requestId?: string; entryId?: string; appLink?: string; reference?: string; - openedAt?: number; nextCheckAt?: number; result?: string; + id: string; + sessionId: string; + generation: number; + loginUrl: string; + successUrl: string; + callbackUrl: string; + cap: string; + expiresAt: number; + status: "creating" | "pending" | "approved" | "filled" | "failed"; + browserSessionId?: string; + requestId?: string; + entryId?: string; + appLink?: string; + reference?: string; + openedAt?: number; + nextCheckAt?: number; + result?: string; } export interface ConnectionState { status: "disconnected" | "active" | "reconnect" | "refreshing" | "disconnecting"; @@ -25,32 +50,68 @@ export interface ConnectionState { login?: LoginRequest; } export class RecordStore { - constructor(private readonly client: PoolClient, readonly owner: string, private readonly key: Buffer) {} + constructor( + private readonly client: PoolClient, + readonly owner: string, + private readonly key: Buffer, + ) {} async read(): Promise { - const result = await this.client.query<{ encrypted_state: string }>("SELECT encrypted_state FROM bell_onepassword_connections WHERE owner=$1", [this.owner]); + const result = await this.client.query<{ encrypted_state: string }>( + "SELECT encrypted_state FROM bell_onepassword_connections WHERE owner=$1", + [this.owner], + ); if (!result.rows[0]) return { status: "disconnected", generation: 0 }; - try { return unseal(result.rows[0].encrypted_state, this.key, `onepassword:${this.owner}`); } - catch { return failed("credentials_unreadable"); } + try { + return unseal( + result.rows[0].encrypted_state, + this.key, + `onepassword:${this.owner}`, + ); + } catch { + return failed("credentials_unreadable"); + } } async write(state: ConnectionState): Promise { - await this.client.query("INSERT INTO bell_onepassword_connections(owner,encrypted_state) VALUES($1,$2) ON CONFLICT(owner) DO UPDATE SET encrypted_state=EXCLUDED.encrypted_state,updated_at=now()", [this.owner, seal(state, this.key, `onepassword:${this.owner}`)]); + await this.client.query( + "INSERT INTO bell_onepassword_connections(owner,encrypted_state) VALUES($1,$2) ON CONFLICT(owner) DO UPDATE SET encrypted_state=EXCLUDED.encrypted_state,updated_at=now()", + [this.owner, seal(state, this.key, `onepassword:${this.owner}`)], + ); } async saveTicket(state: ConnectionState, hash: string, expiresAt: number): Promise { await this.client.query("BEGIN"); try { await this.write(state); - await this.client.query("DELETE FROM bell_onepassword_tickets WHERE owner=$1 OR expires_at { - return (await this.pool.query<{ owner: string }>("SELECT owner FROM bell_onepassword_tickets WHERE hash=$1 AND expires_at>now()", [hash])).rows[0]?.owner; + return ( + await this.pool.query<{ owner: string }>( + "SELECT owner FROM bell_onepassword_tickets WHERE hash=$1 AND expires_at>now()", + [hash], + ) + ).rows[0]?.owner; } async withOwner(owner: string, operation: (db: RecordStore) => Promise): Promise { - return withAdvisoryLock(this.pool, `onepassword:${owner}`, (client) => operation(new RecordStore(client, owner, this.key))); + return withAdvisoryLock(this.pool, `onepassword:${owner}`, (client) => + operation(new RecordStore(client, owner, this.key)), + ); } } diff --git a/server/operator-auth.ts b/server/operator-auth.ts index 45fe625..c4e7b8f 100644 --- a/server/operator-auth.ts +++ b/server/operator-auth.ts @@ -5,8 +5,12 @@ const loopbackHost = /^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?$/i; export function localOperatorAuth(request: Request) { // Checking every host also covers Next's proxy and prevents DNS rebinding. - const hosts = [new URL(request.url).host, request.headers.get("host"), request.headers.get("x-forwarded-host")]; - if (hosts.some(host => host !== null && !loopbackHost.test(host))) return null; + const hosts = [ + new URL(request.url).host, + request.headers.get("host"), + request.headers.get("x-forwarded-host"), + ]; + if (hosts.some((host) => host !== null && !loopbackHost.test(host))) return null; return developmentAuth(request); } @@ -14,28 +18,41 @@ function operatorCredentials() { const username = process.env.BELL_OPERATOR_USERNAME?.trim(); const password = process.env.BELL_OPERATOR_PASSWORD; return username && !username.includes(":") && password && Buffer.byteLength(password) >= 32 - ? { username, password } : undefined; + ? { username, password } + : undefined; } -export const operatorAuth = withAuthChallenges((request: Request) => { - // Browser requests need this guard even when local development auth is used. - // Server clients do not send browser origin metadata and authenticate normally. - if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && - (request.headers.has("origin") || request.headers.has("sec-fetch-site"))) - assertOperatorOrigin(request); - const credentials = operatorCredentials(); - if (!credentials) return null; - const result = verifyHttpBasic(request.headers.get("authorization"), credentials); - return result.ok ? result.sessionAuth : null; -}, [{ scheme: "Basic", parameters: { realm: "Bell", charset: "UTF-8" } }]); +export const operatorAuth = withAuthChallenges( + (request: Request) => { + // Browser requests need this guard even when local development auth is used. + // Server clients do not send browser origin metadata and authenticate normally. + if ( + !["GET", "HEAD", "OPTIONS"].includes(request.method) && + (request.headers.has("origin") || request.headers.has("sec-fetch-site")) + ) + assertOperatorOrigin(request); + const credentials = operatorCredentials(); + if (!credentials) return null; + const result = verifyHttpBasic(request.headers.get("authorization"), credentials); + return result.ok ? result.sessionAuth : null; + }, + [{ scheme: "Basic", parameters: { realm: "Bell", charset: "UTF-8" } }], +); function assertOperatorOrigin(request: Request): void { const expected = new URL(request.url); const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host"); const protocol = request.headers.get("x-forwarded-proto"); - if (host) { expected.port = ""; expected.host = host; } + if (host) { + expected.port = ""; + expected.host = host; + } if (protocol === "https" || protocol === "http") expected.protocol = `${protocol}:`; const origin = request.headers.get("origin"); - if (origin !== null ? origin !== expected.origin : request.headers.get("sec-fetch-site") !== "same-origin") + if ( + origin !== null + ? origin !== expected.origin + : request.headers.get("sec-fetch-site") !== "same-origin" + ) throw new ForbiddenError({ message: "Same-origin access required." }); } diff --git a/server/parallel.ts b/server/parallel.ts index 49a6b72..f5e8d80 100644 --- a/server/parallel.ts +++ b/server/parallel.ts @@ -3,17 +3,31 @@ import { z } from "zod"; const maxCharsTotal = 16_000; const objective = z.string().trim().min(1).max(2_000); -const credentialParameter = /(?:^|[_-])auth(?:$|[_-])|authorization|authentication|oauth|token|password|secret|credential|signature|session|api.?key|key-pair-id|^(authcode|code|key|sig|jwt|otp|policy)$/i; -export const publicUrl = z.string().trim().max(2_048).transform((value) => value.split("#", 1)[0]).refine((value) => { - try { - const url = new URL(value); - const host = url.hostname.replace(/\.$/, ""); - return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password && - !url.port && ![...url.searchParams.keys()].some((key) => credentialParameter.test(key)) && - !isIP(host.replace(/^\[|\]$/g, "")) && host.includes(".") && - !/(^|\.)(localhost|local|internal|lan|home|test|invalid)$/.test(host); - } catch { return false; } -}, "Use a public HTTP(S) domain URL without credentials, token parameters, or a custom port."); +const credentialParameter = + /(?:^|[_-])auth(?:$|[_-])|authorization|authentication|oauth|token|password|secret|credential|signature|session|api.?key|key-pair-id|^(authcode|code|key|sig|jwt|otp|policy)$/i; +export const publicUrl = z + .string() + .trim() + .max(2_048) + .transform((value) => value.split("#", 1)[0]) + .refine((value) => { + try { + const url = new URL(value); + const host = url.hostname.replace(/\.$/, ""); + return ( + ["https:", "http:"].includes(url.protocol) && + !url.username && + !url.password && + !url.port && + ![...url.searchParams.keys()].some((key) => credentialParameter.test(key)) && + !isIP(host.replace(/^\[|\]$/g, "")) && + host.includes(".") && + !/(^|\.)(localhost|local|internal|lan|home|test|invalid)$/.test(host) + ); + } catch { + return false; + } + }, "Use a public HTTP(S) domain URL without credentials, token parameters, or a custom port."); export const searchInput = z.object({ objective, @@ -23,17 +37,23 @@ export const searchInput = z.object({ export const extractInput = z.object({ urls: z.array(publicUrl).min(1).max(5), objective }); const responseSchema = z.object({ - results: z.array(z.object({ - url: publicUrl.nullable().catch(null), - title: z.string().nullish(), - publish_date: z.string().nullish(), - excerpts: z.array(z.string()), - })), - errors: z.array(z.object({ - url: publicUrl.nullable().catch(null), - error_type: z.string(), - http_status_code: z.number().int().nullish(), - })).default([]), + results: z.array( + z.object({ + url: publicUrl.nullable().catch(null), + title: z.string().nullish(), + publish_date: z.string().nullish(), + excerpts: z.array(z.string()), + }), + ), + errors: z + .array( + z.object({ + url: publicUrl.nullable().catch(null), + error_type: z.string(), + http_status_code: z.number().int().nullish(), + }), + ) + .default([]), warnings: z.array(z.unknown()).nullish(), }); @@ -44,7 +64,9 @@ async function readResponse(response: Response, signal: AbortSignal) { if (!reader) throw new Error(); const content = Buffer.alloc(1_048_576); let bytes = 0; - const cancel = () => { void reader.cancel().catch(() => {}); }; + const cancel = () => { + void reader.cancel().catch(() => {}); + }; signal.addEventListener("abort", cancel, { once: true }); try { while (true) { @@ -72,7 +94,8 @@ async function request( options: Options, ) { const apiKey = process.env.PARALLEL_API_KEY?.trim(); - if (!apiKey) throw new Error("Public web research is unavailable. Set PARALLEL_API_KEY on the server."); + if (!apiKey) + throw new Error("Public web research is unavailable. Set PARALLEL_API_KEY on the server."); const timeout = AbortSignal.timeout(30_000); const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout; signal.throwIfAborted(); @@ -92,10 +115,12 @@ async function request( if (!response.ok) { await response.body?.cancel().catch(() => {}); signal.throwIfAborted(); - const guidance = response.status === 401 || response.status === 403 - ? "Check the server API key and app permissions." - : response.status === 429 ? "Rate or spend limit reached; do not retry immediately." - : "Try another source or retry later."; + const guidance = + response.status === 401 || response.status === 403 + ? "Check the server API key and app permissions." + : response.status === 429 + ? "Rate or spend limit reached; do not retry immediately." + : "Try another source or retry later."; throw new Error(`Parallel returned HTTP ${response.status}. ${guidance}`); } let data: z.infer; @@ -106,11 +131,18 @@ async function request( throw new Error("Parallel returned an invalid response."); } signal.throwIfAborted(); - const sources = data.results.filter((result): result is typeof result & { url: string } => result.url !== null); - const errors = data.errors.filter((error): error is typeof error & { url: string } => error.url !== null); + const sources = data.results.filter( + (result): result is typeof result & { url: string } => result.url !== null, + ); + const errors = data.errors.filter( + (error): error is typeof error & { url: string } => error.url !== null, + ); let remaining = maxCharsTotal; - let truncated = data.results.length > maxResults || data.errors.length > maxResults || - sources.length < data.results.length || errors.length < data.errors.length; + let truncated = + data.results.length > maxResults || + data.errors.length > maxResults || + sources.length < data.results.length || + errors.length < data.errors.length; const results = sources.slice(0, maxResults).map((result) => { let budget = Math.min(remaining, maxCharsPerResult); const excerpts: string[] = []; @@ -144,21 +176,37 @@ async function request( export function searchWeb(input: z.input, options: Options = {}) { const { max_results, ...query } = searchInput.parse(input); - return request("search", { - ...query, - mode: "fast", - max_chars_total: maxCharsTotal, - advanced_settings: { max_results, excerpt_settings: { max_chars_per_result: 2_000 } }, - }, max_results, 2_000, options); + return request( + "search", + { + ...query, + mode: "fast", + max_chars_total: maxCharsTotal, + advanced_settings: { max_results, excerpt_settings: { max_chars_per_result: 2_000 } }, + }, + max_results, + 2_000, + options, + ); } export function extractWeb(input: z.input, options: Options = {}) { const parsed = extractInput.parse(input); const urls = [...new Set(parsed.urls)]; - return request("extract", { - ...parsed, - urls, - max_chars_total: maxCharsTotal, - advanced_settings: { full_content: false, fetch_policy: { timeout_seconds: 20 }, excerpt_settings: { max_chars_per_result: 6_000 } }, - }, urls.length, 6_000, options); + return request( + "extract", + { + ...parsed, + urls, + max_chars_total: maxCharsTotal, + advanced_settings: { + full_content: false, + fetch_policy: { timeout_seconds: 20 }, + excerpt_settings: { max_chars_per_result: 6_000 }, + }, + }, + urls.length, + 6_000, + options, + ); } diff --git a/server/postgres.ts b/server/postgres.ts index be68883..5b464ea 100644 --- a/server/postgres.ts +++ b/server/postgres.ts @@ -1,6 +1,10 @@ import { Pool, type PoolClient, type PoolConfig } from "pg"; -export function createPool(connectionString: string, label: string, options: PoolConfig = {}): Pool { +export function createPool( + connectionString: string, + label: string, + options: PoolConfig = {}, +): Pool { const pool = new Pool({ connectionString, max: 5, @@ -12,7 +16,11 @@ export function createPool(connectionString: string, label: string, options: Poo return pool; } -export async function withAdvisoryLock(pool: Pool, key: string, operation: (client: PoolClient) => Promise): Promise { +export async function withAdvisoryLock( + pool: Pool, + key: string, + operation: (client: PoolClient) => Promise, +): Promise { const client = await pool.connect(); let locked = false; let destroy = false; diff --git a/tests/baselayer.test.ts b/tests/baselayer.test.ts index 2107ce8..c36ba26 100644 --- a/tests/baselayer.test.ts +++ b/tests/baselayer.test.ts @@ -96,36 +96,46 @@ describe("Baselayer server minting", () => { config, fetch: async () => new Response("private identity data", { status: 404 }), }), - ).rejects.toThrow(/^Baselayer could not find this principal in the API key's organization \(HTTP 404\)\./); + ).rejects.toThrow( + /^Baselayer could not find this principal in the API key's organization \(HTTP 404\)\./, + ); }); it.each(["audience", "expired", "key", "disclosure"])( "rejects a credential with the wrong %s", async (problem) => { - await expect(mintBaselayerCredential(origin, { - config, - fetch: async (_url, init) => { - const request = JSON.parse(init!.body as string); - const claims = { - aud: problem === "audience" ? "other.example" : request.audience, - exp: Math.floor(Date.now() / 1000) + (problem === "expired" ? -1 : 3600), - cnf: { jwk: { ...request.agent_key, ...(problem === "key" ? { x: "another-key" } : {}) } }, - }; - const wire = `header.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.signature~`; - return Response.json({ - audience: request.audience, - credential: wire + (problem === "disclosure" ? "unexpected-private-disclosure~" : ""), - }); - }, - })).rejects.toThrow("invalid, expired, or incorrectly bound"); + await expect( + mintBaselayerCredential(origin, { + config, + fetch: async (_url, init) => { + const request = JSON.parse(init!.body as string); + const claims = { + aud: problem === "audience" ? "other.example" : request.audience, + exp: Math.floor(Date.now() / 1000) + (problem === "expired" ? -1 : 3600), + cnf: { + jwk: { ...request.agent_key, ...(problem === "key" ? { x: "another-key" } : {}) }, + }, + }; + const wire = `header.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.signature~`; + return Response.json({ + audience: request.audience, + credential: wire + (problem === "disclosure" ? "unexpected-private-disclosure~" : ""), + }); + }, + }), + ).rejects.toThrow("invalid, expired, or incorrectly bound"); }, ); it("redacts transport errors", async () => { - await expect(mintBaselayerCredential(origin, { - config, - fetch: async () => { throw new Error("private request details"); }, - })).rejects.toThrow(/^Baselayer mint request failed\. Check connectivity and retry\.$/); + await expect( + mintBaselayerCredential(origin, { + config, + fetch: async () => { + throw new Error("private request details"); + }, + }), + ).rejects.toThrow(/^Baselayer mint request failed\. Check connectivity and retry\.$/); }); it("rejects malformed credentials without printing the response", async () => { diff --git a/tests/browser-privacy-lock.test.ts b/tests/browser-privacy-lock.test.ts index 1008e66..c5669bd 100644 --- a/tests/browser-privacy-lock.test.ts +++ b/tests/browser-privacy-lock.test.ts @@ -13,28 +13,71 @@ describe.skipIf(!databaseUrl)("Browser privacy with PostgreSQL", () => { await admin.query(`CREATE SCHEMA ${schema}`); first = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}` }); second = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}` }); - await first.query(await readFile(new URL("../migrations/003_link_checkout.sql", import.meta.url), "utf8")); + await first.query( + await readFile(new URL("../migrations/003_link_checkout.sql", import.meta.url), "utf8"), + ); + }); + afterAll(async () => { + await first?.end(); + await second?.end(); + if (admin) { + await admin.query(`DROP SCHEMA ${schema} CASCADE`); + await admin.end(); + } }); - afterAll(async () => { await first?.end(); await second?.end(); if (admin) { await admin.query(`DROP SCHEMA ${schema} CASCADE`); await admin.end(); } }); it("serializes independent workers and rejects a read queued behind payment entry", async () => { let release!: () => void, entered!: () => void; - const started = new Promise(resolve => { entered = resolve; }); - const gate = new Promise(resolve => { release = resolve; }); - const payment = withBrowserPrivacyLock("same-browser", async () => { entered(); await gate; await quarantineBrowser(); }, first); + const started = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const payment = withBrowserPrivacyLock( + "same-browser", + async () => { + entered(); + await gate; + await quarantineBrowser(); + }, + first, + ); await started; const read = withBrowserPrivacyLock("same-browser", async () => "private-snapshot", second); const result = expect(read).rejects.toThrow("Payment details may remain"); release(); await payment; await result; - expect(await withBrowserPrivacyLock("different-browser", async () => "safe", second)).toBe("safe"); + expect(await withBrowserPrivacyLock("different-browser", async () => "safe", second)).toBe( + "safe", + ); }); it("retains the committed guard after an interrupted payment and client replacement", async () => { - await expect(withBrowserPrivacyLock("interrupted", async () => { await quarantineBrowser(); throw new Error("interrupted fill"); }, first)).rejects.toThrow("interrupted fill"); + await expect( + withBrowserPrivacyLock( + "interrupted", + async () => { + await quarantineBrowser(); + throw new Error("interrupted fill"); + }, + first, + ), + ).rejects.toThrow("interrupted fill"); await first.end(); first = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}` }); - await expect(withBrowserPrivacyLock("interrupted", async () => "private-snapshot", first)).rejects.toThrow("Payment details may remain"); - expect((await first.query("SELECT session_id FROM bell_browser_privacy_guards WHERE session_id=$1", ["interrupted"])).rowCount).toBe(1); + await expect( + withBrowserPrivacyLock("interrupted", async () => "private-snapshot", first), + ).rejects.toThrow("Payment details may remain"); + expect( + ( + await first.query( + "SELECT session_id FROM bell_browser_privacy_guards WHERE session_id=$1", + ["interrupted"], + ) + ).rowCount, + ).toBe(1); + }); + it("requires the lock before recording sensitive entry", async () => { + await expect(quarantineBrowser()).rejects.toThrow("private browser lock"); }); - it("requires the lock before recording sensitive entry", async () => { await expect(quarantineBrowser()).rejects.toThrow("private browser lock"); }); }); diff --git a/tests/browser/agent-contexts.test.ts b/tests/browser/agent-contexts.test.ts index dd8af7e..01913f6 100644 --- a/tests/browser/agent-contexts.test.ts +++ b/tests/browser/agent-contexts.test.ts @@ -37,9 +37,14 @@ describe("saved browser contexts", () => { expect((await listAgentContexts()).map(({ id }) => id)).toEqual(["context_fixture"]); await promoteAgentContext("context_draft", "Hacker News"); - expect(await listAgentContexts()).toContainEqual(expect.objectContaining({ - id: "context_draft", name: "Hacker News", status: "saved", selected: true, - })); + expect(await listAgentContexts()).toContainEqual( + expect.objectContaining({ + id: "context_draft", + name: "Hacker News", + status: "saved", + selected: true, + }), + ); const state = await metadata.read(); expect(state.contexts.context_draft.ownerSessionId).toBeUndefined(); expect(state.contexts.context_draft.expiresAt).toBeUndefined(); diff --git a/tests/browser/browser-close.test.ts b/tests/browser/browser-close.test.ts index 447b880..3179712 100644 --- a/tests/browser/browser-close.test.ts +++ b/tests/browser/browser-close.test.ts @@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const { release } = vi.hoisted(() => ({ release: vi.fn() })); vi.mock("@browserbasehq/sdk", () => { class APIError extends Error { - constructor(readonly status: number) { super("Provider failure"); } + constructor(readonly status: number) { + super("Provider failure"); + } } return { default: class { @@ -16,10 +18,18 @@ vi.mock("@browserbasehq/sdk", () => { import Browserbase from "@browserbasehq/sdk"; import { BrowserSession } from "../../server/browser/browser-session"; import type { ConversationState } from "../../server/browser/types"; -import { browserChannelEvents, closeBrowserRuntime, hasBrowserRuntime, runBrowserOperation } from "../../agent/lib/browser-runtime"; +import { + browserChannelEvents, + closeBrowserRuntime, + hasBrowserRuntime, + runBrowserOperation, +} from "../../agent/lib/browser-runtime"; import taskHooks from "../../agent/hooks/task-state"; import { taskState } from "../../agent/lib/task-state"; -import { ContextContainer, contextStorage } from "../../node_modules/eve/dist/src/context/container.js"; +import { + ContextContainer, + contextStorage, +} from "../../node_modules/eve/dist/src/context/container.js"; function fixture() { const state: ConversationState = { @@ -45,7 +55,8 @@ describe("hosted browser retirement", () => { }); it("keeps the browser ID when release fails so cleanup can retry", async () => { - release.mockRejectedValueOnce(new Error("upstream private diagnostic")) + release + .mockRejectedValueOnce(new Error("upstream private diagnostic")) .mockResolvedValueOnce({ status: "COMPLETED" }); const { state, session } = fixture(); await expect(session.close()).rejects.toThrow("The hosted browser could not be released"); @@ -68,10 +79,15 @@ describe("terminal cleanup in a fresh Eve worker", () => { contextStorage.run(new ContextContainer(), async () => { const id = "overlapping-cleanup"; let finish!: () => void; - const gate = new Promise(resolve => { finish = resolve; }); + const gate = new Promise((resolve) => { + finish = resolve; + }); const close = vi.fn(() => gate); - await runBrowserOperation(id, async browser => { vi.spyOn(browser, "close").mockImplementation(close); }); - const first = closeBrowserRuntime(id), second = closeBrowserRuntime(id); + await runBrowserOperation(id, async (browser) => { + vi.spyOn(browser, "close").mockImplementation(close); + }); + const first = closeBrowserRuntime(id), + second = closeBrowserRuntime(id); expect(close).toHaveBeenCalledOnce(); expect(hasBrowserRuntime(id)).toBe(true); finish(); @@ -79,34 +95,42 @@ describe("terminal cleanup in a fresh Eve worker", () => { expect(hasBrowserRuntime(id)).toBe(false); })); it.each(["session.completed", "session.failed"] as const)( - "%s recovers the persisted browser and retains it until release succeeds", (event) => - contextStorage.run(new ContextContainer(), async () => { - const retire = () => event === "session.completed" - ? browserChannelEvents[event]({}, undefined, { session: { id: "fresh-worker-retirement" } }) - : browserChannelEvents[event]({ sessionId: "fresh-worker-retirement" }); - const id = "fresh-worker-retirement"; - taskState.update((current) => ({ - ...current, - browser: { lifecycle: "active", sessionId: "persisted-browser" }, - })); - release.mockRejectedValueOnce(new Error("Synthetic provider outage")) - .mockResolvedValueOnce({ status: "COMPLETED" }); - expect(hasBrowserRuntime(id)).toBe(false); - try { - await expect(retire()).rejects.toThrow("could not be released"); - expect(taskState.get().browser.sessionId).toBe("persisted-browser"); - expect(hasBrowserRuntime(id)).toBe(true); - await retire(); - expect(release).toHaveBeenNthCalledWith(2, "persisted-browser", { status: "REQUEST_RELEASE" }); - expect(taskState.get().browser).toMatchObject({ lifecycle: "closed" }); - expect(taskState.get().browser.sessionId).toBeUndefined(); + "%s recovers the persisted browser and retains it until release succeeds", + (event) => + contextStorage.run(new ContextContainer(), async () => { + const retire = () => + event === "session.completed" + ? browserChannelEvents[event]({}, undefined, { + session: { id: "fresh-worker-retirement" }, + }) + : browserChannelEvents[event]({ sessionId: "fresh-worker-retirement" }); + const id = "fresh-worker-retirement"; + taskState.update((current) => ({ + ...current, + browser: { lifecycle: "active", sessionId: "persisted-browser" }, + })); + release + .mockRejectedValueOnce(new Error("Synthetic provider outage")) + .mockResolvedValueOnce({ status: "COMPLETED" }); expect(hasBrowserRuntime(id)).toBe(false); - } finally { - release.mockResolvedValue({ status: "COMPLETED" }); - await closeBrowserRuntime(id); - } - })); - + try { + await expect(retire()).rejects.toThrow("could not be released"); + expect(taskState.get().browser.sessionId).toBe("persisted-browser"); + expect(hasBrowserRuntime(id)).toBe(true); + await retire(); + expect(release).toHaveBeenNthCalledWith(2, "persisted-browser", { + status: "REQUEST_RELEASE", + }); + expect(taskState.get().browser).toMatchObject({ lifecycle: "closed" }); + expect(taskState.get().browser.sessionId).toBeUndefined(); + expect(hasBrowserRuntime(id)).toBe(false); + } finally { + release.mockResolvedValue({ status: "COMPLETED" }); + await closeBrowserRuntime(id); + } + }), + ); + it("turn.cancelled releases the persisted browser before clearing its ID", () => contextStorage.run(new ContextContainer(), async () => { const id = "fresh-worker-cancel"; @@ -119,8 +143,13 @@ describe("terminal cleanup in a fresh Eve worker", () => { { type: "turn.cancelled", meta: { id: "evt_cancel" } } as never, { session: { id } } as never, ); - expect(release).toHaveBeenCalledExactlyOnceWith("persisted-browser", { status: "REQUEST_RELEASE" }); - expect(taskState.get()).toMatchObject({ status: "canceled", browser: { lifecycle: "closed" } }); + expect(release).toHaveBeenCalledExactlyOnceWith("persisted-browser", { + status: "REQUEST_RELEASE", + }); + expect(taskState.get()).toMatchObject({ + status: "canceled", + browser: { lifecycle: "closed" }, + }); expect(taskState.get().browser.sessionId).toBeUndefined(); expect(hasBrowserRuntime(id)).toBe(false); })); diff --git a/tests/browser/browser-recording.test.ts b/tests/browser/browser-recording.test.ts index 17b057b..ade9b64 100644 --- a/tests/browser/browser-recording.test.ts +++ b/tests/browser/browser-recording.test.ts @@ -2,12 +2,17 @@ import { browserbase, Stagehand } from "@browserbasehq/stagehand"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ retrieve: vi.fn(), update: vi.fn(), debug: vi.fn() })); -vi.mock("@browserbasehq/sdk", () => ({ default: class { - sessions = { retrieve: mocks.retrieve, update: mocks.update, debug: mocks.debug }; -} })); +vi.mock("@browserbasehq/sdk", () => ({ + default: class { + sessions = { retrieve: mocks.retrieve, update: mocks.update, debug: mocks.debug }; + }, +})); import { BrowserSession } from "../../server/browser/browser-session"; -import { issueBrowserHandoffToken, resolveBrowserHandoffLiveViewUrl } from "../../server/browser/handoff"; +import { + issueBrowserHandoffToken, + resolveBrowserHandoffLiveViewUrl, +} from "../../server/browser/handoff"; import type { ConversationState } from "../../server/browser/types"; const secret = "synthetic-handoff-secret-at-least-32-bytes"; @@ -33,33 +38,44 @@ afterEach(() => { }); describe("hosted recording privacy", () => { - it.each(unsafeMetadata)("replaces a persisted browser with unsafe privacy markers: %j", async userMetadata => { - vi.useFakeTimers(); - mocks.retrieve.mockResolvedValue({ userMetadata }); - const remote = { - sessionId: "replacement-browser", closed: false, close: vi.fn(async () => {}), - context: { pages: async () => [] }, - }; - const launch = vi.spyOn(browserbase, "launch").mockResolvedValue(remote as never); - const connect = vi.spyOn(browserbase, "connect"); - vi.spyOn(Stagehand, "create").mockResolvedValue({ close: async () => {} } as never); - const state: ConversationState = { - id: "recording-recovery", traces: [], - browser: { provider: "browserbase", status: "idle", sessionId: "previous-browser" }, - }; - const browser = new BrowserSession(state); - const started = browser.start(); - await vi.runAllTimersAsync(); - await started; - expect(connect).not.toHaveBeenCalled(); - expect(mocks.update).toHaveBeenCalledWith("previous-browser", { status: "REQUEST_RELEASE" }); - expect(launch).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ - browserSettings: expect.objectContaining({ logSession: false, recordSession: false }), - userMetadata: expect.objectContaining({ networkLogging: "false", sessionRecording: "false" }), - })); - expect(state.browser.sessionId).toBe("replacement-browser"); - await browser.close(); - }); + it.each(unsafeMetadata)( + "replaces a persisted browser with unsafe privacy markers: %j", + async (userMetadata) => { + vi.useFakeTimers(); + mocks.retrieve.mockResolvedValue({ userMetadata }); + const remote = { + sessionId: "replacement-browser", + closed: false, + close: vi.fn(async () => {}), + context: { pages: async () => [] }, + }; + const launch = vi.spyOn(browserbase, "launch").mockResolvedValue(remote as never); + const connect = vi.spyOn(browserbase, "connect"); + vi.spyOn(Stagehand, "create").mockResolvedValue({ close: async () => {} } as never); + const state: ConversationState = { + id: "recording-recovery", + traces: [], + browser: { provider: "browserbase", status: "idle", sessionId: "previous-browser" }, + }; + const browser = new BrowserSession(state); + const started = browser.start(); + await vi.runAllTimersAsync(); + await started; + expect(connect).not.toHaveBeenCalled(); + expect(mocks.update).toHaveBeenCalledWith("previous-browser", { status: "REQUEST_RELEASE" }); + expect(launch).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + browserSettings: expect.objectContaining({ logSession: false, recordSession: false }), + userMetadata: expect.objectContaining({ + networkLogging: "false", + sessionRecording: "false", + }), + }), + ); + expect(state.browser.sessionId).toBe("replacement-browser"); + await browser.close(); + }, + ); it("replaces a personal browser when the rollout flag is off", async () => { vi.useFakeTimers(); @@ -74,14 +90,17 @@ describe("hosted recording privacy", () => { }, }); const remote = { - sessionId: "replacement-browser", closed: false, close: vi.fn(async () => {}), + sessionId: "replacement-browser", + closed: false, + close: vi.fn(async () => {}), context: { pages: async () => [] }, }; vi.spyOn(browserbase, "launch").mockResolvedValue(remote as never); const connect = vi.spyOn(browserbase, "connect"); vi.spyOn(Stagehand, "create").mockResolvedValue({ close: async () => {} } as never); const state: ConversationState = { - id: "personal-flag-off", traces: [], + id: "personal-flag-off", + traces: [], browser: { provider: "browserbase", status: "idle", sessionId: "previous-browser" }, }; const browser = new BrowserSession(state); @@ -94,18 +113,33 @@ describe("hosted recording privacy", () => { await browser.close(); }); - it.each(unsafeMetadata)("refuses handoff to unsafe privacy markers: %j", async userMetadata => { + it.each(unsafeMetadata)("refuses handoff to unsafe privacy markers: %j", async (userMetadata) => { mocks.retrieve.mockResolvedValue({ userMetadata }); - const token = issueBrowserHandoffToken({ eveSessionId: "eve-session", browserSessionId: "browser-session" }, { secret }); - await expect(resolveBrowserHandoffLiveViewUrl(token, { secret })).rejects.toMatchObject({ code: "unavailable" }); + const token = issueBrowserHandoffToken( + { eveSessionId: "eve-session", browserSessionId: "browser-session" }, + { secret }, + ); + await expect(resolveBrowserHandoffLiveViewUrl(token, { secret })).rejects.toMatchObject({ + code: "unavailable", + }); expect(mocks.debug).not.toHaveBeenCalled(); }); it("resolves a handoff only after verifying recording is disabled", async () => { - mocks.retrieve.mockResolvedValue({ userMetadata: { networkLogging: "false", sessionRecording: "false" } }); - mocks.debug.mockResolvedValue({ debuggerFullscreenUrl: "https://www.browserbase.com/devtools-fullscreen/fixture.html?wss=wss%3A%2F%2Fconnect.browserbase.com%3FsessionId%3Dfixture" }); - const token = issueBrowserHandoffToken({ eveSessionId: "eve-session", browserSessionId: "browser-session" }, { secret }); - await expect(resolveBrowserHandoffLiveViewUrl(token, { secret })).resolves.toContain("browserbase.com"); + mocks.retrieve.mockResolvedValue({ + userMetadata: { networkLogging: "false", sessionRecording: "false" }, + }); + mocks.debug.mockResolvedValue({ + debuggerFullscreenUrl: + "https://www.browserbase.com/devtools-fullscreen/fixture.html?wss=wss%3A%2F%2Fconnect.browserbase.com%3FsessionId%3Dfixture", + }); + const token = issueBrowserHandoffToken( + { eveSessionId: "eve-session", browserSessionId: "browser-session" }, + { secret }, + ); + await expect(resolveBrowserHandoffLiveViewUrl(token, { secret })).resolves.toContain( + "browserbase.com", + ); expect(mocks.retrieve).toHaveBeenCalledWith("browser-session"); expect(mocks.debug).toHaveBeenCalledWith("browser-session"); }); diff --git a/tests/browser/browser-restart-real.test.ts b/tests/browser/browser-restart-real.test.ts index 14c30a6..31b8670 100644 --- a/tests/browser/browser-restart-real.test.ts +++ b/tests/browser/browser-restart-real.test.ts @@ -39,12 +39,17 @@ describe("opt-in Browserbase restart recovery", () => { let fixtureBrowser: Browser | undefined; let parked = false; try { - fixtureBrowser = await session.withHostedPage(new AbortController().signal, (_page, connectUrl) => - chromium.connectOverCDP(connectUrl)); + fixtureBrowser = await session.withHostedPage( + new AbortController().signal, + (_page, connectUrl) => chromium.connectOverCDP(connectUrl), + ); // Keep the fixture in the hosted page so the second process can resume it. - await fixtureBrowser.contexts()[0].route(fixture, route => route.fulfill({ - contentType: "text/html", body: page, - })); + await fixtureBrowser.contexts()[0].route(fixture, (route) => + route.fulfill({ + contentType: "text/html", + body: page, + }), + ); await session.run([ { action: "goto", url: fixture }, { action: "fill", target: "#username", value: "public-test-user" }, diff --git a/tests/browser/browser-security.test.ts b/tests/browser/browser-security.test.ts index 7378fba..99eee1a 100644 --- a/tests/browser/browser-security.test.ts +++ b/tests/browser/browser-security.test.ts @@ -29,7 +29,9 @@ function fixture() { const page = { url: async () => url, title: async () => "Test shop", - goto: vi.fn(async (next: string) => { url = next; }), + goto: vi.fn(async (next: string) => { + url = next; + }), waitForLoadState: vi.fn(async () => {}), waitForTimeout: vi.fn(async () => {}), locator: (target: string) => ({ @@ -38,18 +40,31 @@ function fixture() { count: async () => 1, }), }; - const batch = vi.fn(async ( - callback: (context: { page: typeof page }, args: A) => Promise, - args: A, - ) => callback({ page }, args)); + const batch = vi.fn( + async (callback: (context: { page: typeof page }, args: A) => Promise, args: A) => + callback({ page }, args), + ); const start = vi.spyOn(session, "start").mockResolvedValue(); - vi.spyOn(session as unknown as { gateCaptcha(): Promise }, "gateCaptcha") - .mockResolvedValue(); + vi.spyOn( + session as unknown as { gateCaptcha(): Promise }, + "gateCaptcha", + ).mockResolvedValue(); Object.assign(session, { stagehand: { experimentalBatch: batch }, browserbase: {}, }); - return { session, state, start, batch, page, fill, click, setUrl: (next: string) => { url = next; } }; + return { + session, + state, + start, + batch, + page, + fill, + click, + setUrl: (next: string) => { + url = next; + }, + }; } afterEach(() => { @@ -58,10 +73,18 @@ afterEach(() => { }); describe("vault login credential boundary", () => { - it.each(["iframe >> #password", "xpath=/html/body/iframe/input", "/html/body/iframe/input", "(//iframe)/input"])( - "rejects selectors that could send credentials into another frame: %s", async passwordTarget => { + it.each([ + "iframe >> #password", + "xpath=/html/body/iframe/input", + "/html/body/iframe/input", + "(//iframe)/input", + ])( + "rejects selectors that could send credentials into another frame: %s", + async (passwordTarget) => { const { session, start, fill } = fixture(); - await expect(session.secureLogin({ ...login, passwordTarget })).rejects.toThrow("CSS selectors"); + await expect(session.secureLogin({ ...login, passwordTarget })).rejects.toThrow( + "CSS selectors", + ); expect(start).not.toHaveBeenCalled(); expect(fill).not.toHaveBeenCalled(); }, @@ -80,24 +103,32 @@ describe("vault login credential boundary", () => { it("rechecks the destination before filling the password", async () => { const { session, setUrl, fill } = fixture(); - fill.mockImplementationOnce(async () => { setUrl("https://other.example/login"); }); + fill.mockImplementationOnce(async () => { + setUrl("https://other.example/login"); + }); await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed"); expect(fill.mock.calls).toEqual([[login.usernameTarget, login.username]]); }); - it.each([false, true])("rechecks the destination before OTP, afterSubmit=%s", async (afterSubmit) => { - const { session, setUrl, fill, click } = fixture(); - if (afterSubmit) { - click.mockImplementationOnce(async () => { setUrl("https://other.example/otp"); }); - } else { - fill.mockImplementation(async (target) => { - if (target === login.passwordTarget) setUrl("http://shop.example/otp"); - }); - } - await expect(session.secureLogin({ ...login, totpAfterSubmit: afterSubmit })) - .rejects.toThrow("Secure login failed"); - expect(fill.mock.calls.some(([, value]) => value === login.totp)).toBe(false); - }); + it.each([false, true])( + "rechecks the destination before OTP, afterSubmit=%s", + async (afterSubmit) => { + const { session, setUrl, fill, click } = fixture(); + if (afterSubmit) { + click.mockImplementationOnce(async () => { + setUrl("https://other.example/otp"); + }); + } else { + fill.mockImplementation(async (target) => { + if (target === login.passwordTarget) setUrl("http://shop.example/otp"); + }); + } + await expect(session.secureLogin({ ...login, totpAfterSubmit: afterSubmit })).rejects.toThrow( + "Secure login failed", + ); + expect(fill.mock.calls.some(([, value]) => value === login.totp)).toBe(false); + }, + ); it("allows HTTPS login without exposing secrets in its result or trace", async () => { const { session, state, fill } = fixture(); @@ -114,7 +145,9 @@ describe("vault login credential boundary", () => { vi.spyOn(session, "start").mockRejectedValue(new Error(login.password)); await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed"); expect(JSON.stringify(state)).not.toContain(login.password); - expect(state.traces).toContainEqual(expect.objectContaining({ name: "vault.login", kind: "error" })); + expect(state.traces).toContainEqual( + expect.objectContaining({ name: "vault.login", kind: "error" }), + ); }); it("redacts failures and releases the operation lock for the next attempt", async () => { @@ -135,17 +168,28 @@ describe("browser navigation boundary", () => { expect(fill).toHaveBeenCalledWith(`#field-${i}`, value); expect(JSON.stringify(state.traces)).not.toContain(value); } - expect(state.traces.find(trace => trace.name === "run")?.detail).toMatchObject({ - actions: values.map((_, i) => ({ action: "fill", target: `#field-${i}`, value: "[value hidden]" })), + expect(state.traces.find((trace) => trace.name === "run")?.detail).toMatchObject({ + actions: values.map((_, i) => ({ + action: "fill", + target: `#field-${i}`, + value: "[value hidden]", + })), }); }); it("rejects operations queued behind a browser that has been closed", async () => { const { session, batch } = fixture(); - Object.assign(session, { stagehand: { experimentalBatch: batch, close: vi.fn(async () => {}) } }); + Object.assign(session, { + stagehand: { experimentalBatch: batch, close: vi.fn(async () => {}) }, + }); let resume!: () => void; - const gate = new Promise(resolve => { resume = resolve; }); - batch.mockImplementationOnce(async () => { await gate; return { url: "https://shop.example", title: "Fixture" }; }); + const gate = new Promise((resolve) => { + resume = resolve; + }); + batch.mockImplementationOnce(async () => { + await gate; + return { url: "https://shop.example", title: "Fixture" }; + }); const first = session.run([{ action: "fill", target: "#first", value: "one" }]); await vi.waitFor(() => expect(batch).toHaveBeenCalledOnce()); const second = session.run([{ action: "fill", target: "#second", value: "two" }]); @@ -154,7 +198,9 @@ describe("browser navigation boundary", () => { resume(); await first; await rejected; - await expect(session.run([{ action: "wait", milliseconds: 1 }])).rejects.toMatchObject({ name: "AbortError" }); + await expect(session.run([{ action: "wait", milliseconds: 1 }])).rejects.toMatchObject({ + name: "AbortError", + }); expect(batch).toHaveBeenCalledOnce(); }); it.each([ @@ -171,10 +217,12 @@ describe("browser navigation boundary", () => { it("validates the whole batch before executing an earlier action", async () => { const { session, batch } = fixture(); - await expect(session.run([ - { action: "goto", url: "https://shop.example/" }, - { action: "goto", url: "file:///tmp/bell-synthetic-fixture" }, - ])).rejects.toThrow(); + await expect( + session.run([ + { action: "goto", url: "https://shop.example/" }, + { action: "goto", url: "file:///tmp/bell-synthetic-fixture" }, + ]), + ).rejects.toThrow(); expect(batch).not.toHaveBeenCalled(); }); diff --git a/tests/browser/cancellation.test.ts b/tests/browser/cancellation.test.ts index 1632526..a507b20 100644 --- a/tests/browser/cancellation.test.ts +++ b/tests/browser/cancellation.test.ts @@ -4,7 +4,9 @@ import { BrowserCancellationError, runWithCancellation } from "../../agent/lib/c describe("browser operation cancellation", () => { it("does not start queued work after cancellation cleanup", async () => { - const controller = new AbortController(), operation = vi.fn(), cleanup = vi.fn(async () => undefined); + const controller = new AbortController(), + operation = vi.fn(), + cleanup = vi.fn(async () => undefined); const pending = runWithCancellation(controller.signal, operation, cleanup); controller.abort(); await expect(pending).rejects.toBeInstanceOf(BrowserCancellationError); @@ -13,9 +15,13 @@ describe("browser operation cancellation", () => { }); it("never starts work for a previously aborted turn", async () => { - const controller = new AbortController(), operation = vi.fn(), cleanup = vi.fn(async () => undefined); + const controller = new AbortController(), + operation = vi.fn(), + cleanup = vi.fn(async () => undefined); controller.abort(); - await expect(runWithCancellation(controller.signal, operation, cleanup)).rejects.toBeInstanceOf(BrowserCancellationError); + await expect(runWithCancellation(controller.signal, operation, cleanup)).rejects.toBeInstanceOf( + BrowserCancellationError, + ); expect(operation).not.toHaveBeenCalled(); expect(cleanup).toHaveBeenCalledOnce(); }); diff --git a/tests/browser/captcha-live.test.ts b/tests/browser/captcha-live.test.ts index 84c1b97..5a45326 100644 --- a/tests/browser/captcha-live.test.ts +++ b/tests/browser/captcha-live.test.ts @@ -23,13 +23,17 @@ describe("opt-in hosted CAPTCHA marker fixture", () => { let fixtureBrowser: Browser | undefined; try { const fixture = "https://bell-fixture.invalid/captcha"; - fixtureBrowser = await session.withHostedPage(new AbortController().signal, (_page, connectUrl) => - chromium.connectOverCDP(connectUrl)); + fixtureBrowser = await session.withHostedPage( + new AbortController().signal, + (_page, connectUrl) => chromium.connectOverCDP(connectUrl), + ); // Fulfill locally over CDP: the hosted browser cannot reach a localhost HTTP server. - await fixtureBrowser.contexts()[0].route(fixture, route => route.fulfill({ - contentType: "text/html", - body: "captcha fixture

After solve

", - })); + await fixtureBrowser.contexts()[0].route(fixture, (route) => + route.fulfill({ + contentType: "text/html", + body: "captcha fixture

After solve

", + }), + ); const result = await session.run([ { action: "goto", url: fixture }, { action: "wait", milliseconds: 150 }, diff --git a/tests/browser/captcha-recovery.test.ts b/tests/browser/captcha-recovery.test.ts index 9f3e3e1..f902458 100644 --- a/tests/browser/captcha-recovery.test.ts +++ b/tests/browser/captcha-recovery.test.ts @@ -4,88 +4,134 @@ import taskHooks from "../../agent/hooks/task-state"; import { closeBrowserRuntime, runBrowserOperation } from "../../agent/lib/browser-runtime"; import { applyCaptchaTransition, closeTaskBrowser, taskState } from "../../agent/lib/task-state"; import { BrowserSession, CaptchaHandoffError } from "../../server/browser/browser-session"; -import { ContextContainer, contextStorage } from "../../node_modules/eve/dist/src/context/container.js"; +import { + ContextContainer, + contextStorage, +} from "../../node_modules/eve/dist/src/context/container.js"; afterEach(() => vi.restoreAllMocks()); describe("resuming a browser after CAPTCHA handoff", () => { it.each(["errored", "timed_out"] as const)( - "observes the existing page after the user replies to a %s challenge", (status) => - contextStorage.run(new ContextContainer(), async () => { - const id = `captcha-recovery-${status}`; - taskState.update(current => ({ - ...current, sessionId: id, status: "waiting_for_user", waitReason: "captcha", - captcha: { status, attemptCount: 1, journal: [] }, - })); - const page = { - snapshot: vi.fn(async () => ({ formattedTree: "Account home", xpathMap: {} })), - url: async () => "https://shop.example/account", - title: async () => "Account", - }; - const close = vi.fn(async () => {}); - await runBrowserOperation(id, async browser => { - vi.spyOn(browser, "start").mockResolvedValue(); - vi.spyOn(browser, "close").mockImplementation(close); - Object.assign(browser, { stagehand: { - experimentalBatch: (operation: (context: { page: typeof page }) => Promise) => operation({ page }), - } }); - }); - try { - // Before the reply, the restored challenge must still gate all browser work. - expect(await runBrowserOperation(id, browser => browser.snapshot())).toMatchObject({ - result: { status: "waiting_for_user", reason: "captcha" }, + "observes the existing page after the user replies to a %s challenge", + (status) => + contextStorage.run(new ContextContainer(), async () => { + const id = `captcha-recovery-${status}`; + taskState.update((current) => ({ + ...current, + sessionId: id, + status: "waiting_for_user", + waitReason: "captcha", + captcha: { status, attemptCount: 1, journal: [] }, + })); + const page = { + snapshot: vi.fn(async () => ({ formattedTree: "Account home", xpathMap: {} })), + url: async () => "https://shop.example/account", + title: async () => "Account", + }; + const close = vi.fn(async () => {}); + await runBrowserOperation(id, async (browser) => { + vi.spyOn(browser, "start").mockResolvedValue(); + vi.spyOn(browser, "close").mockImplementation(close); + Object.assign(browser, { + stagehand: { + experimentalBatch: (operation: (context: { page: typeof page }) => Promise) => + operation({ page }), + }, + }); }); - expect(page.snapshot).not.toHaveBeenCalled(); + try { + // Before the reply, the restored challenge must still gate all browser work. + expect(await runBrowserOperation(id, (browser) => browser.snapshot())).toMatchObject({ + result: { status: "waiting_for_user", reason: "captcha" }, + }); + expect(page.snapshot).not.toHaveBeenCalled(); - await taskHooks.events!["message.received"]!({ - type: "message.received", data: { message: "done" }, meta: { id: "new-reply" }, - } as never, { session: { id } } as never); - expect(await runBrowserOperation(id, browser => browser.snapshot())).toMatchObject({ - result: { tree: "Account home", url: "https://shop.example/account" }, - workbench: { captcha: { status: "idle" } }, - }); - expect(page.snapshot).toHaveBeenCalledTimes(2); - expect(close).not.toHaveBeenCalled(); - expect(taskState.get()).toMatchObject({ status: "running", captcha: { status: "idle" } }); - } finally { - await closeBrowserRuntime(id); - } - }), + await taskHooks.events!["message.received"]!( + { + type: "message.received", + data: { message: "done" }, + meta: { id: "new-reply" }, + } as never, + { session: { id } } as never, + ); + expect(await runBrowserOperation(id, (browser) => browser.snapshot())).toMatchObject({ + result: { tree: "Account home", url: "https://shop.example/account" }, + workbench: { captcha: { status: "idle" } }, + }); + expect(page.snapshot).toHaveBeenCalledTimes(2); + expect(close).not.toHaveBeenCalled(); + expect(taskState.get()).toMatchObject({ status: "running", captcha: { status: "idle" } }); + } finally { + await closeBrowserRuntime(id); + } + }), ); it("does not let an old replayed message clear a new handoff", () => contextStorage.run(new ContextContainer(), async () => { const id = "captcha-replayed-reply"; - taskState.update(current => ({ ...current, sessionId: id, processedEventIds: ["old-reply"] })); - taskState.update(current => applyCaptchaTransition(current, { - id: "challenge-start", type: "started", at: new Date().toISOString(), + taskState.update((current) => ({ + ...current, + sessionId: id, + processedEventIds: ["old-reply"], })); - taskState.update(current => applyCaptchaTransition(current, { - id: "challenge-error", type: "errored", at: new Date().toISOString(), - })); - await taskHooks.events!["message.received"]!({ - type: "message.received", data: { message: "done" }, meta: { id: "old-reply" }, - } as never, { session: { id } } as never); - expect(taskState.get()).toMatchObject({ status: "waiting_for_user", captcha: { status: "errored" } }); + taskState.update((current) => + applyCaptchaTransition(current, { + id: "challenge-start", + type: "started", + at: new Date().toISOString(), + }), + ); + taskState.update((current) => + applyCaptchaTransition(current, { + id: "challenge-error", + type: "errored", + at: new Date().toISOString(), + }), + ); + await taskHooks.events!["message.received"]!( + { + type: "message.received", + data: { message: "done" }, + meta: { id: "old-reply" }, + } as never, + { session: { id } } as never, + ); + expect(taskState.get()).toMatchObject({ + status: "waiting_for_user", + captcha: { status: "errored" }, + }); })); it("records a listener event on the turn that is running", () => contextStorage.run(new ContextContainer(), async () => { const id = "captcha-active-turn"; const other = new ContextContainer(); - taskState.update(current => ({ ...current, sessionId: id })); + taskState.update((current) => ({ ...current, sessionId: id })); try { - await runBrowserOperation(id, async browser => { - const notify = (browser as unknown as { - options: { onCaptchaTransition?: (transition: { id: string; type: "started" | "errored"; at: string }) => void }; - }).options.onCaptchaTransition; + await runBrowserOperation(id, async (browser) => { + const notify = ( + browser as unknown as { + options: { + onCaptchaTransition?: (transition: { + id: string; + type: "started" | "errored"; + at: string; + }) => void; + }; + } + ).options.onCaptchaTransition; const at = new Date().toISOString(); contextStorage.run(other, () => { notify?.({ id: "challenge-start", type: "started", at }); notify?.({ id: "challenge-error", type: "errored", at }); }); }); - expect(taskState.get()).toMatchObject({ status: "waiting_for_user", captcha: { status: "errored" } }); + expect(taskState.get()).toMatchObject({ + status: "waiting_for_user", + captcha: { status: "errored" }, + }); contextStorage.run(other, () => { expect(taskState.get().captcha).toBeUndefined(); }); @@ -95,19 +141,33 @@ describe("resuming a browser after CAPTCHA handoff", () => { })); it("preserves CAPTCHA handoff errors from secure login", async () => { - const browser = new BrowserSession({ - id: "login-handoff", traces: [], browser: { provider: "not-started", status: "idle" }, - }, { captcha: { status: "errored" } }); + const browser = new BrowserSession( + { + id: "login-handoff", + traces: [], + browser: { provider: "not-started", status: "idle" }, + }, + { captcha: { status: "errored" } }, + ); vi.spyOn(browser, "start").mockResolvedValue(); - await expect(browser.secureLogin({ - allowedHosts: ["shop.example"], username: "fixture", password: "synthetic-only", - usernameTarget: "#username", passwordTarget: "#password", submitTarget: "#submit", - })).rejects.toBeInstanceOf(CaptchaHandoffError); + await expect( + browser.secureLogin({ + allowedHosts: ["shop.example"], + username: "fixture", + password: "synthetic-only", + usernameTarget: "#username", + passwordTarget: "#password", + submitTarget: "#submit", + }), + ).rejects.toBeInstanceOf(CaptchaHandoffError); }); it("drops a closed browser's challenge before starting a replacement", () => contextStorage.run(new ContextContainer(), () => { - const record = { ...taskState.get(), captcha: { status: "timed_out" as const, attemptCount: 1, journal: [] } }; + const record = { + ...taskState.get(), + captcha: { status: "timed_out" as const, attemptCount: 1, journal: [] }, + }; expect(closeTaskBrowser(record).captcha).toBeUndefined(); })); }); diff --git a/tests/browser/context-cancellation.test.ts b/tests/browser/context-cancellation.test.ts index dd5f8f7..816cac6 100644 --- a/tests/browser/context-cancellation.test.ts +++ b/tests/browser/context-cancellation.test.ts @@ -1,55 +1,95 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ create: vi.fn(), draft: vi.fn(), promote: vi.fn() })); vi.mock("../../server/browser/agent-contexts.js", () => ({ - createAgentContext: mocks.create, isDraftAgentContext: mocks.draft, promoteAgentContext: mocks.promote, - ensureAgentContext: vi.fn(), listAgentContexts: vi.fn(), selectAgentContext: vi.fn(), + createAgentContext: mocks.create, + isDraftAgentContext: mocks.draft, + promoteAgentContext: mocks.promote, + ensureAgentContext: vi.fn(), + listAgentContexts: vi.fn(), + selectAgentContext: vi.fn(), })); import { BrowserSession } from "../../server/browser/browser-session"; -import { createBrowserContext, closeBrowserRuntime, runBrowserOperation, runCancelableBrowserWork, saveBrowserContext, selectBrowserContext } from "../../agent/lib/browser-runtime"; +import { + createBrowserContext, + closeBrowserRuntime, + runBrowserOperation, + runCancelableBrowserWork, + saveBrowserContext, + selectBrowserContext, +} from "../../agent/lib/browser-runtime"; import { taskState } from "../../agent/lib/task-state"; -import { ContextContainer, contextStorage } from "../../node_modules/eve/dist/src/context/container.js"; +import { + ContextContainer, + contextStorage, +} from "../../node_modules/eve/dist/src/context/container.js"; -afterEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); vi.useRealTimers(); }); +afterEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + vi.useRealTimers(); +}); describe("Context cancellation", () => { it("does not lose the previous hosted browser when selecting a Context in a fresh worker", () => contextStorage.run(new ContextContainer(), async () => { - taskState.update(current => ({ ...current, browser: { - lifecycle: "active", sessionId: "hosted-browser", contextId: "current-context", - } })); - await expect(selectBrowserContext("fresh-worker", "next-context")).rejects.toThrow("Save or close"); + taskState.update((current) => ({ + ...current, + browser: { + lifecycle: "active", + sessionId: "hosted-browser", + contextId: "current-context", + }, + })); + await expect(selectBrowserContext("fresh-worker", "next-context")).rejects.toThrow( + "Save or close", + ); expect(taskState.get().browser).toMatchObject({ - sessionId: "hosted-browser", contextId: "current-context", + sessionId: "hosted-browser", + contextId: "current-context", }); })); it("does not open a replacement browser after a canceled remote Context creation finishes", () => contextStorage.run(new ContextContainer(), async () => { - const id = "context-cancellation", controller = new AbortController(); + const id = "context-cancellation", + controller = new AbortController(); let resolve!: (value: { id: string; name: string }) => void; - mocks.create.mockReturnValue(new Promise(done => { resolve = done; })); + mocks.create.mockReturnValue( + new Promise((done) => { + resolve = done; + }), + ); const importCookies = vi.spyOn(BrowserSession.prototype, "importCookies").mockResolvedValue(); - await runBrowserOperation(id, async browser => { + await runBrowserOperation(id, async (browser) => { vi.spyOn(browser, "hasStarted").mockReturnValue(true); vi.spyOn(browser, "start").mockResolvedValue(); vi.spyOn(browser, "close").mockResolvedValue(); - vi.spyOn(browser, "exportCookies").mockResolvedValue([{ name: "fixture", value: "synthetic-only", domain: "shop.example", path: "/" }]); + vi.spyOn(browser, "exportCookies").mockResolvedValue([ + { name: "fixture", value: "synthetic-only", domain: "shop.example", path: "/" }, + ]); }); try { - const pending = runCancelableBrowserWork(id, controller.signal, () => createBrowserContext(id, "Fixture", controller.signal)); + const pending = runCancelableBrowserWork(id, controller.signal, () => + createBrowserContext(id, "Fixture", controller.signal), + ); await vi.waitFor(() => expect(mocks.create).toHaveBeenCalledOnce()); controller.abort(); await expect(pending).rejects.toMatchObject({ name: "AbortError" }); resolve({ id: "created-context", name: "Fixture" }); - await new Promise(done => setImmediate(done)); + await new Promise((done) => setImmediate(done)); expect(importCookies).not.toHaveBeenCalled(); expect(taskState.get().browser.contextId).toBeUndefined(); - } finally { await closeBrowserRuntime(id); } + } finally { + await closeBrowserRuntime(id); + } })); it("does not promote a Context when cancellation interrupts the synchronization wait", () => contextStorage.run(new ContextContainer(), async () => { vi.useFakeTimers(); - taskState.update(current => ({ ...current, browser: { lifecycle: "closed", contextId: "saved-context" } })); + taskState.update((current) => ({ + ...current, + browser: { lifecycle: "closed", contextId: "saved-context" }, + })); mocks.draft.mockResolvedValue(true); const controller = new AbortController(); const pending = saveBrowserContext("saving-context", "Fixture", controller.signal); diff --git a/tests/browser/context-cleanup.test.ts b/tests/browser/context-cleanup.test.ts index d0df20a..ee2f8db 100644 --- a/tests/browser/context-cleanup.test.ts +++ b/tests/browser/context-cleanup.test.ts @@ -6,12 +6,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ retrieve: vi.fn(), create: vi.fn(), fetch: vi.fn() })); vi.mock("@browserbasehq/sdk", () => { class APIError extends Error { - constructor(readonly status: number) { super("Synthetic provider failure"); } + constructor(readonly status: number) { + super("Synthetic provider failure"); + } } - return { default: class { - static APIError = APIError; - contexts = { retrieve: mocks.retrieve, create: mocks.create }; - } }; + return { + default: class { + static APIError = APIError; + contexts = { retrieve: mocks.retrieve, create: mocks.create }; + }, + }; }); import Browserbase from "@browserbasehq/sdk"; @@ -43,7 +47,9 @@ describe("remote Context cleanup", () => { it("keeps an expired current Context tracked when remote deletion fails", async () => { await store.addDraft("old_context", "Old", "session_one", new Date(0)); mocks.fetch.mockResolvedValue(new Response(null, { status: 503 })); - await expect(ensureAgentContext("session_one", "old_context")).rejects.toThrow("cleanup failed"); + await expect(ensureAgentContext("session_one", "old_context")).rejects.toThrow( + "cleanup failed", + ); expect((await store.read()).contexts.old_context).toBeDefined(); expect(mocks.create).not.toHaveBeenCalled(); }); @@ -52,28 +58,42 @@ describe("remote Context cleanup", () => { await store.addDraft("old_context", "Old", "session_old", new Date(0)); mocks.fetch.mockResolvedValue(new Response(null, { status: 503 })); await expect(ensureAgentContext("session_new")).resolves.toMatchObject({ id: "new_context" }); - expect(Object.keys((await store.read()).contexts).sort()).toEqual(["new_context", "old_context"]); + expect(Object.keys((await store.read()).contexts).sort()).toEqual([ + "new_context", + "old_context", + ]); }); it("preserves a draft through a temporary retrieval failure", async () => { await store.addDraft("old_context", "Old", "session_one", new Date(Date.now() + 60_000)); - mocks.retrieve.mockRejectedValue(new Browserbase.APIError(503, undefined, "unavailable", undefined)); - await expect(ensureAgentContext("session_one", "old_context")).rejects.toThrow("Synthetic provider failure"); + mocks.retrieve.mockRejectedValue( + new Browserbase.APIError(503, undefined, "unavailable", undefined), + ); + await expect(ensureAgentContext("session_one", "old_context")).rejects.toThrow( + "Synthetic provider failure", + ); expect((await store.read()).contexts.old_context).toBeDefined(); expect(mocks.create).not.toHaveBeenCalled(); }); it("replaces a draft only after retrieval confirms it no longer exists", async () => { await store.addDraft("old_context", "Old", "session_one", new Date(Date.now() + 60_000)); - mocks.retrieve.mockRejectedValue(new Browserbase.APIError(404, undefined, "missing", undefined)); - await expect(ensureAgentContext("session_one", "old_context")).resolves.toMatchObject({ id: "new_context" }); + mocks.retrieve.mockRejectedValue( + new Browserbase.APIError(404, undefined, "missing", undefined), + ); + await expect(ensureAgentContext("session_one", "old_context")).resolves.toMatchObject({ + id: "new_context", + }); expect(Object.keys((await store.read()).contexts)).toEqual(["new_context"]); }); - it.each([204, 404])("forgets expired Context metadata after deletion returns %s", async status => { - await store.addDraft("old_context", "Old", "session_one", new Date(0)); - mocks.fetch.mockResolvedValue(new Response(null, { status })); - await ensureAgentContext("session_one", "old_context"); - expect(Object.keys((await store.read()).contexts)).toEqual(["new_context"]); - }); + it.each([204, 404])( + "forgets expired Context metadata after deletion returns %s", + async (status) => { + await store.addDraft("old_context", "Old", "session_one", new Date(0)); + mocks.fetch.mockResolvedValue(new Response(null, { status })); + await ensureAgentContext("session_one", "old_context"); + expect(Object.keys((await store.read()).contexts)).toEqual(["new_context"]); + }, + ); }); diff --git a/tests/browser/context-metadata-store.test.ts b/tests/browser/context-metadata-store.test.ts index 1b6cd01..dff56db 100644 --- a/tests/browser/context-metadata-store.test.ts +++ b/tests/browser/context-metadata-store.test.ts @@ -24,12 +24,16 @@ describe("browser context metadata", () => { new ContextMetadataStore(filePath), new ContextMetadataStore(path.relative(process.cwd(), filePath)), ]; - await Promise.all(Array.from({ length: 8 }, (_, index) => - stores[index % stores.length].add(`context_${index}`, `Context ${index}`), - )); + await Promise.all( + Array.from({ length: 8 }, (_, index) => + stores[index % stores.length].add(`context_${index}`, `Context ${index}`), + ), + ); const value = await stores[0].read(); - expect(Object.keys(value.contexts)).toEqual(Array.from({ length: 8 }, (_, index) => `context_${index}`)); + expect(Object.keys(value.contexts)).toEqual( + Array.from({ length: 8 }, (_, index) => `context_${index}`), + ); expect(value.selectedContextId).toBe("context_0"); expect(await readdir(directory)).toEqual(["contexts.json"]); expect((await stat(filePath)).mode & 0o777).toBe(0o600); @@ -53,7 +57,11 @@ describe("browser context metadata", () => { ]); const value = await second.read(); - expect(Object.keys(value.contexts).sort()).toEqual(["context_draft", "context_new", "context_saved"]); + expect(Object.keys(value.contexts).sort()).toEqual([ + "context_draft", + "context_new", + "context_saved", + ]); expect(value.selectedContextId).toBe("context_saved"); expect(value.contexts.context_saved).toMatchObject({ name: "Saved", status: "saved" }); expect(value.contexts.context_saved).not.toHaveProperty("ownerSessionId"); @@ -77,7 +85,9 @@ describe("browser context metadata", () => { JSON.stringify({}), JSON.stringify({ contexts: [] }), JSON.stringify({ contexts: { context_broken: null } }), - JSON.stringify({ contexts: { context_broken: { name: "Broken", createdAt: "bad", updatedAt: "bad" } } }), + JSON.stringify({ + contexts: { context_broken: { name: "Broken", createdAt: "bad", updatedAt: "bad" } }, + }), ])("refuses to overwrite invalid existing data: %s", async (contents) => { await writeFile(filePath, contents); const store = new ContextMetadataStore(filePath); diff --git a/tests/browser/e2e-auth.test.ts b/tests/browser/e2e-auth.test.ts index efcdca4..05cbc29 100644 --- a/tests/browser/e2e-auth.test.ts +++ b/tests/browser/e2e-auth.test.ts @@ -4,35 +4,61 @@ import { expect, it } from "vitest"; import { restrictBrowserRequests } from "../../scripts/browser-test-auth"; async function listen(server: Server) { - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("Fixture server did not bind"); return `http://127.0.0.1:${address.port}`; } -it.runIf(process.env.BELL_TEST_LOCAL_CHROME === "1")("keeps browser-test credentials on their exact origin across redirects", async () => { - const headers = { authorization: "Basic synthetic-only", "x-vercel-protection-bypass": "synthetic-bypass" }; - let contacted = false; - const received: Record = {}, errors: unknown[] = []; - const sink = createServer((_request, response) => { contacted = true; response.end("unexpected"); }); - const target = await listen(sink); - const source = createServer((request, response) => { - for (const name of Object.keys(headers)) received[name] = request.headers[name]; - response.writeHead(302, { location: target }); - response.end(); - }); - let browser; - try { - const origin = await listen(source); - browser = await chromium.launch({ headless: true, ...(process.env.TEST_CHROME_PATH ? { executablePath: process.env.TEST_CHROME_PATH } : { channel: "chrome" }) }); - const context = await browser.newContext({ serviceWorkers: "block" }), page = await context.newPage(); - await restrictBrowserRequests(context, page, origin, headers, (error: unknown) => errors.push(error)); - await expect(page.goto(origin)).rejects.toThrow(); - expect(received).toEqual(headers); - expect(contacted).toBe(false); - expect(errors).toEqual([]); - } finally { - await browser?.close(); - await Promise.all([source, sink].map(server => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())))); - } -}, 30_000); +it.runIf(process.env.BELL_TEST_LOCAL_CHROME === "1")( + "keeps browser-test credentials on their exact origin across redirects", + async () => { + const headers = { + authorization: "Basic synthetic-only", + "x-vercel-protection-bypass": "synthetic-bypass", + }; + let contacted = false; + const received: Record = {}, + errors: unknown[] = []; + const sink = createServer((_request, response) => { + contacted = true; + response.end("unexpected"); + }); + const target = await listen(sink); + const source = createServer((request, response) => { + for (const name of Object.keys(headers)) received[name] = request.headers[name]; + response.writeHead(302, { location: target }); + response.end(); + }); + let browser; + try { + const origin = await listen(source); + browser = await chromium.launch({ + headless: true, + ...(process.env.TEST_CHROME_PATH + ? { executablePath: process.env.TEST_CHROME_PATH } + : { channel: "chrome" }), + }); + const context = await browser.newContext({ serviceWorkers: "block" }), + page = await context.newPage(); + await restrictBrowserRequests(context, page, origin, headers, (error: unknown) => + errors.push(error), + ); + await expect(page.goto(origin)).rejects.toThrow(); + expect(received).toEqual(headers); + expect(contacted).toBe(false); + expect(errors).toEqual([]); + } finally { + await browser?.close(); + await Promise.all( + [source, sink].map( + (server) => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + ), + ); + } + }, + 30_000, +); diff --git a/tests/browser/eve-origin.test.ts b/tests/browser/eve-origin.test.ts index 16d206b..a9d744b 100644 --- a/tests/browser/eve-origin.test.ts +++ b/tests/browser/eve-origin.test.ts @@ -3,35 +3,84 @@ import channel from "../../agent/channels/eve"; const password = "synthetic-operator-password-at-least-32-bytes"; const authorization = `Basic ${Buffer.from(`operator:${password}`).toString("base64")}`; -const cancel = channel.routes!.find(route => route.method === "POST" && route.path.endsWith("/:sessionId/cancel"))!; -const stream = channel.routes!.find(route => route.method === "GET" && route.path.endsWith("/:sessionId/stream"))!; -afterEach(() => { vi.unstubAllEnvs(); }); -it.each(["0", "1"])("rejects cross-origin Basic writes without falling through to dev auth (%s)", async dev => { - vi.stubEnv("EVE_DEV", dev); vi.stubEnv("BELL_OPERATOR_USERNAME", "operator"); vi.stubEnv("BELL_OPERATOR_PASSWORD", password); - const callback = vi.fn(async () => ({ status: "accepted", sessionId: "synthetic-session" })); - const context = { params: { sessionId: "synthetic-session" }, attachSession: () => ({ cancel: callback }) }; - for (const origin of ["https://other.example", "null", ""]) for (const credential of [authorization, "", "Basic d3Jvbmc6d3Jvbmc="]) { - const response = await cancel.handler(new Request("https://bell.example/eve/v1/session/synthetic-session/cancel", { - method: "POST", headers: { authorization: credential, origin, "sec-fetch-site": "cross-site", "content-type": "text/plain" }, body: "{}", - }), context as never); - if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); - expect(response.status).toBe(403); - } - expect(callback).not.toHaveBeenCalled(); - const response = await cancel.handler(new Request("https://bell.example/eve/v1/session/synthetic-session/cancel", { - method: "POST", headers: { authorization, origin: "https://bell.example" }, body: "{}", - }), context as never); - if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); - expect(response.status).toBe(202); expect(callback).toHaveBeenCalledOnce(); +const cancel = channel.routes!.find( + (route) => route.method === "POST" && route.path.endsWith("/:sessionId/cancel"), +)!; +const stream = channel.routes!.find( + (route) => route.method === "GET" && route.path.endsWith("/:sessionId/stream"), +)!; +afterEach(() => { + vi.unstubAllEnvs(); }); +it.each(["0", "1"])( + "rejects cross-origin Basic writes without falling through to dev auth (%s)", + async (dev) => { + vi.stubEnv("EVE_DEV", dev); + vi.stubEnv("BELL_OPERATOR_USERNAME", "operator"); + vi.stubEnv("BELL_OPERATOR_PASSWORD", password); + const callback = vi.fn(async () => ({ status: "accepted", sessionId: "synthetic-session" })); + const context = { + params: { sessionId: "synthetic-session" }, + attachSession: () => ({ cancel: callback }), + }; + for (const origin of ["https://other.example", "null", ""]) + for (const credential of [authorization, "", "Basic d3Jvbmc6d3Jvbmc="]) { + const response = await cancel.handler( + new Request("https://bell.example/eve/v1/session/synthetic-session/cancel", { + method: "POST", + headers: { + authorization: credential, + origin, + "sec-fetch-site": "cross-site", + "content-type": "text/plain", + }, + body: "{}", + }), + context as never, + ); + if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); + expect(response.status).toBe(403); + } + expect(callback).not.toHaveBeenCalled(); + const response = await cancel.handler( + new Request("https://bell.example/eve/v1/session/synthetic-session/cancel", { + method: "POST", + headers: { authorization, origin: "https://bell.example" }, + body: "{}", + }), + context as never, + ); + if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); + expect(response.status).toBe(202); + expect(callback).toHaveBeenCalledOnce(); + }, +); it("accepts the original origin through Next's proxy and rejects a different origin", async () => { - vi.stubEnv("BELL_OPERATOR_USERNAME", "operator"); vi.stubEnv("BELL_OPERATOR_PASSWORD", password); + vi.stubEnv("BELL_OPERATOR_USERNAME", "operator"); + vi.stubEnv("BELL_OPERATOR_PASSWORD", password); const callback = vi.fn(async () => ({ status: "accepted", sessionId: "synthetic-session" })); - for (const [origin, status] of [["https://bell.example", 202], ["https://other.example", 403]] as const) { - const response = await cancel.handler(new Request("http://127.0.0.1:4274/eve/v1/session/synthetic-session/cancel", { - method: "POST", headers: { authorization, origin, host: "127.0.0.1:4274", "x-forwarded-host": "bell.example", "x-forwarded-proto": "https" }, body: "{}", - }), { params: { sessionId: "synthetic-session" }, attachSession: () => ({ cancel: callback }) } as never); + for (const [origin, status] of [ + ["https://bell.example", 202], + ["https://other.example", 403], + ] as const) { + const response = await cancel.handler( + new Request("http://127.0.0.1:4274/eve/v1/session/synthetic-session/cancel", { + method: "POST", + headers: { + authorization, + origin, + host: "127.0.0.1:4274", + "x-forwarded-host": "bell.example", + "x-forwarded-proto": "https", + }, + body: "{}", + }), + { + params: { sessionId: "synthetic-session" }, + attachSession: () => ({ cancel: callback }), + } as never, + ); if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); expect(response.status).toBe(status); } @@ -43,7 +92,10 @@ it("rejects cross-site writes with default local development configuration", asy vi.stubEnv("BELL_OPERATOR_USERNAME", ""); vi.stubEnv("BELL_OPERATOR_PASSWORD", ""); const callback = vi.fn(async () => ({ status: "accepted", sessionId: "synthetic-session" })); - const context = { params: { sessionId: "synthetic-session" }, attachSession: () => ({ cancel: callback }) }; + const context = { + params: { sessionId: "synthetic-session" }, + attachSession: () => ({ cancel: callback }), + }; const endpoint = "http://127.0.0.1:4320/eve/v1/session/synthetic-session/cancel"; const crossSiteHeaders: Record[] = [ { origin: "https://other.example", "sec-fetch-site": "cross-site" }, @@ -52,18 +104,28 @@ it("rejects cross-site writes with default local development configuration", asy { origin: "", "sec-fetch-site": "same-origin" }, ]; for (const headers of crossSiteHeaders) { - const response = await cancel.handler(new Request(endpoint, { - method: "POST", headers: { ...headers, "content-type": "text/plain" }, body: "{}", - }), context as never); + const response = await cancel.handler( + new Request(endpoint, { + method: "POST", + headers: { ...headers, "content-type": "text/plain" }, + body: "{}", + }), + context as never, + ); if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); expect(response.status).toBe(403); } expect(callback).not.toHaveBeenCalled(); const localHeaders: Record[] = [{ origin: "http://127.0.0.1:4320" }, {}]; for (const headers of localHeaders) { - const response = await cancel.handler(new Request(endpoint, { - method: "POST", headers, body: "{}", - }), context as never); + const response = await cancel.handler( + new Request(endpoint, { + method: "POST", + headers, + body: "{}", + }), + context as never, + ); if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); expect(response.status).toBe(202); } @@ -76,18 +138,31 @@ it("advertises Basic authentication and accepts authenticated server clients", a vi.stubEnv("BELL_OPERATOR_USERNAME", "operator"); vi.stubEnv("BELL_OPERATOR_PASSWORD", password); const callback = vi.fn(async () => ({ status: "accepted", sessionId: "synthetic-session" })); - const context = { params: { sessionId: "synthetic-session" }, attachSession: () => ({ cancel: callback }) }; + const context = { + params: { sessionId: "synthetic-session" }, + attachSession: () => ({ cancel: callback }), + }; const endpoint = "https://bell.example/eve/v1/session/synthetic-session/cancel"; - const unauthenticated = await cancel.handler(new Request(endpoint, { method: "POST", body: "{}" }), context as never); + const unauthenticated = await cancel.handler( + new Request(endpoint, { method: "POST", body: "{}" }), + context as never, + ); if (!(unauthenticated instanceof Response)) throw new Error("Expected an HTTP response."); expect(unauthenticated.status).toBe(401); - expect(unauthenticated.headers.get("www-authenticate")).toContain('Basic realm="Bell", charset="UTF-8"'); + expect(unauthenticated.headers.get("www-authenticate")).toContain( + 'Basic realm="Bell", charset="UTF-8"', + ); expect(unauthenticated.headers.get("www-authenticate")).toContain("Bearer"); expect(callback).not.toHaveBeenCalled(); - const authenticated = await cancel.handler(new Request(endpoint, { - method: "POST", headers: { authorization }, body: "{}", - }), context as never); + const authenticated = await cancel.handler( + new Request(endpoint, { + method: "POST", + headers: { authorization }, + body: "{}", + }), + context as never, + ); if (!(authenticated instanceof Response)) throw new Error("Expected an HTTP response."); expect(authenticated.status).toBe(202); expect(callback).toHaveBeenCalledOnce(); @@ -99,20 +174,56 @@ it("denies development auth for foreign URL, Host, or forwarded Host on reads an vi.stubEnv("BELL_OPERATOR_PASSWORD", ""); const attachSession = vi.fn(); const cases = [ - { origin: "http://foreign.example:4320", host: "foreign.example:4320", forwardedHost: "foreign.example:4320", requestOrigin: "http://foreign.example:4320" }, - { origin: "http://foreign.example:4320", host: "foreign.example:4320", forwardedHost: "foreign.example:4320", requestOrigin: "http://127.0.0.1:4320" }, - { origin: "http://foreign.example:4320", host: "127.0.0.1:4320", forwardedHost: "foreign.example:4320", requestOrigin: "http://127.0.0.1:4320" }, - { origin: "http://127.0.0.1:4320", host: "foreign.example:4320", forwardedHost: "127.0.0.1:4320", requestOrigin: "http://127.0.0.1:4320" }, - { origin: "http://127.0.0.1:4320", host: "127.0.0.1:4320", forwardedHost: "127.0.0.1:4320", requestOrigin: "http://foreign.example:4320" }, + { + origin: "http://foreign.example:4320", + host: "foreign.example:4320", + forwardedHost: "foreign.example:4320", + requestOrigin: "http://foreign.example:4320", + }, + { + origin: "http://foreign.example:4320", + host: "foreign.example:4320", + forwardedHost: "foreign.example:4320", + requestOrigin: "http://127.0.0.1:4320", + }, + { + origin: "http://foreign.example:4320", + host: "127.0.0.1:4320", + forwardedHost: "foreign.example:4320", + requestOrigin: "http://127.0.0.1:4320", + }, + { + origin: "http://127.0.0.1:4320", + host: "foreign.example:4320", + forwardedHost: "127.0.0.1:4320", + requestOrigin: "http://127.0.0.1:4320", + }, + { + origin: "http://127.0.0.1:4320", + host: "127.0.0.1:4320", + forwardedHost: "127.0.0.1:4320", + requestOrigin: "http://foreign.example:4320", + }, ]; for (const { origin, host, forwardedHost, requestOrigin } of cases) { for (const route of [cancel, stream]) { - const request = new Request(`${requestOrigin}${route.path.replace(":sessionId", "synthetic-session")}`, { - method: route.method, - headers: { origin, host, "x-forwarded-host": forwardedHost, "sec-fetch-site": "same-origin" }, - ...(route.method === "POST" ? { body: "{}" } : {}), - }); - const response = await route.handler(request, { params: { sessionId: "synthetic-session" }, attachSession } as never); + const request = new Request( + `${requestOrigin}${route.path.replace(":sessionId", "synthetic-session")}`, + { + method: route.method, + headers: { + origin, + host, + "x-forwarded-host": forwardedHost, + "sec-fetch-site": "same-origin", + }, + ...(route.method === "POST" ? { body: "{}" } : {}), + }, + ); + const response = await route.handler(request, { + params: { sessionId: "synthetic-session" }, + attachSession, + } as never); if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); expect(response.status).toBe(401); } @@ -120,26 +231,37 @@ it("denies development auth for foreign URL, Host, or forwarded Host on reads an expect(attachSession).not.toHaveBeenCalled(); }); -it.each(["localhost", "127.0.0.1", "[::1]"])("accepts development reads and writes on %s", async host => { - vi.stubEnv("EVE_DEV", "1"); - vi.stubEnv("BELL_OPERATOR_USERNAME", ""); - vi.stubEnv("BELL_OPERATOR_PASSWORD", ""); - const attachSession = vi.fn(() => ({ - id: "synthetic-session", - cancel: async () => ({ status: "accepted", sessionId: "synthetic-session" }), - getStreamTailIndex: async () => -1, - getEventStream: async () => new ReadableStream({ start(controller) { controller.close(); } }), - })); - const origin = `http://${host}:4320`; - for (const route of [cancel, stream]) { - const response = await route.handler(new Request(`${origin}${route.path.replace(":sessionId", "synthetic-session")}`, { - method: route.method, - headers: { origin, host: `${host}:4320`, "x-forwarded-host": `${host}:4320` }, - ...(route.method === "POST" ? { body: "{}" } : {}), - }), { params: { sessionId: "synthetic-session" }, attachSession } as never); - if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); - expect(response.status).toBe(route.method === "POST" ? 202 : 200); - await response.text(); - } - expect(attachSession).toHaveBeenCalledTimes(2); -}); +it.each(["localhost", "127.0.0.1", "[::1]"])( + "accepts development reads and writes on %s", + async (host) => { + vi.stubEnv("EVE_DEV", "1"); + vi.stubEnv("BELL_OPERATOR_USERNAME", ""); + vi.stubEnv("BELL_OPERATOR_PASSWORD", ""); + const attachSession = vi.fn(() => ({ + id: "synthetic-session", + cancel: async () => ({ status: "accepted", sessionId: "synthetic-session" }), + getStreamTailIndex: async () => -1, + getEventStream: async () => + new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + })); + const origin = `http://${host}:4320`; + for (const route of [cancel, stream]) { + const response = await route.handler( + new Request(`${origin}${route.path.replace(":sessionId", "synthetic-session")}`, { + method: route.method, + headers: { origin, host: `${host}:4320`, "x-forwarded-host": `${host}:4320` }, + ...(route.method === "POST" ? { body: "{}" } : {}), + }), + { params: { sessionId: "synthetic-session" }, attachSession } as never, + ); + if (!(response instanceof Response)) throw new Error("Expected an HTTP response."); + expect(response.status).toBe(route.method === "POST" ? 202 : 200); + await response.text(); + } + expect(attachSession).toHaveBeenCalledTimes(2); + }, +); diff --git a/tests/browser/fixtures/native-vault.json b/tests/browser/fixtures/native-vault.json index a27d8b2..ba96cea 100644 --- a/tests/browser/fixtures/native-vault.json +++ b/tests/browser/fixtures/native-vault.json @@ -5,9 +5,7 @@ "version": 1, "id": "fixture-login", "label": "Demo", - "allowedHosts": [ - "authenticationtest.com" - ], + "allowedHosts": ["authenticationtest.com"], "hasTotp": true, "createdAt": "2026-09-05T00:00:00.000Z", "updatedAt": "2026-09-05T00:00:00.000Z", diff --git a/tests/browser/hosted-page.test.ts b/tests/browser/hosted-page.test.ts index 372b280..35657db 100644 --- a/tests/browser/hosted-page.test.ts +++ b/tests/browser/hosted-page.test.ts @@ -52,8 +52,13 @@ describe("shared hosted-page integration boundary", () => { }); it("does not observe the page or run post-CAPTCHA snapshots after a private operation", async () => { const { session, state, page } = fixture(); - const title = vi.spyOn(page, "title"), url = vi.spyOn(page, "url"); - await session.withHostedPage(new AbortController().signal, async () => ({ submitted: true }), true); + const title = vi.spyOn(page, "title"), + url = vi.spyOn(page, "url"); + await session.withHostedPage( + new AbortController().signal, + async () => ({ submitted: true }), + true, + ); expect(title).not.toHaveBeenCalled(); expect(url).not.toHaveBeenCalled(); expect((session as unknown as { gateCaptcha: unknown }).gateCaptcha).toHaveBeenCalledTimes(1); @@ -108,26 +113,32 @@ describe("shared hosted-page integration boundary", () => { expect(options.userMetadata.sessionRecording).toBe("false"); }); - it.each([undefined, { country: "US" }])("keeps Verified and managed proxies enabled with location %j", (location) => { - const options = browserbaseLaunchOptions("test-key", "test-context", location); - expect(options.browserSettings).toMatchObject({ - verified: true, - context: { id: "test-context", persist: true }, - }); - expect(options.keepAlive).toBe(true); - expect(options.proxies).toEqual(location - ? [{ type: "browserbase", geolocation: location }] - : true); - }); + it.each([undefined, { country: "US" }])( + "keeps Verified and managed proxies enabled with location %j", + (location) => { + const options = browserbaseLaunchOptions("test-key", "test-context", location); + expect(options.browserSettings).toMatchObject({ + verified: true, + context: { id: "test-context", persist: true }, + }); + expect(options.keepAlive).toBe(true); + expect(options.proxies).toEqual( + location ? [{ type: "browserbase", geolocation: location }] : true, + ); + }, + ); it("retries hosted failures with Verified enabled and never falls back to local Chrome", async () => { vi.useFakeTimers(); vi.stubEnv("STAGEHAND_BROWSER", "browserbase"); vi.stubEnv("BROWSERBASE_API_KEY", "test-key"); - const launch = vi.spyOn(browserbase, "launch").mockRejectedValue(new Error("Synthetic launch failure")); + const launch = vi + .spyOn(browserbase, "launch") + .mockRejectedValue(new Error("Synthetic launch failure")); const localLaunch = vi.spyOn(localBrowser, "launch"); const state: ConversationState = { - id: "verified-retry-test", traces: [], + id: "verified-retry-test", + traces: [], browser: { provider: "not-started", status: "idle" }, }; const session = new BrowserSession(state); diff --git a/tests/browser/onepassword-cli.test.ts b/tests/browser/onepassword-cli.test.ts index 95540cb..f9fe3c6 100644 --- a/tests/browser/onepassword-cli.test.ts +++ b/tests/browser/onepassword-cli.test.ts @@ -8,7 +8,10 @@ vi.mock("@1password/sdk", () => ({ createClient: mocks.createClient })); function item() { return { - id: "login-id", vault: { id: "vault-id" }, title: "Example login", category: "LOGIN", + id: "login-id", + vault: { id: "vault-id" }, + title: "Example login", + category: "LOGIN", fields: [ { id: "username", purpose: "USERNAME", value: "fixture-private-user" }, { id: "password", purpose: "PASSWORD", value: "fixture-private-password" }, @@ -26,21 +29,27 @@ function item() { type Callback = (error: Error | null, stdout: string, stderr: string) => void; type Options = { env: NodeJS.ProcessEnv; timeout: number; maxBuffer: number }; -const respondWith = (result: unknown) => ( - _file: string, _args: string[], _options: Options, callback: Callback, -) => callback(null, typeof result === "string" ? result : JSON.stringify(result), ""); +const respondWith = + (result: unknown) => (_file: string, _args: string[], _options: Options, callback: Callback) => + callback(null, typeof result === "string" ? result : JSON.stringify(result), ""); beforeEach(() => { vi.resetAllMocks(); vi.stubEnv("BELL_ONEPASSWORD_BACKEND", "cli"); vi.stubEnv("OP_SERVICE_ACCOUNT_TOKEN", ""); vi.stubEnv("OP_ACCOUNT", "account-id"); - mocks.execFile.mockImplementation((file: string, args: string[], options: Options, callback: Callback) => { - const result = args.includes("--otp") ? "123456\n" - : args.includes("vault") ? [{ id: "vault-id", name: "Agent vault" }] - : args.includes("list") ? [{ id: "login-id" }] : item(); - respondWith(result)(file, args, options, callback); - }); + mocks.execFile.mockImplementation( + (file: string, args: string[], options: Options, callback: Callback) => { + const result = args.includes("--otp") + ? "123456\n" + : args.includes("vault") + ? [{ id: "vault-id", name: "Agent vault" }] + : args.includes("list") + ? [{ id: "login-id" }] + : item(); + respondWith(result)(file, args, options, callback); + }, + ); }); afterEach(() => vi.unstubAllEnvs()); @@ -48,10 +57,16 @@ afterEach(() => vi.unstubAllEnvs()); describe("explicit 1Password CLI compatibility", () => { it("lists safe metadata without initializing the SDK or returning secrets", async () => { const result = await new OnePasswordVault().list(); - expect(result).toEqual([{ - itemId: "login-id", vaultId: "vault-id", vaultName: "Agent vault", label: "Example login", - allowedHosts: ["example.com"], fields: { username: true, password: true, totp: true }, - }]); + expect(result).toEqual([ + { + itemId: "login-id", + vaultId: "vault-id", + vaultName: "Agent vault", + label: "Example login", + allowedHosts: ["example.com"], + fields: { username: true, password: true, totp: true }, + }, + ]); expect(JSON.stringify(result)).not.toContain("fixture-private"); expect(mocks.createClient).not.toHaveBeenCalled(); expect(mocks.execFile).toHaveBeenCalledTimes(3); @@ -60,11 +75,21 @@ describe("explicit 1Password CLI compatibility", () => { it("reads credentials and hosts from one scoped item and gets only a generated OTP", async () => { const result = await new OnePasswordVault().login("vault-id", "login-id"); expect(result).toEqual({ - allowedHosts: ["example.com"], username: "fixture-private-user", - password: "fixture-private-password", totp: "123456", + allowedHosts: ["example.com"], + username: "fixture-private-user", + password: "fixture-private-password", + totp: "123456", }); expect(mocks.execFile.mock.calls.map((call) => call[1])).toEqual([ - ["--account=account-id", "item", "get", "--vault=vault-id", "--format=json", "--", "login-id"], + [ + "--account=account-id", + "item", + "get", + "--vault=vault-id", + "--format=json", + "--", + "login-id", + ], ["--account=account-id", "item", "get", "--vault=vault-id", "--otp", "--", "login-id"], ]); expect(JSON.stringify(result)).not.toContain("fixture-private-totp-seed"); @@ -81,7 +106,14 @@ describe("explicit 1Password CLI compatibility", () => { expect(file).toBe("op"); expect(args[0]).toBe("--account=account-id"); expect(options).toMatchObject({ timeout: 20_000, maxBuffer: 2 * 1024 * 1024 }); - for (const name of ["OP_CONNECT_HOST", "OP_CONNECT_TOKEN", "OP_SESSION", "OP_SESSION_other", "OP_SERVICE_ACCOUNT_TOKEN", "OP_ACCOUNT"]) + for (const name of [ + "OP_CONNECT_HOST", + "OP_CONNECT_TOKEN", + "OP_SESSION", + "OP_SESSION_other", + "OP_SERVICE_ACCOUNT_TOKEN", + "OP_ACCOUNT", + ]) expect(options.env).not.toHaveProperty(name); expect(options.env.OP_DEBUG).toBe("false"); } @@ -109,28 +141,47 @@ describe("explicit 1Password CLI compatibility", () => { expect(mocks.execFile).not.toHaveBeenCalled(); }); - it.each(["--help", "-", "item/id", "item\nother"])("rejects unsafe item ID %s before spawning", async (itemId) => { - await expect(new OnePasswordVault().login("vault-id", itemId)).rejects.toThrow("Invalid 1Password"); - expect(mocks.execFile).not.toHaveBeenCalled(); - }); + it.each(["--help", "-", "item/id", "item\nother"])( + "rejects unsafe item ID %s before spawning", + async (itemId) => { + await expect(new OnePasswordVault().login("vault-id", itemId)).rejects.toThrow( + "Invalid 1Password", + ); + expect(mocks.execFile).not.toHaveBeenCalled(); + }, + ); it("rejects a mismatched item or vault before returning credentials", async () => { mocks.execFile.mockImplementation(respondWith({ ...item(), vault: { id: "other-vault" } })); - await expect(new OnePasswordVault().login("vault-id", "login-id")).rejects.toThrow("Could not read"); + await expect(new OnePasswordVault().login("vault-id", "login-id")).rejects.toThrow( + "Could not read", + ); expect(mocks.execFile).toHaveBeenCalledTimes(1); }); - it.each(["", "fixture-private-seed", "123456\nprivate-error"])("omits unavailable or malformed OTP %j for human handoff", async (otp) => { - mocks.execFile.mockImplementationOnce(respondWith(item())).mockImplementationOnce(respondWith(otp)); - expect(await new OnePasswordVault().login("vault-id", "login-id")).not.toHaveProperty("totp"); - }); + it.each(["", "fixture-private-seed", "123456\nprivate-error"])( + "omits unavailable or malformed OTP %j for human handoff", + async (otp) => { + mocks.execFile + .mockImplementationOnce(respondWith(item())) + .mockImplementationOnce(respondWith(otp)); + expect(await new OnePasswordVault().login("vault-id", "login-id")).not.toHaveProperty("totp"); + }, + ); it("keeps provider stdout and stderr out of errors and allows retry", async () => { - mocks.execFile.mockImplementationOnce((_file: string, _args: string[], _options: Options, callback: Callback) => { - callback(Object.assign(new Error("fixture-private-provider-error"), { - stdout: "fixture-private-secret", stderr: "fixture-private-details", - }), "fixture-private-secret", "fixture-private-details"); - }); + mocks.execFile.mockImplementationOnce( + (_file: string, _args: string[], _options: Options, callback: Callback) => { + callback( + Object.assign(new Error("fixture-private-provider-error"), { + stdout: "fixture-private-secret", + stderr: "fixture-private-details", + }), + "fixture-private-secret", + "fixture-private-details", + ); + }, + ); const vault = new OnePasswordVault(); const request = vault.list(); await expect(request).rejects.toThrow("Could not list 1Password CLI logins"); @@ -141,7 +192,10 @@ describe("explicit 1Password CLI compatibility", () => { it("sanitizes invalid JSON errors without exposing provider output", async () => { mocks.execFile.mockImplementationOnce(respondWith("fixture-private-json")); - await expect(new OnePasswordVault().login("vault-id", "login-id")) - .rejects.toThrow(new Error("Could not read the 1Password CLI login. Check the item handle, configured account, and vault permissions.")); + await expect(new OnePasswordVault().login("vault-id", "login-id")).rejects.toThrow( + new Error( + "Could not read the 1Password CLI login. Check the item handle, configured account, and vault permissions.", + ), + ); }); }); diff --git a/tests/browser/onepassword-login-real.test.ts b/tests/browser/onepassword-login-real.test.ts index f488954..330c1f8 100644 --- a/tests/browser/onepassword-login-real.test.ts +++ b/tests/browser/onepassword-login-real.test.ts @@ -14,9 +14,11 @@ describe("opt-in live 1Password login", () => { "uses a Login item without exposing credentials to the model", async () => { const items = await onePasswordVault.list(), - item = items.find((candidate) => - candidate.label === "Bell integration test (authenticationtest.com)" && - candidate.allowedHosts.includes("authenticationtest.com")); + item = items.find( + (candidate) => + candidate.label === "Bell integration test (authenticationtest.com)" && + candidate.allowedHosts.includes("authenticationtest.com"), + ); expect(item).toBeDefined(); expect(item?.fields).toEqual({ username: true, @@ -52,8 +54,11 @@ describe("opt-in live 1Password login", () => { expect(page.tree).toContain("Login Success"); expect(page.tree).toContain("You are now logged in"); const visible = JSON.stringify({ result, traces: state.traces }); - expect([login.username, login.password, login.totp] - .some((secret) => secret && visible.includes(secret))).toBe(false); + expect( + [login.username, login.password, login.totp].some( + (secret) => secret && visible.includes(secret), + ), + ).toBe(false); console.log( JSON.stringify( { diff --git a/tests/browser/onepassword.test.ts b/tests/browser/onepassword.test.ts index 7d56a70..2b173b0 100644 --- a/tests/browser/onepassword.test.ts +++ b/tests/browser/onepassword.test.ts @@ -31,20 +31,45 @@ function loginItem(): Item { category: ItemCategory.Login, fields: [ { id: "username", title: "username", fieldType: ItemFieldType.Text, value: "fixture-user" }, - { id: "password", title: "password", fieldType: ItemFieldType.Concealed, value: "fixture-password" }, { - id: "otp", title: "OTP", fieldType: ItemFieldType.Totp, value: "fixture-totp-seed", + id: "password", + title: "password", + fieldType: ItemFieldType.Concealed, + value: "fixture-password", + }, + { + id: "otp", + title: "OTP", + fieldType: ItemFieldType.Totp, + value: "fixture-totp-seed", details: { type: "Otp", content: { code: "123456" } }, }, ], websites: [ - { url: "https://EXAMPLE.com/login", label: "website", autofillBehavior: AutofillBehavior.ExactDomain }, - { url: "https://example.com/", label: "duplicate", autofillBehavior: AutofillBehavior.ExactDomain }, - { url: "https://blocked.example/", label: "no autofill", autofillBehavior: AutofillBehavior.Never }, + { + url: "https://EXAMPLE.com/login", + label: "website", + autofillBehavior: AutofillBehavior.ExactDomain, + }, + { + url: "https://example.com/", + label: "duplicate", + autofillBehavior: AutofillBehavior.ExactDomain, + }, + { + url: "https://blocked.example/", + label: "no autofill", + autofillBehavior: AutofillBehavior.Never, + }, { url: "invalid", label: "invalid", autofillBehavior: AutofillBehavior.ExactDomain }, ], - sections: [], notes: "fixture-private-note", tags: [], version: 1, files: [], - createdAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01"), + sections: [], + notes: "fixture-private-note", + tags: [], + version: 1, + files: [], + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), }; } @@ -71,19 +96,34 @@ describe("1Password vault", () => { { id: "login-id", category: ItemCategory.Login }, ]); const result = await new OnePasswordVault().list(); - expect(result).toEqual([{ - itemId: "login-id", vaultId: "vault-id", vaultName: "Agent vault", label: "Example login", - allowedHosts: ["example.com"], fields: { username: true, password: true, totp: true }, - }]); + expect(result).toEqual([ + { + itemId: "login-id", + vaultId: "vault-id", + vaultName: "Agent vault", + label: "Example login", + allowedHosts: ["example.com"], + fields: { username: true, password: true, totp: true }, + }, + ]); expect(sdk.getItem).toHaveBeenCalledExactlyOnceWith("vault-id", "login-id"); - for (const secret of ["fixture-user", "fixture-password", "fixture-totp-seed", "fixture-private-note", "123456"]) + for (const secret of [ + "fixture-user", + "fixture-password", + "fixture-totp-seed", + "fixture-private-note", + "123456", + ]) expect(JSON.stringify(result)).not.toContain(secret); }); it("uses one scoped read for credentials, permitted hosts, and computed OTP", async () => { const result = await new OnePasswordVault().login("vault-id", "login-id"); expect(result).toEqual({ - allowedHosts: ["example.com"], username: "fixture-user", password: "fixture-password", totp: "123456", + allowedHosts: ["example.com"], + username: "fixture-user", + password: "fixture-password", + totp: "123456", }); expect(sdk.getItem).toHaveBeenCalledExactlyOnceWith("vault-id", "login-id"); expect(sdk.listVaults).not.toHaveBeenCalled(); @@ -92,16 +132,23 @@ describe("1Password vault", () => { it("does not substitute a TOTP seed or provider error for an unavailable code", async () => { const item = loginItem(); - item.fields[2].details = { type: "Otp", content: { errorMessage: "fixture-private-provider-error" } }; + item.fields[2].details = { + type: "Otp", + content: { errorMessage: "fixture-private-provider-error" }, + }; sdk.getItem.mockResolvedValue(item); expect(await new OnePasswordVault().login("vault-id", "login-id")).toEqual({ - allowedHosts: ["example.com"], username: "fixture-user", password: "fixture-password", + allowedHosts: ["example.com"], + username: "fixture-user", + password: "fixture-password", }); }); it("rejects non-Login items even when they have username and password fields", async () => { sdk.getItem.mockResolvedValue({ ...loginItem(), category: ItemCategory.SecureNote }); - await expect(new OnePasswordVault().login("vault-id", "login-id")).rejects.toThrow("not a Login"); + await expect(new OnePasswordVault().login("vault-id", "login-id")).rejects.toThrow( + "not a Login", + ); expect(await new OnePasswordVault().list()).toEqual([]); }); @@ -109,8 +156,12 @@ describe("1Password vault", () => { const item = loginItem(); item.fields.find((candidate) => candidate.id === field)!.value = ""; sdk.getItem.mockResolvedValue(item); - await expect(new OnePasswordVault().login("vault-id", "login-id")).rejects.toThrow("must contain a username and password"); - expect((await new OnePasswordVault().list())[0].fields[field as "username" | "password"]).toBe(false); + await expect(new OnePasswordVault().login("vault-id", "login-id")).rejects.toThrow( + "must contain a username and password", + ); + expect((await new OnePasswordVault().list())[0].fields[field as "username" | "password"]).toBe( + false, + ); }); it("prefers the service account and reuses its client", async () => { @@ -119,7 +170,9 @@ describe("1Password vault", () => { await vault.list(); await vault.login("vault-id", "login-id"); expect(sdk.createClient).toHaveBeenCalledExactlyOnceWith({ - auth: "fixture-service-token", integrationName: "Bell", integrationVersion: "0.1.0", + auth: "fixture-service-token", + integrationName: "Bell", + integrationVersion: "0.1.0", }); }); @@ -129,7 +182,8 @@ describe("1Password vault", () => { await new OnePasswordVault().list(); expect(sdk.createClient).toHaveBeenCalledWith({ auth: expect.objectContaining({ accountName: "Desktop account" }), - integrationName: "Bell", integrationVersion: "0.1.0", + integrationName: "Bell", + integrationVersion: "0.1.0", }); }); @@ -144,7 +198,9 @@ describe("1Password vault", () => { sdk.createClient.mockRejectedValueOnce(new Error("fixture-private-provider-error")); const vault = new OnePasswordVault(); const request = vault.list(); - await expect(request).rejects.toThrow(new Error("Could not connect to 1Password. Check the configured account and authorization.")); + await expect(request).rejects.toThrow( + new Error("Could not connect to 1Password. Check the configured account and authorization."), + ); await expect(request).rejects.not.toHaveProperty("cause"); expect(await vault.list()).toHaveLength(1); expect(sdk.createClient).toHaveBeenCalledTimes(2); @@ -163,7 +219,11 @@ describe("1Password vault", () => { const method = { vaults: sdk.listVaults, items: sdk.listItems, item: sdk.getItem }[operation]!; method.mockRejectedValueOnce(new Error("fixture-private-provider-error")); const request = new OnePasswordVault().list(); - await expect(request).rejects.toThrow(new Error("Could not list 1Password logins. Check the configured account and vault permissions.")); + await expect(request).rejects.toThrow( + new Error( + "Could not list 1Password logins. Check the configured account and vault permissions.", + ), + ); await expect(request).rejects.not.toHaveProperty("cause"); expect(cli.listCliLogins).not.toHaveBeenCalled(); }); @@ -171,7 +231,9 @@ describe("1Password vault", () => { it("sanitizes credential read errors", async () => { sdk.getItem.mockRejectedValueOnce(new Error("fixture-private-provider-error")); const request = new OnePasswordVault().login("vault-id", "login-id"); - await expect(request).rejects.toThrow(new Error("Could not read the 1Password login. Check the item handle and vault permissions.")); + await expect(request).rejects.toThrow( + new Error("Could not read the 1Password login. Check the item handle and vault permissions."), + ); await expect(request).rejects.not.toHaveProperty("cause"); expect(cli.readCliLogin).not.toHaveBeenCalled(); }); diff --git a/tests/browser/operator-auth.test.ts b/tests/browser/operator-auth.test.ts index 2dc1dc0..b426d61 100644 --- a/tests/browser/operator-auth.test.ts +++ b/tests/browser/operator-auth.test.ts @@ -21,7 +21,7 @@ beforeEach(() => { afterEach(() => vi.unstubAllEnvs()); describe("operator authentication", () => { - it.each(["", "short"])("fails closed with an absent or weak operator password", value => { + it.each(["", "short"])("fails closed with an absent or weak operator password", (value) => { vi.stubEnv("BELL_OPERATOR_PASSWORD", value); expect(operatorAuth(request("/"))).toBeNull(); }); @@ -32,15 +32,25 @@ describe("operator authentication", () => { }); it("rejects cross-origin writes even with an authenticated operator", () => { - expect(() => operatorAuth(request("/eve/v1/sessions/example/cancel", { - method: "POST", - headers: { origin: "https://other.example", "sec-fetch-site": "same-origin" }, - }))).toThrow(); + expect(() => + operatorAuth( + request("/eve/v1/sessions/example/cancel", { + method: "POST", + headers: { origin: "https://other.example", "sec-fetch-site": "same-origin" }, + }), + ), + ).toThrow(); }); it("lets server bearer requests reach the next authentication strategy", () => { - expect(operatorAuth(new Request(`${origin}/eve/v1/session`, { - method: "POST", headers: { authorization: "Bearer synthetic-token" }, body: "{}", - }))).toBeNull(); + expect( + operatorAuth( + new Request(`${origin}/eve/v1/session`, { + method: "POST", + headers: { authorization: "Bearer synthetic-token" }, + body: "{}", + }), + ), + ).toBeNull(); }); }); diff --git a/tests/browser/redact.test.ts b/tests/browser/redact.test.ts index 6ce7fdd..aa78128 100644 --- a/tests/browser/redact.test.ts +++ b/tests/browser/redact.test.ts @@ -17,15 +17,36 @@ describe("trace redaction", () => { }); it("redacts sensitive fields even when their values have no recognizable pattern", () => { - const input = { password: "arbitrary-one", headers: { Authorization: "arbitrary-two", Cookie: "arbitrary-three" }, access_token: "arbitrary-four", cardNumber: "4242424242424242", cvc: "123" }; - expect(redactValue(input)).toEqual({ password: "[REDACTED]", headers: { Authorization: "[REDACTED]", Cookie: "[REDACTED]" }, access_token: "[REDACTED]", cardNumber: "[REDACTED]", cvc: "[REDACTED]" }); + const input = { + password: "arbitrary-one", + headers: { Authorization: "arbitrary-two", Cookie: "arbitrary-three" }, + access_token: "arbitrary-four", + cardNumber: "4242424242424242", + cvc: "123", + }; + expect(redactValue(input)).toEqual({ + password: "[REDACTED]", + headers: { Authorization: "[REDACTED]", Cookie: "[REDACTED]" }, + access_token: "[REDACTED]", + cardNumber: "[REDACTED]", + cvc: "[REDACTED]", + }); }); it("hides every entered form value while preserving useful action metadata", () => { - const actions = ["fill", "type", "select"].map(action => ({ action, target: "#field", value: "synthetic-private-value" })); + const actions = ["fill", "type", "select"].map((action) => ({ + action, + target: "#field", + value: "synthetic-private-value", + })); const output = redactValue({ actions }); - expect(output.actions).toEqual(actions.map(action => ({ ...action, value: "[value hidden]" }))); + expect(output.actions).toEqual( + actions.map((action) => ({ ...action, value: "[value hidden]" })), + ); expect(actions[0].value).toBe("synthetic-private-value"); - expect(redactValue({ action: "press", key: "s" })).toEqual({ action: "press", key: "[key hidden]" }); + expect(redactValue({ action: "press", key: "s" })).toEqual({ + action: "press", + key: "[key hidden]", + }); }); }); diff --git a/tests/browser/screenshot-tool.test.ts b/tests/browser/screenshot-tool.test.ts index f3f2e96..29fa893 100644 --- a/tests/browser/screenshot-tool.test.ts +++ b/tests/browser/screenshot-tool.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vitest"; import screenshot from "../../agent/tools/screenshot"; -const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aS2kAAAAASUVORK5CYII="; +const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aS2kAAAAASUVORK5CYII="; const workbench = { browser: { provider: "local" as const, status: "active" as const }, traces: [], @@ -27,7 +28,8 @@ describe("screenshot model output", () => { it("preserves a CAPTCHA handoff when no screenshot was captured", async () => { const result = { status: "waiting_for_user", reason: "captcha" }; expect(await screenshot.toModelOutput!({ result, workbench } as never)).toEqual({ - type: "json", value: result, + type: "json", + value: result, }); }); }); diff --git a/tests/browser/vault-list-tool.test.ts b/tests/browser/vault-list-tool.test.ts index 3917bcc..4d47129 100644 --- a/tests/browser/vault-list-tool.test.ts +++ b/tests/browser/vault-list-tool.test.ts @@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import vaultList from "../../agent/tools/vault_list_logins"; const mocks = vi.hoisted(() => ({ nativeList: vi.fn(), onePasswordList: vi.fn() })); -vi.mock("../../server/browser/vault/native.js", () => ({ nativeVault: { list: mocks.nativeList } })); +vi.mock("../../server/browser/vault/native.js", () => ({ + nativeVault: { list: mocks.nativeList }, +})); vi.mock("../../server/browser/vault/onepassword.js", () => ({ onePasswordVault: { list: mocks.onePasswordList }, })); @@ -20,14 +22,22 @@ describe("configured vault discovery", () => { afterEach(() => vi.unstubAllEnvs()); it("does not contact unconfigured optional integrations", async () => { - expect(await vaultList.execute({}, { session: { auth: { current: { authenticator: "http-basic" } } } } as never)).toEqual({ items: [] }); + expect( + await vaultList.execute({}, { + session: { auth: { current: { authenticator: "http-basic" } } }, + } as never), + ).toEqual({ items: [] }); expect(mocks.nativeList).not.toHaveBeenCalled(); expect(mocks.onePasswordList).not.toHaveBeenCalled(); }); it("discovers native logins without requiring 1Password", async () => { vi.stubEnv("BELL_VAULT_MASTER_KEY", "fixture-key"); - expect(await vaultList.execute({}, { session: { auth: { current: { authenticator: "http-basic" } } } } as never)).toEqual({ + expect( + await vaultList.execute({}, { + session: { auth: { current: { authenticator: "http-basic" } } }, + } as never), + ).toEqual({ items: [{ provider: "native", itemId: "local-item" }], }); expect(mocks.onePasswordList).not.toHaveBeenCalled(); @@ -36,22 +46,35 @@ describe("configured vault discovery", () => { it("includes provider IDs so the selected login reaches the correct backend", async () => { vi.stubEnv("BELL_VAULT_MASTER_KEY", "fixture-key"); vi.stubEnv("OP_ACCOUNT", "fixture-account"); - expect(await vaultList.execute({}, { session: { auth: { current: { authenticator: "http-basic" } } } } as never)).toEqual({ items: [ - { provider: "native", itemId: "local-item" }, - { provider: "onepassword", vaultId: "vault-id", itemId: "remote-item" }, - ] }); + expect( + await vaultList.execute({}, { + session: { auth: { current: { authenticator: "http-basic" } } }, + } as never), + ).toEqual({ + items: [ + { provider: "native", itemId: "local-item" }, + { provider: "onepassword", vaultId: "vault-id", itemId: "remote-item" }, + ], + }); }); it("does not silently hide errors for an explicitly requested provider", async () => { mocks.onePasswordList.mockRejectedValue(new Error("Configure 1Password")); - await expect(vaultList.execute({ provider: "onepassword" }, { session: { auth: { current: { authenticator: "http-basic" } } } } as never)) - .rejects.toThrow("Configure 1Password"); + await expect( + vaultList.execute({ provider: "onepassword" }, { + session: { auth: { current: { authenticator: "http-basic" } } }, + } as never), + ).rejects.toThrow("Configure 1Password"); expect(mocks.nativeList).not.toHaveBeenCalled(); }); }); it("does not expose the operator's vault to an iMessage sender", async () => { mocks.onePasswordList.mockClear(); - await expect(vaultList.execute({ provider: "onepassword" }, { session: { auth: { current: { authenticator: "linq-message" } } } } as never)).rejects.toThrow("personal credentials"); + await expect( + vaultList.execute({ provider: "onepassword" }, { + session: { auth: { current: { authenticator: "linq-message" } } }, + } as never), + ).rejects.toThrow("personal credentials"); expect(mocks.onePasswordList).not.toHaveBeenCalled(); }); diff --git a/tests/browser/vault-login-tool.test.ts b/tests/browser/vault-login-tool.test.ts index 732687b..f930287 100644 --- a/tests/browser/vault-login-tool.test.ts +++ b/tests/browser/vault-login-tool.test.ts @@ -50,7 +50,8 @@ const credentials = { allowedHosts: ["shop.example"], }; const context = (signal = new AbortController().signal) => ({ - session: { id: "vault-tool-test" }, abortSignal: signal, + session: { id: "vault-tool-test" }, + abortSignal: signal, }); describe("vault login tool boundary", () => { @@ -84,8 +85,9 @@ describe("vault login tool boundary", () => { if (!(schema instanceof z.ZodObject)) throw new Error("Expected a Zod tool schema."); const parsed = schema.parse({ ...input, provider: undefined }); expect(parsed.provider).toBe("onepassword"); - await expect(vaultLogin.execute({ ...input, vaultId: undefined }, context() as never)) - .rejects.toThrow("vault ID is required"); + await expect( + vaultLogin.execute({ ...input, vaultId: undefined }, context() as never), + ).rejects.toThrow("vault ID is required"); expect(mocks.login).not.toHaveBeenCalled(); expect(mocks.secureLogin).not.toHaveBeenCalled(); }); @@ -96,8 +98,9 @@ describe("vault login tool boundary", () => { controller.abort(); return credentials; }); - await expect(vaultLogin.execute(input, context(controller.signal) as never)) - .rejects.toMatchObject({ name: "AbortError" }); + await expect( + vaultLogin.execute(input, context(controller.signal) as never), + ).rejects.toMatchObject({ name: "AbortError" }); expect(mocks.secureLogin).not.toHaveBeenCalled(); }); @@ -107,8 +110,9 @@ describe("vault login tool boundary", () => { controller.abort(); return credentials; }); - await expect(vaultLogin.execute({ ...input, provider: "native" }, context(controller.signal) as never)) - .rejects.toMatchObject({ name: "AbortError" }); + await expect( + vaultLogin.execute({ ...input, provider: "native" }, context(controller.signal) as never), + ).rejects.toMatchObject({ name: "AbortError" }); expect(mocks.secureLogin).not.toHaveBeenCalled(); }); diff --git a/tests/browser/vault.test.ts b/tests/browser/vault.test.ts index a2f497e..f23a29a 100644 --- a/tests/browser/vault.test.ts +++ b/tests/browser/vault.test.ts @@ -11,15 +11,18 @@ import { generateTotp } from "../../server/browser/vault/totp"; const directories: string[] = []; const masterKey = Buffer.alloc(32, 7); async function fixture() { - const directory = await mkdtemp(join(tmpdir(), "bell-vault-")), path = join(directory, "vault.json"); + const directory = await mkdtemp(join(tmpdir(), "bell-vault-")), + path = join(directory, "vault.json"); directories.push(directory); await copyFile(new URL("./fixtures/native-vault.json", import.meta.url), path); return { path, vault: new NativeVault(path, masterKey) }; } async function replacePayload(path: string, payload: unknown) { - const store = JSON.parse(await readFile(path, "utf8")), dataKey = Buffer.alloc(32, 8); + const store = JSON.parse(await readFile(path, "utf8")), + dataKey = Buffer.alloc(32, 8); const seal = (key: Buffer, text: Buffer) => { - const iv = Buffer.alloc(12, 3), cipher = createCipheriv("aes-256-gcm", key, iv); + const iv = Buffer.alloc(12, 3), + cipher = createCipheriv("aes-256-gcm", key, iv); return { iv: iv.toString("base64"), ciphertext: Buffer.concat([cipher.update(text), cipher.final()]).toString("base64"), @@ -33,7 +36,9 @@ async function replacePayload(path: string, payload: unknown) { afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); - await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); }); describe("existing local encrypted vaults", () => { @@ -41,15 +46,17 @@ describe("existing local encrypted vaults", () => { const { vault } = await fixture(); vi.useFakeTimers(); vi.setSystemTime(59000); - expect(await vault.list()).toEqual([{ - provider: "native", - itemId: "fixture-login", - label: "Demo", - allowedHosts: ["authenticationtest.com"], - fields: { username: true, password: true, totp: true }, - createdAt: "2026-09-05T00:00:00.000Z", - updatedAt: "2026-09-05T00:00:00.000Z", - }]); + expect(await vault.list()).toEqual([ + { + provider: "native", + itemId: "fixture-login", + label: "Demo", + allowedHosts: ["authenticationtest.com"], + fields: { username: true, password: true, totp: true }, + createdAt: "2026-09-05T00:00:00.000Z", + updatedAt: "2026-09-05T00:00:00.000Z", + }, + ]); expect(await vault.login("fixture-login")).toEqual({ allowedHosts: ["authenticationtest.com"], username: "fixture-user", @@ -59,26 +66,33 @@ describe("existing local encrypted vaults", () => { expect(JSON.stringify(await vault.list())).not.toMatch(/fixture-password|fixture-user|JBSWY/); }); - it.each(["base64", "hex"] as const)("uses Bell configuration with a %s master key", async (encoding) => { - const { path } = await fixture(); - vi.stubEnv("BELL_VAULT_FILE", path); - vi.stubEnv("BELL_VAULT_MASTER_KEY", masterKey.toString(encoding)); - await expect(new NativeVault().login("fixture-login")) - .resolves.toMatchObject({ username: "fixture-user", password: "fixture-password" }); - }); + it.each(["base64", "hex"] as const)( + "uses Bell configuration with a %s master key", + async (encoding) => { + const { path } = await fixture(); + vi.stubEnv("BELL_VAULT_FILE", path); + vi.stubEnv("BELL_VAULT_MASTER_KEY", masterKey.toString(encoding)); + await expect(new NativeVault().login("fixture-login")).resolves.toMatchObject({ + username: "fixture-user", + password: "fixture-password", + }); + }, + ); it("rejects a wrong key without exposing cryptographic details", async () => { const { path } = await fixture(); - await expect(new NativeVault(path, Buffer.alloc(32, 9)).login("fixture-login")) - .rejects.toThrow("Could not decrypt the local vault login"); + await expect(new NativeVault(path, Buffer.alloc(32, 9)).login("fixture-login")).rejects.toThrow( + "Could not decrypt the local vault login", + ); }); it("rejects missing or malformed master keys", async () => { const { path } = await fixture(); for (const key of ["", "too-short"]) { vi.stubEnv("BELL_VAULT_MASTER_KEY", key); - await expect(new NativeVault(path).login("fixture-login")) - .rejects.toThrow("Set BELL_VAULT_MASTER_KEY"); + await expect(new NativeVault(path).login("fixture-login")).rejects.toThrow( + "Set BELL_VAULT_MASTER_KEY", + ); } }); @@ -93,15 +107,18 @@ describe("existing local encrypted vaults", () => { await expect(vault.list()).rejects.toThrow("Could not read the local vault"); }); - it.each(["invalid-host", "duplicate-id", "invalid-box"])("rejects %s records", async (failure) => { - const { path, vault } = await fixture(); - const store = JSON.parse(await readFile(path, "utf8")); - if (failure === "invalid-host") store.records[0].allowedHosts = ["https://shop.example/path"]; - if (failure === "duplicate-id") store.records.push(store.records[0]); - if (failure === "invalid-box") store.records[0].payload.iv = "AA=="; - await writeFile(path, JSON.stringify(store)); - await expect(vault.login("fixture-login")).rejects.toThrow("Could not read the local vault"); - }); + it.each(["invalid-host", "duplicate-id", "invalid-box"])( + "rejects %s records", + async (failure) => { + const { path, vault } = await fixture(); + const store = JSON.parse(await readFile(path, "utf8")); + if (failure === "invalid-host") store.records[0].allowedHosts = ["https://shop.example/path"]; + if (failure === "duplicate-id") store.records.push(store.records[0]); + if (failure === "invalid-box") store.records[0].payload.iv = "AA=="; + await writeFile(path, JSON.stringify(store)); + await expect(vault.login("fixture-login")).rejects.toThrow("Could not read the local vault"); + }, + ); it("detects ciphertext tampering", async () => { const { path, vault } = await fixture(); @@ -110,13 +127,17 @@ describe("existing local encrypted vaults", () => { encrypted[0] ^= 1; store.records[0].payload.ciphertext = encrypted.toString("base64"); await writeFile(path, JSON.stringify(store)); - await expect(vault.login("fixture-login")).rejects.toThrow("Could not decrypt the local vault login"); + await expect(vault.login("fixture-login")).rejects.toThrow( + "Could not decrypt the local vault login", + ); }); it("rejects malformed decrypted payloads without exposing their contents", async () => { const { path, vault } = await fixture(); await replacePayload(path, { password: "private-payload" }); - await expect(vault.login("fixture-login")).rejects.toThrow("Could not decrypt the local vault login"); + await expect(vault.login("fixture-login")).rejects.toThrow( + "Could not decrypt the local vault login", + ); }); it("treats a not-yet-created local vault as empty", async () => { @@ -128,9 +149,15 @@ describe("existing local encrypted vaults", () => { it("preserves password login when an invalid OTP needs human handoff", async () => { const { path, vault } = await fixture(); - await replacePayload(path, { username: "fixture-user", password: "fixture-password", totpSecret: "invalid!" }); + await replacePayload(path, { + username: "fixture-user", + password: "fixture-password", + totpSecret: "invalid!", + }); await expect(vault.login("fixture-login")).resolves.toEqual({ - allowedHosts: ["authenticationtest.com"], username: "fixture-user", password: "fixture-password", + allowedHosts: ["authenticationtest.com"], + username: "fixture-user", + password: "fixture-password", }); }); diff --git a/tests/fixtures/onepassword-extension/manifest.json b/tests/fixtures/onepassword-extension/manifest.json index 3823db5..9486b5b 100644 --- a/tests/fixtures/onepassword-extension/manifest.json +++ b/tests/fixtures/onepassword-extension/manifest.json @@ -1 +1,9 @@ -{"manifest_version":3,"name":"Bell synthetic Autofill test fixture","short_name":"1Password","version":"8.12.46.6","background":{"service_worker":"worker.js"},"permissions":["tabs","scripting"],"host_permissions":["https://login.bell-test.example/*"]} +{ + "manifest_version": 3, + "name": "Bell synthetic Autofill test fixture", + "short_name": "1Password", + "version": "8.12.46.6", + "background": { "service_worker": "worker.js" }, + "permissions": ["tabs", "scripting"], + "host_permissions": ["https://login.bell-test.example/*"] +} diff --git a/tests/fixtures/onepassword-extension/worker.js b/tests/fixtures/onepassword-extension/worker.js index b21f432..f8f89d4 100644 --- a/tests/fixtures/onepassword-extension/worker.js +++ b/tests/fixtures/onepassword-extension/worker.js @@ -1,21 +1,41 @@ -const result = value => ({ success: true, result: value }); -const valid = args => args.accessToken === "synthetic-access" && args.integrationKey === "synthetic-key"; +const result = (value) => ({ success: true, result: value }); +const valid = (args) => + args.accessToken === "synthetic-access" && args.integrationKey === "synthetic-key"; globalThis.api = { initialization: { v1: { whenSettled: async () => "ready" } }, agenticMode: { v1: { enable: async () => result({}) } }, - agenticAutofill: { v1: { - createAccessRequest: async args => valid(args) ? result({ appLink: "https://approval.bell-test.example/approve", accessRequest: { id: "synthetic-request", entries: [{ id: "synthetic-entry" }] } }) : { success: false, error: { code: "invalidToken" } }, - getAccessRequestStatus: async args => valid(args) ? result({ state: "resolved", resolved: [{ entryId: "synthetic-entry", reference: "synthetic-reference" }] }) : { success: false, error: { code: "invalidToken" } }, - fillCredential: async args => { - if (!valid(args) || args.resourcePath !== "synthetic-reference") return { success: false, error: { code: "invalidGrant" } }; - const tab = await chrome.tabs.get(args.tabId); - if (tab.url !== "https://login.bell-test.example/login") return { success: false, error: { code: "wrongOrigin" } }; - await chrome.scripting.executeScript({ target: { tabId: args.tabId }, func: () => { - document.querySelector("#username").value = "fixture-user"; - document.querySelector("#password").value = "fixture-password"; - document.querySelector("form").requestSubmit(); - } }); - return result({ status: "fill_submitted" }); + agenticAutofill: { + v1: { + createAccessRequest: async (args) => + valid(args) + ? result({ + appLink: "https://approval.bell-test.example/approve", + accessRequest: { id: "synthetic-request", entries: [{ id: "synthetic-entry" }] }, + }) + : { success: false, error: { code: "invalidToken" } }, + getAccessRequestStatus: async (args) => + valid(args) + ? result({ + state: "resolved", + resolved: [{ entryId: "synthetic-entry", reference: "synthetic-reference" }], + }) + : { success: false, error: { code: "invalidToken" } }, + fillCredential: async (args) => { + if (!valid(args) || args.resourcePath !== "synthetic-reference") + return { success: false, error: { code: "invalidGrant" } }; + const tab = await chrome.tabs.get(args.tabId); + if (tab.url !== "https://login.bell-test.example/login") + return { success: false, error: { code: "wrongOrigin" } }; + await chrome.scripting.executeScript({ + target: { tabId: args.tabId }, + func: () => { + document.querySelector("#username").value = "fixture-user"; + document.querySelector("#password").value = "fixture-password"; + document.querySelector("form").requestSubmit(); + }, + }); + return result({ status: "fill_submitted" }); + }, }, - } }, + }, }; diff --git a/tests/link-checkout-browser.test.ts b/tests/link-checkout-browser.test.ts index d539519..f0e4ffe 100644 --- a/tests/link-checkout-browser.test.ts +++ b/tests/link-checkout-browser.test.ts @@ -8,67 +8,156 @@ describe.skipIf(process.env.TEST_LINK_BROWSER !== "1")("Link checkout in Chrome" let browser: Awaited>, driver: Browser, page: Page; const origins = ["https://merchant.invalid", "https://payments.invalid"]; const card = { card: { number: "4242424242424242", cvc: "918", exp_month: 12, exp_year: 2040 } }; - const input: CheckoutInput = { id: "spend_test", amount: 100, currency: "usd", credential: "card", fields: [{ field: "number", selector: "#number" }, { field: "cvc", selector: "#cvc" }], submit: { selector: "#pay" }, confirmation: { selector: "#receipt" } }; - const form = ''; + const input: CheckoutInput = { + id: "spend_test", + amount: 100, + currency: "usd", + credential: "card", + fields: [ + { field: "number", selector: "#number" }, + { field: "cvc", selector: "#cvc" }, + ], + submit: { selector: "#pay" }, + confirmation: { selector: "#receipt" }, + }; + const form = + ''; const signal = () => new AbortController().signal; beforeAll(async () => { - browser = await localBrowser.launch({ executablePath: process.env.TEST_CHROME_PATH ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", port: 9357, headless: true }); + browser = await localBrowser.launch({ + executablePath: + process.env.TEST_CHROME_PATH ?? + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + port: 9357, + headless: true, + }); driver = await chromium.connectOverCDP("http://127.0.0.1:9357"); const context = driver.contexts()[0]; - await context.route("**/*", route => { + await context.route("**/*", (route) => { const url = new URL(route.request().url()); if (![...origins, "https://untrusted.invalid"].includes(url.origin)) return route.abort(); - return route.fulfill({ contentType: "text/html", body: url.pathname === "/framed" ? '' : form }); + return route.fulfill({ + contentType: "text/html", + body: + url.pathname === "/framed" + ? '' + : form, + }); }); - page = context.pages().find(candidate => !candidate.url().startsWith("chrome-extension:")) ?? await context.newPage(); + page = + context.pages().find((candidate) => !candidate.url().startsWith("chrome-extension:")) ?? + (await context.newPage()); }, 30_000); - afterAll(async () => { await driver?.close(); await browser?.close(); }); - beforeEach(async () => { await page.goto(`${origins[0]}/form`); }); + afterAll(async () => { + await driver?.close(); + await browser?.close(); + }); + beforeEach(async () => { + await page.goto(`${origins[0]}/form`); + }); it("submits once and returns only safe status while the browser stays quarantined", async () => { const quarantine = vi.fn().mockResolvedValue(undefined); - const result = await submitPayment(page, input, origins, async () => card, signal(), quarantine); + const result = await submitPayment( + page, + input, + origins, + async () => card, + signal(), + quarantine, + ); expect(result.status).toBe("merchant_confirmation_observed"); expect(JSON.stringify(result)).not.toContain(card.card.number); - expect(await page.evaluate(() => Reflect.get(window, "orders"))).toEqual([{ number: card.card.number, cvc: card.card.cvc }]); + expect(await page.evaluate(() => Reflect.get(window, "orders"))).toEqual([ + { number: card.card.number, cvc: card.card.cvc }, + ]); expect(await page.locator("#number").inputValue()).toBe(""); expect(quarantine).toHaveBeenCalledExactlyOnceWith(); }); it("supports explicitly trusted provider frames and rejects untrusted ones before retrieval", async () => { await page.goto(`${origins[0]}/framed`); await page.frameLocator("#payment").locator("#number").waitFor(); - const framed = { ...input, fields: input.fields.map(field => ({ ...field, frame: "#payment" })), submit: { ...input.submit, frame: "#payment" }, confirmation: { selector: "#receipt", frame: "#payment" } }; + const framed = { + ...input, + fields: input.fields.map((field) => ({ ...field, frame: "#payment" })), + submit: { ...input.submit, frame: "#payment" }, + confirmation: { selector: "#receipt", frame: "#payment" }, + }; const get = vi.fn(async () => card); - await expect(submitPayment(page, framed, [origins[0]], get, signal(), async () => {})).rejects.toThrow("Unapproved payment frame"); + await expect( + submitPayment(page, framed, [origins[0]], get, signal(), async () => {}), + ).rejects.toThrow("Unapproved payment frame"); expect(get).not.toHaveBeenCalled(); - expect((await submitPayment(page, framed, origins, get, signal(), async () => {})).status).toBe("merchant_confirmation_observed"); + expect((await submitPayment(page, framed, origins, get, signal(), async () => {})).status).toBe( + "merchant_confirmation_observed", + ); }); it("does not resolve stale selectors on a replacement page", async () => { - const get = async () => { await page.goto("https://untrusted.invalid/form"); return card; }; - await expect(submitPayment(page, input, origins, get, signal(), async () => {})).rejects.toThrow("stopped before submission"); + const get = async () => { + await page.goto("https://untrusted.invalid/form"); + return card; + }; + await expect( + submitPayment(page, input, origins, get, signal(), async () => {}), + ).rejects.toThrow("stopped before submission"); expect(await page.locator("#number").inputValue()).toBe(""); expect(await page.evaluate(() => Reflect.get(window, "orders"))).toBeUndefined(); }); it("rejects ambiguous fields before retrieving credentials", async () => { await page.evaluate(() => document.body.append(document.querySelector("#number")!.cloneNode())); const get = vi.fn(async () => card); - await expect(submitPayment(page, input, origins, get, signal(), async () => {})).rejects.toThrow("ambiguous"); + await expect( + submitPayment(page, input, origins, get, signal(), async () => {}), + ).rejects.toThrow("ambiguous"); expect(get).not.toHaveBeenCalled(); }); it("checks cancellation after retrieval and writes nothing", async () => { - const controller = new AbortController(), quarantine = vi.fn(); - await expect(submitPayment(page, input, origins, async () => { controller.abort(); return card; }, controller.signal, quarantine)).rejects.toThrow(); + const controller = new AbortController(), + quarantine = vi.fn(); + await expect( + submitPayment( + page, + input, + origins, + async () => { + controller.abort(); + return card; + }, + controller.signal, + quarantine, + ), + ).rejects.toThrow(); expect(quarantine).not.toHaveBeenCalled(); expect(await page.locator("#number").inputValue()).toBe(""); }); it("refuses to fill if persistent quarantine cannot be recorded", async () => { - await expect(submitPayment(page, input, origins, async () => card, signal(), async () => { throw new Error("database unavailable"); })).rejects.toThrow("database unavailable"); + await expect( + submitPayment( + page, + input, + origins, + async () => card, + signal(), + async () => { + throw new Error("database unavailable"); + }, + ), + ).rejects.toThrow("database unavailable"); expect(await page.locator("#number").inputValue()).toBe(""); }); it("keeps quarantine after navigation and does not duplicate submission", async () => { - await page.locator("#pay").evaluate(node => node.setAttribute("onclick", "location.href='/receipt'")); + await page + .locator("#pay") + .evaluate((node) => node.setAttribute("onclick", "location.href='/receipt'")); const quarantine = vi.fn().mockResolvedValue(undefined); - const result = await submitPayment(page, { ...input, confirmation: undefined }, origins, async () => card, signal(), quarantine); + const result = await submitPayment( + page, + { ...input, confirmation: undefined }, + origins, + async () => card, + signal(), + quarantine, + ); expect(result.status).toBe("submission_attempted"); await page.waitForURL("**/receipt"); expect(quarantine).toHaveBeenCalledTimes(1); diff --git a/tests/link-checkout-tool.test.ts b/tests/link-checkout-tool.test.ts index c18b621..d31857a 100644 --- a/tests/link-checkout-tool.test.ts +++ b/tests/link-checkout-tool.test.ts @@ -3,28 +3,92 @@ import { retrieve_spend_request } from "@stripe/link-integrations-eve/tools"; import checkout from "../agent/extensions/link/tools/checkout"; import { checkoutInHostedPage } from "../server/link/checkout-browser"; import { runBrowserOperation } from "../agent/lib/browser-runtime"; -vi.mock("../agent/lib/link-auth", () => ({ linkPrincipal: (ctx: { authorized?: boolean }) => { if (!ctx.authorized) throw new Error("principal_required"); }, freshLinkContext: (ctx: unknown) => ctx })); -vi.mock("../agent/lib/browser-runtime", () => ({ runCancelableBrowserWork: (_id: string, _signal: AbortSignal, operation: () => unknown) => operation(), runBrowserOperation: vi.fn(async (_id, operation) => ({ result: await operation({ withHostedPage: (_signal: AbortSignal, call: (page: unknown, url: string) => unknown) => call({}, "private-connection") }), workbench: { private: "not-returned" } })) })); +vi.mock("../agent/lib/link-auth", () => ({ + linkPrincipal: (ctx: { authorized?: boolean }) => { + if (!ctx.authorized) throw new Error("principal_required"); + }, + freshLinkContext: (ctx: unknown) => ctx, +})); +vi.mock("../agent/lib/browser-runtime", () => ({ + runCancelableBrowserWork: (_id: string, _signal: AbortSignal, operation: () => unknown) => + operation(), + runBrowserOperation: vi.fn(async (_id, operation) => ({ + result: await operation({ + withHostedPage: (_signal: AbortSignal, call: (page: unknown, url: string) => unknown) => + call({}, "private-connection"), + }), + workbench: { private: "not-returned" }, + })), +})); vi.mock("../server/link/checkout-browser", () => ({ checkoutInHostedPage: vi.fn() })); -const input = { id: "spend_fixture", amount: 100, currency: "usd", credential: "card" as const, fields: [{ field: "number" as const, selector: "#card" }], submit: { selector: "#pay" } }; -const ctx = { authorized: true, session: { id: "synthetic-session" }, abortSignal: new AbortController().signal }; -const approved = { id: input.id, status: "approved", amount: 100, currency: "usd", merchant_url: "https://merchant.example", created_at: "now", updated_at: "now" }; +const input = { + id: "spend_fixture", + amount: 100, + currency: "usd", + credential: "card" as const, + fields: [{ field: "number" as const, selector: "#card" }], + submit: { selector: "#pay" }, +}; +const ctx = { + authorized: true, + session: { id: "synthetic-session" }, + abortSignal: new AbortController().signal, +}; +const approved = { + id: input.id, + status: "approved", + amount: 100, + currency: "usd", + merchant_url: "https://merchant.example", + created_at: "now", + updated_at: "now", +}; afterEach(() => vi.restoreAllMocks()); describe("Link checkout tool", () => { it("rechecks approval at private retrieval and returns no credentials or browser workbench", async () => { - const native = vi.spyOn(retrieve_spend_request, "execute").mockResolvedValueOnce(approved as never).mockResolvedValueOnce({ ...approved, card: { number: "synthetic-private" } } as never); - vi.mocked(checkoutInHostedPage).mockImplementation(async (_page, _url, _input, _origins, get) => { expect(await get()).toHaveProperty("card.number", "synthetic-private"); return { id: input.id, status: "submission_attempted", verify_payment: true }; }); - expect(await checkout.execute(input, ctx as never)).toEqual({ id: input.id, status: "submission_attempted", verify_payment: true }); - expect(native.mock.calls.map(call => call[0])).toEqual([{ id: input.id }, { id: input.id, include: ["card"] }]); + const native = vi + .spyOn(retrieve_spend_request, "execute") + .mockResolvedValueOnce(approved as never) + .mockResolvedValueOnce({ ...approved, card: { number: "synthetic-private" } } as never); + vi.mocked(checkoutInHostedPage).mockImplementation( + async (_page, _url, _input, _origins, get) => { + expect(await get()).toHaveProperty("card.number", "synthetic-private"); + return { id: input.id, status: "submission_attempted", verify_payment: true }; + }, + ); + expect(await checkout.execute(input, ctx as never)).toEqual({ + id: input.id, + status: "submission_attempted", + verify_payment: true, + }); + expect(native.mock.calls.map((call) => call[0])).toEqual([ + { id: input.id }, + { id: input.id, include: ["card"] }, + ]); }); it("stops when approval changes before credentials can be used", async () => { - vi.spyOn(retrieve_spend_request, "execute").mockResolvedValueOnce(approved as never).mockResolvedValueOnce({ ...approved, status: "canceled", card: { number: "synthetic-private" } } as never); - vi.mocked(checkoutInHostedPage).mockImplementation(async (_page, _url, _input, _origins, get) => { await get(); throw new Error("must not reach submission"); }); + vi.spyOn(retrieve_spend_request, "execute") + .mockResolvedValueOnce(approved as never) + .mockResolvedValueOnce({ + ...approved, + status: "canceled", + card: { number: "synthetic-private" }, + } as never); + vi.mocked(checkoutInHostedPage).mockImplementation( + async (_page, _url, _input, _origins, get) => { + await get(); + throw new Error("must not reach submission"); + }, + ); await expect(checkout.execute(input, ctx as never)).rejects.toThrow("approval"); }); it("rejects an unauthenticated sender or unapproved request before browser work", async () => { - await expect(checkout.execute(input, { ...ctx, authorized: false } as never)).rejects.toThrow("principal_required"); - const native = vi.spyOn(retrieve_spend_request, "execute").mockResolvedValue({ ...approved, status: "pending_approval" } as never); + await expect(checkout.execute(input, { ...ctx, authorized: false } as never)).rejects.toThrow( + "principal_required", + ); + const native = vi + .spyOn(retrieve_spend_request, "execute") + .mockResolvedValue({ ...approved, status: "pending_approval" } as never); vi.mocked(runBrowserOperation).mockClear(); await expect(checkout.execute(input, ctx as never)).rejects.toThrow("approval"); expect(native).toHaveBeenCalledTimes(1); diff --git a/tests/link-checkout.test.ts b/tests/link-checkout.test.ts index 43c1373..07d9fe6 100644 --- a/tests/link-checkout.test.ts +++ b/tests/link-checkout.test.ts @@ -8,16 +8,29 @@ import approval from "../agent/extensions/link/tools/request_spend_approval"; import list from "../agent/extensions/link/tools/list_spend_requests"; import disconnect from "../agent/tools/link_disconnect"; -const context = { session: { id: "linq-chat-session" }, abortSignal: new AbortController().signal } as never; +const context = { + session: { id: "linq-chat-session" }, + abortSignal: new AbortController().signal, +} as never; const purchase = { - credential_type: "card" as const, amount: 4200, currency: "usd", - request_approval: true, test: false, - merchant_name: "Example", merchant_url: "https://shop.example", - context: "The user requested this notebook from Example Shop. This amount includes the agreed items, shipping, and taxes.", + credential_type: "card" as const, + amount: 4200, + currency: "usd", + request_approval: true, + test: false, + merchant_name: "Example", + merchant_url: "https://shop.example", + context: + "The user requested this notebook from Example Shop. This amount includes the agreed items, shipping, and taxes.", }; const metadata = { - id: "spend_1", status: "approved", amount: 4200, currency: "usd", - approval_url: "https://link.example/approval", created_at: "2026-10-22T00:00:00Z", updated_at: "2026-10-22T00:00:00Z", + id: "spend_1", + status: "approved", + amount: 4200, + currency: "usd", + approval_url: "https://link.example/approval", + created_at: "2026-10-22T00:00:00Z", + updated_at: "2026-10-22T00:00:00Z", }; const secrets = { card: { number: "synthetic-card-number", cvc: "synthetic-cvc" }, @@ -27,15 +40,22 @@ const secrets = { }; const purchaseHistory = { ...metadata, - merchant_name: "Example", merchant_url: "https://shop.example", + merchant_name: "Example", + merchant_url: "https://shop.example", context: "The requested monthly notebook delivery, including shipping and tax.", - line_items: [{ - name: "Notebook", url: "https://shop.example/notebook", - image_url: "https://shop.example/notebook.png", description: "Blue notebook", - sku: "notebook-blue", quantity: 1, unit_amount: 4000, - product_url: "https://shop.example/notebook", - totals: [{ type: "total", display_text: "Item total", amount: 4000 }], - }], + line_items: [ + { + name: "Notebook", + url: "https://shop.example/notebook", + image_url: "https://shop.example/notebook.png", + description: "Blue notebook", + sku: "notebook-blue", + quantity: 1, + unit_amount: 4000, + product_url: "https://shop.example/notebook", + totals: [{ type: "total", display_text: "Item total", amount: 4000 }], + }, + ], totals: [ { type: "subtotal", display_text: "Subtotal", amount: 4000 }, { type: "tax", display_text: "Tax", amount: 200 }, @@ -45,13 +65,20 @@ const purchaseHistory = { expires_at: 1795305600, recurring: { interval: "month", interval_count: 1 }, payment_status_details: { - outcome: "success", code: null, decline_code: null, - amount: 4200, currency: "usd", created: 1792627200, + outcome: "success", + code: null, + decline_code: null, + amount: 4200, + currency: "usd", + created: 1792627200, refund_details: { amount: 1000, currency: "usd", state: "succeeded", created: 1792713600 }, }, } satisfies Exclude>, null>; -afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); describe("Link payment boundary", () => { it("retains spend confirmation and requires confirmation to disconnect", () => { @@ -60,32 +87,56 @@ describe("Link payment boundary", () => { expect(disconnect.approval).toBeDefined(); }); - it.each([ - ["card"], ["shared_payment_token"], ["link_pay_token"], [], ["future_credential"], - ])("rejects credential includes before calling Link: %j", async (...include) => { - const execute = vi.spyOn(native.retrieve_spend_request, "execute"); - await expect(retrieve.execute({ id: "spend_1", include } as never, context)).rejects.toThrow(); - expect(execute).not.toHaveBeenCalled(); - }); + it.each([["card"], ["shared_payment_token"], ["link_pay_token"], [], ["future_credential"]])( + "rejects credential includes before calling Link: %j", + async (...include) => { + const execute = vi.spyOn(native.retrieve_spend_request, "execute"); + await expect( + retrieve.execute({ id: "spend_1", include } as never, context), + ).rejects.toThrow(); + expect(execute).not.toHaveBeenCalled(); + }, + ); it("requests only status and removes unexpected credential fields before returning", async () => { - const execute = vi.spyOn(native.retrieve_spend_request, "execute") + const execute = vi + .spyOn(native.retrieve_spend_request, "execute") .mockResolvedValue({ ...metadata, ...secrets } as never); await expect(retrieve.execute({ id: "spend_1" }, context)).resolves.toEqual(metadata); - expect(execute).toHaveBeenCalledExactlyOnceWith({ id: "spend_1" }, expect.objectContaining(context)); + expect(execute).toHaveBeenCalledExactlyOnceWith( + { id: "spend_1" }, + expect.objectContaining(context), + ); }); it("preserves missing requests and approval-required metadata", async () => { - const nextAction = { type: "contact_support", display_message: "Contact support", action_url: null, resolution: "auto_resume", expires_at: null }; + const nextAction = { + type: "contact_support", + display_message: "Contact support", + action_url: null, + resolution: "auto_resume", + expires_at: null, + }; const status_details = { requires_action: { next_action: nextAction } }; - const execute = vi.spyOn(native.retrieve_spend_request, "execute") + const execute = vi + .spyOn(native.retrieve_spend_request, "execute") .mockResolvedValueOnce(null) .mockResolvedValueOnce({ - ...metadata, status: "requires_action", ...secrets, - status_details: { ...status_details, ...secrets, requires_action: { next_action: { ...nextAction, ...secrets }, ...secrets } }, + ...metadata, + status: "requires_action", + ...secrets, + status_details: { + ...status_details, + ...secrets, + requires_action: { next_action: { ...nextAction, ...secrets }, ...secrets }, + }, } as never); await expect(retrieve.execute({ id: "spend_1" }, context)).resolves.toBeNull(); - await expect(retrieve.execute({ id: "spend_1" }, context)).resolves.toEqual({ ...metadata, status: "requires_action", status_details }); + await expect(retrieve.execute({ id: "spend_1" }, context)).resolves.toEqual({ + ...metadata, + status: "requires_action", + status_details, + }); expect(execute).toHaveBeenCalledTimes(2); }); @@ -93,7 +144,9 @@ describe("Link payment boundary", () => { vi.spyOn(native.retrieve_spend_request, "execute").mockResolvedValue(purchaseHistory); vi.spyOn(native.list_spend_requests, "execute").mockResolvedValue([purchaseHistory]); await expect(retrieve.execute({ id: "spend_1" }, context)).resolves.toEqual(purchaseHistory); - await expect(list.execute({ include_history: true }, context)).resolves.toEqual([purchaseHistory]); + await expect(list.execute({ include_history: true }, context)).resolves.toEqual([ + purchaseHistory, + ]); }); it("keeps recovery details while removing unknown and credential fields at every nested level", async () => { @@ -104,30 +157,37 @@ describe("Link payment boundary", () => { requires_action: { failure_code: "authentication_required", next_action: { - type: "re_authorize", display_message: "Authorize this payment", - action_url: "https://link.example/action", resolution: "create_new_spend_request_after_completion", + type: "re_authorize", + display_message: "Authorize this payment", + action_url: "https://link.example/action", + resolution: "create_new_spend_request_after_completion", expires_at: null, }, }, }, } satisfies Exclude>, null>; const response = { - ...expected, ...secrets, + ...expected, + ...secrets, card: { ...secrets.card, id: "card_synthetic", brand: "visa", exp_month: 12, exp_year: 2030 }, metadata: { hidden_credential: "synthetic-secret" }, line_items: expected.line_items.map((item) => ({ - ...item, ...secrets, totals: item.totals.map((total) => ({ ...total, ...secrets })), + ...item, + ...secrets, + totals: item.totals.map((total) => ({ ...total, ...secrets })), })), totals: expected.totals.map((total) => ({ ...total, ...secrets })), recurring: { ...expected.recurring, ...secrets }, payment_status_details: { - ...expected.payment_status_details, ...secrets, + ...expected.payment_status_details, + ...secrets, refund_details: { ...expected.payment_status_details.refund_details, ...secrets }, }, status_details: { ...secrets, requires_action: { - ...expected.status_details.requires_action, ...secrets, + ...expected.status_details.requires_action, + ...secrets, next_action: { ...expected.status_details.requires_action.next_action, ...secrets }, }, }, @@ -137,25 +197,47 @@ describe("Link payment boundary", () => { }); it("filters credentials from creation, mutation, approval, and list responses", async () => { - vi.spyOn(native.create_spend_request, "execute").mockResolvedValue({ ...metadata, ...secrets } as never); - vi.spyOn(native.update_spend_request, "execute").mockResolvedValue({ ...metadata, ...secrets } as never); - vi.spyOn(native.cancel_spend_request, "execute").mockResolvedValue({ ...metadata, ...secrets } as never); - vi.spyOn(native.request_spend_approval, "execute").mockResolvedValue({ ...metadata, ...secrets } as never); - vi.spyOn(native.list_spend_requests, "execute").mockResolvedValue([{ ...metadata, ...secrets }] as never); + vi.spyOn(native.create_spend_request, "execute").mockResolvedValue({ + ...metadata, + ...secrets, + } as never); + vi.spyOn(native.update_spend_request, "execute").mockResolvedValue({ + ...metadata, + ...secrets, + } as never); + vi.spyOn(native.cancel_spend_request, "execute").mockResolvedValue({ + ...metadata, + ...secrets, + } as never); + vi.spyOn(native.request_spend_approval, "execute").mockResolvedValue({ + ...metadata, + ...secrets, + } as never); + vi.spyOn(native.list_spend_requests, "execute").mockResolvedValue([ + { ...metadata, ...secrets }, + ] as never); await expect(create.execute(purchase, context)).resolves.toEqual(metadata); - await expect(update.execute({ id: "spend_1", amount: 4200 }, context)).resolves.toEqual(metadata); + await expect(update.execute({ id: "spend_1", amount: 4200 }, context)).resolves.toEqual( + metadata, + ); await expect(cancel.execute({ id: "spend_1" }, context)).resolves.toEqual(metadata); - await expect(approval.execute({ id: "spend_1" }, context)).resolves.toEqual({ id: "spend_1", approval_url: metadata.approval_url }); + await expect(approval.execute({ id: "spend_1" }, context)).resolves.toEqual({ + id: "spend_1", + approval_url: metadata.approval_url, + }); await expect(list.execute({}, context)).resolves.toEqual([metadata]); }); it("rejects malformed responses without including provider data in the error", async () => { vi.spyOn(native.retrieve_spend_request, "execute").mockResolvedValue(secrets as never); - await expect(retrieve.execute({ id: "spend_1" }, context)).rejects.toThrow("Unexpected Link spend-request response."); + await expect(retrieve.execute({ id: "spend_1" }, context)).rejects.toThrow( + "Unexpected Link spend-request response.", + ); }); it.each(["LinkApiError", "LinkResponseError", "LinkTransportError", "LinkConfigurationError"])( - "withholds provider bodies in %s failures", async (name) => { + "withholds provider bodies in %s failures", + async (name) => { const error = Object.assign(new Error(JSON.stringify(secrets)), { name, status: 400 }); vi.spyOn(native.retrieve_spend_request, "execute").mockRejectedValue(error); await expect(retrieve.execute({ id: "spend_1" }, context)).rejects.toThrow( @@ -171,11 +253,21 @@ describe("Link payment boundary", () => { }); it.each([ - "", "spend_1/update_delegated", "../approval_policies/policy_1", "spend_1?include=card#", - "spend_1#card", "spend_1%3Finclude%3Dcard", "spend_1\\cancel", "a".repeat(256), + "", + "spend_1/update_delegated", + "../approval_policies/policy_1", + "spend_1?include=card#", + "spend_1#card", + "spend_1%3Finclude%3Dcard", + "spend_1\\cancel", + "a".repeat(256), ])("rejects unsafe IDs in every resource-specific wrapper: %s", async (id) => { - const mocks = [native.retrieve_spend_request, native.update_spend_request, native.cancel_spend_request, native.request_spend_approval] - .map((tool) => vi.spyOn(tool, "execute")); + const mocks = [ + native.retrieve_spend_request, + native.update_spend_request, + native.cancel_spend_request, + native.request_spend_approval, + ].map((tool) => vi.spyOn(tool, "execute")); await expect(retrieve.execute({ id }, context)).rejects.toThrow(); await expect(update.execute({ id, amount: 4200 }, context)).rejects.toThrow(); await expect(cancel.execute({ id }, context)).rejects.toThrow(); diff --git a/tests/link-config.test.ts b/tests/link-config.test.ts index ec3be6e..07505fa 100644 --- a/tests/link-config.test.ts +++ b/tests/link-config.test.ts @@ -29,38 +29,78 @@ describe("Link credential boundary", () => { { type: "user" as const, id: "", issuer: "linq" }, ]) { expect(() => linkOwner(principal)).toThrow(); - await expect(linkAuth.getToken({ principal, connection: { url: "" } })).rejects.toMatchObject({ reason: "principal_required" }); + await expect(linkAuth.getToken({ principal, connection: { url: "" } })).rejects.toMatchObject( + { reason: "principal_required" }, + ); } - expect(linkOwner({ type: "user", issuer: "linq", id: "linq:sender" })).toEqual({ issuer: "linq", id: "linq:sender" }); + expect(linkOwner({ type: "user", issuer: "linq", id: "linq:sender" })).toEqual({ + issuer: "linq", + id: "linq:sender", + }); }); it("does not expose arbitrary database or transport errors", async () => { const log = vi.spyOn(console, "error").mockImplementation(() => {}); - const call = safeLinkCall(async () => { throw new Error("postgres://user:secret@host"); }); + const call = safeLinkCall(async () => { + throw new Error("postgres://user:secret@host"); + }); await expect(call).rejects.toMatchObject({ reason: "link_unavailable", retryable: false }); await expect(call).rejects.not.toThrow("secret"); - expect(log).toHaveBeenCalledWith(JSON.stringify({ event: "link.error", reason: "unexpected_error" })); + expect(log).toHaveBeenCalledWith( + JSON.stringify({ event: "link.error", reason: "unexpected_error" }), + ); }); it.each([ - [new LinkSetupError("missing_configuration", "DATABASE_URL"), "missing_configuration", "DATABASE_URL"], - [new LinkSetupError("credentials_unreadable", "LINK_TOKEN_ENCRYPTION_KEY"), "credentials_unreadable", "LINK_TOKEN_ENCRYPTION_KEY"], - [Object.assign(new Error("private SQL content"), { code: "42P01" }), "database_migration_required", undefined], - [Object.assign(new Error("postgres://user:secret@host"), { code: "ECONNREFUSED" }), "database_unavailable", undefined], + [ + new LinkSetupError("missing_configuration", "DATABASE_URL"), + "missing_configuration", + "DATABASE_URL", + ], + [ + new LinkSetupError("credentials_unreadable", "LINK_TOKEN_ENCRYPTION_KEY"), + "credentials_unreadable", + "LINK_TOKEN_ENCRYPTION_KEY", + ], + [ + Object.assign(new Error("private SQL content"), { code: "42P01" }), + "database_migration_required", + undefined, + ], + [ + Object.assign(new Error("postgres://user:secret@host"), { code: "ECONNREFUSED" }), + "database_unavailable", + undefined, + ], ])("reports only safe operator diagnostics", async (error, reason, setting) => { const log = vi.spyOn(console, "error").mockImplementation(() => {}); - await expect(safeLinkCall(async () => { throw error; })).rejects.toMatchObject({ reason: "link_unavailable" }); - expect(log).toHaveBeenCalledWith(JSON.stringify({ event: "link.error", reason, ...(setting ? { setting } : {}) })); + await expect( + safeLinkCall(async () => { + throw error; + }), + ).rejects.toMatchObject({ reason: "link_unavailable" }); + expect(log).toHaveBeenCalledWith( + JSON.stringify({ event: "link.error", reason, ...(setting ? { setting } : {}) }), + ); expect(JSON.stringify(log.mock.calls)).not.toMatch(/secret|private SQL/); }); it("requires an exact public origin and a 256-bit server key", () => { - for (const name of ["DATABASE_URL", "LINK_CLIENT_ID", "LINK_CLIENT_SECRET", "STRIPE_PUBLISHABLE_KEY"]) + for (const name of [ + "DATABASE_URL", + "LINK_CLIENT_ID", + "LINK_CLIENT_SECRET", + "STRIPE_PUBLISHABLE_KEY", + ]) vi.stubEnv(name, "test-value"); vi.stubEnv("BELL_PUBLIC_URL", "http://127.0.0.1:4320"); vi.stubEnv("LINK_TOKEN_ENCRYPTION_KEY", randomBytes(32).toString("base64")); expect(linkConfig().redirectUri).toBe("http://127.0.0.1:4320/api/link/oauth/callback"); - for (const value of ["http://bell.example", "https://bell.example/path", "https://user:pass@bell.example"] ) { + for (const value of [ + "http://bell.example", + "https://bell.example/path", + "https://user:pass@bell.example", + ]) { vi.stubEnv("BELL_PUBLIC_URL", value); expect(() => linkConfig()).toThrow(); } diff --git a/tests/link-oauth.test.ts b/tests/link-oauth.test.ts index ea202df..12e70bf 100644 --- a/tests/link-oauth.test.ts +++ b/tests/link-oauth.test.ts @@ -8,9 +8,16 @@ import { LinkOAuth } from "../server/link/oauth"; import { LinkStore, OwnerStore } from "../server/link/store"; import { linkCallback } from "../server/link/callback"; import { createLinkAuth, freshLinkContext } from "../agent/lib/link-auth"; -import { ContextContainer, contextStorage } from "../node_modules/eve/dist/src/context/container.js"; +import { + ContextContainer, + contextStorage, +} from "../node_modules/eve/dist/src/context/container.js"; import { AuthKey, SessionIdKey, SessionKey } from "../node_modules/eve/dist/src/context/keys.js"; -import { CallbackBaseUrlKey, PendingAuthorizationResultKey, isAuthorizationSignal } from "../node_modules/eve/dist/src/harness/authorization.js"; +import { + CallbackBaseUrlKey, + PendingAuthorizationResultKey, + isAuthorizationSignal, +} from "../node_modules/eve/dist/src/harness/authorization.js"; import { createAuthorizationExecution } from "../node_modules/eve/dist/src/runtime/connections/scoped-authorization.js"; import { resolveWorkflowCallbackBaseUrl } from "../node_modules/eve/dist/src/execution/workflow-callback-url.js"; import type { ChannelAdapter } from "../node_modules/eve/dist/src/channel/adapter.js"; @@ -31,15 +38,20 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { let callbackUrl: string; let fetcher: ReturnType>; const tokens = (suffix = "first", scope = "payment_methods.agentic userinfo:read") => ({ - access_token: `access-${suffix}`, refresh_token: `refresh-${suffix}`, - token_type: "Bearer", expires_in: 3600, scope, + access_token: `access-${suffix}`, + refresh_token: `refresh-${suffix}`, + token_type: "Bearer", + expires_in: 3600, + scope, }); beforeAll(async () => { admin = new Pool({ connectionString: databaseUrl }); await admin.query(`CREATE SCHEMA ${schema}`); pool = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}`, max: 6 }); - await pool.query(await readFile(new URL("../migrations/001_link_oauth.sql", import.meta.url), "utf8")); + await pool.query( + await readFile(new URL("../migrations/001_link_oauth.sql", import.meta.url), "utf8"), + ); store = new LinkStore(pool); }); afterAll(async () => { @@ -54,8 +66,13 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { principal = { type: "user", issuer: "linq", id: `linq:${randomUUID()}` }; callbackUrl = `https://bell.example/eve/v1/connections/link/callback/${randomUUID()}/opaque-hook`; config = { - databaseUrl: databaseUrl!, origin: "https://bell.example", redirectUri: "https://bell.example/api/link/oauth/callback", - clientId: "client-id", clientSecret: "client-secret", publishableKey: "pk_test", encryptionKey: randomBytes(32), + databaseUrl: databaseUrl!, + origin: "https://bell.example", + redirectUri: "https://bell.example/api/link/oauth/callback", + clientId: "client-id", + clientSecret: "client-secret", + publishableKey: "pk_test", + encryptionKey: randomBytes(32), }; fetcher = vi.fn().mockResolvedValue(Response.json(tokens())); service = new LinkOAuth(config, store, fetcher, () => now); @@ -64,7 +81,13 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { function eveContext() { const sessionId = `test-session-${randomUUID()}`; - const auth = { principalType: "user" as const, principalId: principal.id, issuer: "linq", authenticator: "linq-message", attributes: {} }; + const auth = { + principalType: "user" as const, + principalId: principal.id, + issuer: "linq", + authenticator: "linq-message", + attributes: {}, + }; const context = new ContextContainer(); context.set(AuthKey, auth); context.set(SessionIdKey, sessionId); @@ -87,7 +110,9 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { } it("uses PKCE and the registered redirect, persists encrypted grants, and survives a restart", async () => { - await expect(service.getToken(principal)).rejects.toMatchObject({ name: "ConnectionAuthorizationRequiredError" }); + await expect(service.getToken(principal)).rejects.toMatchObject({ + name: "ConnectionAuthorizationRequiredError", + }); const { start, url, state } = await begin(); expect(url.origin).toBe("https://login.link.com"); expect(url.searchParams.get("scope")).toBe("payment_methods.agentic userinfo:read"); @@ -96,15 +121,24 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { expect(Object.keys(start.resume)).toEqual(["attemptId"]); expect(JSON.stringify(start)).not.toContain(config.clientSecret); expect(await service.start(principal, callbackUrl)).toEqual(start); - const response = await linkCallback(new Request(`${config.redirectUri}?state=${state}&code=single-use-code`), () => service); + const response = await linkCallback( + new Request(`${config.redirectUri}?state=${state}&code=single-use-code`), + () => service, + ); expect(response.status).toBe(303); expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("referrer-policy")).toBe("no-referrer"); const forwarded = new URL(response.headers.get("location")!); expect(forwarded.origin + forwarded.pathname).toBe(callbackUrl); const restarted = new LinkOAuth(config, new LinkStore(pool), fetcher, () => now); - expect(await restarted.complete(principal, callbackUrl, start.resume, Object.fromEntries(forwarded.searchParams))) - .toEqual({ token: "access-first", expiresAt: now + 3600_000 }); + expect( + await restarted.complete( + principal, + callbackUrl, + start.resume, + Object.fromEntries(forwarded.searchParams), + ), + ).toEqual({ token: "access-first", expiresAt: now + 3600_000 }); const [, request] = fetcher.mock.calls[0]; const form = request!.body as URLSearchParams; expect(hash(form.get("code_verifier")!)).toBe(url.searchParams.get("code_challenge")); @@ -113,7 +147,10 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { expect(request!.redirect).toBe("error"); expect(request!.headers).toMatchObject({ authorization: "Bearer pk_test" }); expect(await restarted.getToken(principal)).toMatchObject({ token: "access-first" }); - const rows = await pool.query("SELECT encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", [principal.id]); + const rows = await pool.query( + "SELECT encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", + [principal.id], + ); expect(JSON.stringify(rows.rows)).not.toMatch(/access-first|refresh-first|client-secret/); const attempt = await store.findAttempt(hash(state)); expect(attempt?.encrypted_secrets).toBeNull(); @@ -125,23 +162,41 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { const params = { state, code: "code" }; await service.forward(params); const other = { ...principal, id: `linq:${randomUUID()}` }; - await expect(service.complete(other, callbackUrl, start.resume, params)).rejects.toMatchObject({ reason: "invalid_state" }); - await expect(service.complete(principal, callbackUrl + "other", start.resume, params)).rejects.toMatchObject({ reason: "invalid_state" }); - await expect(service.complete(principal, callbackUrl, start.resume, { ...params, code: "changed" })).rejects.toMatchObject({ reason: "invalid_state" }); - await expect(service.getToken(other)).rejects.toMatchObject({ name: "ConnectionAuthorizationRequiredError" }); + await expect(service.complete(other, callbackUrl, start.resume, params)).rejects.toMatchObject({ + reason: "invalid_state", + }); + await expect( + service.complete(principal, callbackUrl + "other", start.resume, params), + ).rejects.toMatchObject({ reason: "invalid_state" }); + await expect( + service.complete(principal, callbackUrl, start.resume, { ...params, code: "changed" }), + ).rejects.toMatchObject({ reason: "invalid_state" }); + await expect(service.getToken(other)).rejects.toMatchObject({ + name: "ConnectionAuthorizationRequiredError", + }); expect(fetcher).not.toHaveBeenCalled(); }); it("rejects malformed, unknown, duplicated, or expired callback state", async () => { const { state } = await begin(); - for (const query of ["state=invalid&code=code", `state=${randomBytes(32).toString("base64url")}&code=code`, - `state=${state}&code=code&error=access_denied`, `state=${state}&code=a&code=b`, `state=${state}`]) { - const response = await linkCallback(new Request(`${config.redirectUri}?${query}`), () => service); + for (const query of [ + "state=invalid&code=code", + `state=${randomBytes(32).toString("base64url")}&code=code`, + `state=${state}&code=code&error=access_denied`, + `state=${state}&code=a&code=b`, + `state=${state}`, + ]) { + const response = await linkCallback( + new Request(`${config.redirectUri}?${query}`), + () => service, + ); expect(response.status).toBe(400); expect(await response.text()).not.toContain(state); } now += 10 * 60_000; - await expect(service.forward({ state, code: "code" })).rejects.toMatchObject({ reason: "authorization_expired" }); + await expect(service.forward({ state, code: "code" })).rejects.toMatchObject({ + reason: "authorization_expired", + }); expect(fetcher).not.toHaveBeenCalled(); }); @@ -149,14 +204,20 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { const { start, state } = await begin(); const params = { state, error: "access_denied" }; await service.forward(params); - await expect(service.complete(principal, callbackUrl, start.resume, params)).rejects.toMatchObject({ reason: "access_denied", retryable: false }); - await expect(service.complete(principal, callbackUrl, start.resume, params)).rejects.toMatchObject({ reason: "authorization_expired" }); + await expect( + service.complete(principal, callbackUrl, start.resume, params), + ).rejects.toMatchObject({ reason: "access_denied", retryable: false }); + await expect( + service.complete(principal, callbackUrl, start.resume, params), + ).rejects.toMatchObject({ reason: "authorization_expired" }); expect(fetcher).not.toHaveBeenCalled(); }); it("rejects browser callback replay but permits durable completion replay without another exchange", async () => { const { start, params, result } = await authorize(); - await expect(service.forward(params)).rejects.toMatchObject({ reason: "authorization_expired" }); + await expect(service.forward(params)).rejects.toMatchObject({ + reason: "authorization_expired", + }); expect(await service.complete(principal, callbackUrl, start.resume, params)).toEqual(result); expect(fetcher).toHaveBeenCalledOnce(); }); @@ -167,9 +228,13 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { const target = await service.forward(params); const restarted = new LinkOAuth(config, new LinkStore(pool), fetcher, () => now); expect(await restarted.forward(params)).toBe(target); - await expect(restarted.forward({ state, code: "different" })).rejects.toMatchObject({ reason: "authorization_expired" }); + await expect(restarted.forward({ state, code: "different" })).rejects.toMatchObject({ + reason: "authorization_expired", + }); await restarted.complete(principal, callbackUrl, start.resume, params); - await expect(restarted.forward(params)).rejects.toMatchObject({ reason: "authorization_expired" }); + await expect(restarted.forward(params)).rejects.toMatchObject({ + reason: "authorization_expired", + }); expect(fetcher).toHaveBeenCalledOnce(); }); @@ -178,53 +243,84 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { const params = { state, code: "code" }; await service.forward(params); fetcher.mockRejectedValue(new Error("connection lost with secret data")); - await expect(service.complete(principal, callbackUrl, start.resume, params)).rejects.toMatchObject({ reason: "link_request_failed" }); - await expect(service.complete(principal, callbackUrl, start.resume, params)).rejects.toMatchObject({ reason: "authorization_expired" }); + await expect( + service.complete(principal, callbackUrl, start.resume, params), + ).rejects.toMatchObject({ reason: "link_request_failed" }); + await expect( + service.complete(principal, callbackUrl, start.resume, params), + ).rejects.toMatchObject({ reason: "authorization_expired" }); expect(fetcher).toHaveBeenCalledOnce(); }); - it.each(["bell_link_grants", "bell_link_oauth_attempts"])("keeps authorization blocked if the final %s write fails", async (table) => { - const { start, state } = await begin(); - const params = { state, code: "single-use-code" }; - await service.forward(params); - await pool.query(`CREATE FUNCTION reject_completion() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN + it.each(["bell_link_grants", "bell_link_oauth_attempts"])( + "keeps authorization blocked if the final %s write fails", + async (table) => { + const { start, state } = await begin(); + const params = { state, code: "single-use-code" }; + await service.forward(params); + await pool.query(`CREATE FUNCTION reject_completion() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.status IN ('active', 'completed') THEN RAISE EXCEPTION 'completion write failed'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_completion BEFORE UPDATE ON ${table} FOR EACH ROW EXECUTE FUNCTION reject_completion();`); - try { - await expect(service.complete(principal, callbackUrl, start.resume, params)).rejects.toThrow("completion write failed"); - } finally { - await pool.query(`DROP TRIGGER reject_completion ON ${table}; DROP FUNCTION reject_completion();`); - } - expect((await pool.query("SELECT status, encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", [principal.id])).rows[0]) - .toEqual({ status: "reconnect", encrypted_tokens: null }); - expect(await store.findAttempt(hash(state))).toMatchObject({ status: "exchanging" }); - const restarted = new LinkOAuth(config, new LinkStore(pool), fetcher, () => now); - await expect(restarted.complete(principal, callbackUrl, start.resume, params)).rejects.toMatchObject({ reason: "authorization_expired" }); - await expect(restarted.getToken(principal)).rejects.toMatchObject({ name: "ConnectionAuthorizationRequiredError" }); - expect(fetcher).toHaveBeenCalledOnce(); - }); + try { + await expect( + service.complete(principal, callbackUrl, start.resume, params), + ).rejects.toThrow("completion write failed"); + } finally { + await pool.query( + `DROP TRIGGER reject_completion ON ${table}; DROP FUNCTION reject_completion();`, + ); + } + expect( + ( + await pool.query( + "SELECT status, encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", + [principal.id], + ) + ).rows[0], + ).toEqual({ status: "reconnect", encrypted_tokens: null }); + expect(await store.findAttempt(hash(state))).toMatchObject({ status: "exchanging" }); + const restarted = new LinkOAuth(config, new LinkStore(pool), fetcher, () => now); + await expect( + restarted.complete(principal, callbackUrl, start.resume, params), + ).rejects.toMatchObject({ reason: "authorization_expired" }); + await expect(restarted.getToken(principal)).rejects.toMatchObject({ + name: "ConnectionAuthorizationRequiredError", + }); + expect(fetcher).toHaveBeenCalledOnce(); + }, + ); it("replays committed authorization after its persistence acknowledgment is lost", async () => { const { start, state } = await begin(); const params = { state, code: "single-use-code" }; await service.forward(params); const save = OwnerStore.prototype.saveAuthorization; - vi.spyOn(OwnerStore.prototype, "saveAuthorization").mockImplementationOnce(async function (this: OwnerStore, grant, attempt) { + vi.spyOn(OwnerStore.prototype, "saveAuthorization").mockImplementationOnce(async function ( + this: OwnerStore, + grant, + attempt, + ) { await save.call(this, grant, attempt); throw new Error("persistence acknowledgment lost"); }); - await expect(service.complete(principal, callbackUrl, start.resume, params)).rejects.toThrow("persistence acknowledgment lost"); + await expect(service.complete(principal, callbackUrl, start.resume, params)).rejects.toThrow( + "persistence acknowledgment lost", + ); const restarted = new LinkOAuth(config, new LinkStore(pool), fetcher, () => now); - await expect(restarted.complete(principal, callbackUrl, start.resume, params)).resolves.toMatchObject({ token: "access-first" }); + await expect( + restarted.complete(principal, callbackUrl, start.resume, params), + ).resolves.toMatchObject({ token: "access-first" }); expect(fetcher).toHaveBeenCalledOnce(); }); it("requires new consent when the user grants only a subset of the scopes", async () => { fetcher.mockResolvedValue(Response.json(tokens("limited", "userinfo:read"))); await expect(authorize()).rejects.toMatchObject({ reason: "missing_scopes", retryable: false }); - await expect(service.getToken(principal)).rejects.toMatchObject({ name: "ConnectionAuthorizationRequiredError" }); + await expect(service.getToken(principal)).rejects.toMatchObject({ + name: "ConnectionAuthorizationRequiredError", + }); }); it("serializes concurrent refreshes across separate service instances and persists rotation", async () => { @@ -235,49 +331,79 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { return Response.json(tokens("rotated")); }); const second = new LinkOAuth(config, new LinkStore(pool), fetcher, () => now); - const results = await Promise.all([service.getToken(principal), second.getToken(principal), service.getToken(principal)]); + const results = await Promise.all([ + service.getToken(principal), + second.getToken(principal), + service.getToken(principal), + ]); expect(results.every((result) => result.token === "access-rotated")).toBe(true); expect(fetcher).toHaveBeenCalledTimes(2); const refresh = fetcher.mock.calls[1][1]!.body as URLSearchParams; expect(refresh.get("refresh_token")).toBe("refresh-first"); now += 3550_000; await second.getToken(principal); - expect((fetcher.mock.calls[2][1]!.body as URLSearchParams).get("refresh_token")).toBe("refresh-rotated"); + expect((fetcher.mock.calls[2][1]!.body as URLSearchParams).get("refresh_token")).toBe( + "refresh-rotated", + ); }); it("blocks a possibly consumed refresh token after transport failure or process loss", async () => { await authorize(); now += 3550_000; fetcher.mockRejectedValue(new Error("lost response")); - await expect(service.getToken(principal)).rejects.toMatchObject({ reason: "link_request_failed" }); - await expect(service.getToken(principal)).rejects.toMatchObject({ name: "ConnectionAuthorizationRequiredError" }); + await expect(service.getToken(principal)).rejects.toMatchObject({ + reason: "link_request_failed", + }); + await expect(service.getToken(principal)).rejects.toMatchObject({ + name: "ConnectionAuthorizationRequiredError", + }); expect(fetcher).toHaveBeenCalledTimes(2); }); it("evicts a rejected bearer so a subsequent tool call needs authorization", async () => { await authorize(); await service.evict(principal); - await expect(service.getToken(principal)).rejects.toMatchObject({ name: "ConnectionAuthorizationRequiredError" }); + await expect(service.getToken(principal)).rejects.toMatchObject({ + name: "ConnectionAuthorizationRequiredError", + }); }); it("blocks use on failed revocation and permits retry", async () => { await authorize(); fetcher.mockResolvedValue(new Response(null, { status: 503 })); - await expect(service.disconnect(principal)).rejects.toMatchObject({ reason: "revocation_failed" }); - await expect(service.getToken(principal)).rejects.toMatchObject({ reason: "disconnect_pending" }); - await expect(service.start(principal, callbackUrl)).rejects.toMatchObject({ reason: "disconnect_pending" }); + await expect(service.disconnect(principal)).rejects.toMatchObject({ + reason: "revocation_failed", + }); + await expect(service.getToken(principal)).rejects.toMatchObject({ + reason: "disconnect_pending", + }); + await expect(service.start(principal, callbackUrl)).rejects.toMatchObject({ + reason: "disconnect_pending", + }); fetcher.mockResolvedValue(new Response(null, { status: 200 })); expect(await service.disconnect(principal)).toEqual({ status: "disconnected" }); - expect((fetcher.mock.calls.at(-1)![1]!.body as URLSearchParams).get("token")).toBe("refresh-first"); - expect((await pool.query("SELECT encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", [principal.id])).rows[0].encrypted_tokens).toBeNull(); - await expect(service.getToken(principal)).rejects.toMatchObject({ name: "ConnectionAuthorizationRequiredError" }); + expect((fetcher.mock.calls.at(-1)![1]!.body as URLSearchParams).get("token")).toBe( + "refresh-first", + ); + expect( + ( + await pool.query("SELECT encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", [ + principal.id, + ]) + ).rows[0].encrypted_tokens, + ).toBeNull(); + await expect(service.getToken(principal)).rejects.toMatchObject({ + name: "ConnectionAuthorizationRequiredError", + }); }); it("invalidates pending callbacks on disconnect", async () => { const { start, state } = await begin(); await service.forward({ state, code: "late-code" }); await service.disconnect(principal); - await expect(service.complete(principal, callbackUrl, start.resume, { state, code: "late-code" })).rejects.toMatchObject({ reason: "invalid_state" }); + await expect( + service.complete(principal, callbackUrl, start.resume, { state, code: "late-code" }), + ).rejects.toMatchObject({ reason: "invalid_state" }); expect(fetcher).not.toHaveBeenCalled(); }); @@ -289,13 +415,24 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { expect(fetcher).toHaveBeenCalledTimes(2); expect(fetcher.mock.calls[1][0]).toBe("https://login.link.com/auth/revoke"); expect((fetcher.mock.calls[1][1]!.body as URLSearchParams).get("token")).toBe("refresh-first"); - expect((await pool.query("SELECT encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", [principal.id])).rows[0].encrypted_tokens).toBeNull(); + expect( + ( + await pool.query("SELECT encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", [ + principal.id, + ]) + ).rows[0].encrypted_tokens, + ).toBeNull(); fetcher.mockResolvedValueOnce(Response.json(tokens("replacement"))); await service.forward({ state, code: "replacement-code" }); - await service.complete(principal, callbackUrl, start.resume, { state, code: "replacement-code" }); + await service.complete(principal, callbackUrl, start.resume, { + state, + code: "replacement-code", + }); fetcher.mockResolvedValueOnce(new Response(null, { status: 200 })); await service.disconnect(principal); - expect((fetcher.mock.calls[3][1]!.body as URLSearchParams).get("token")).toBe("refresh-replacement"); + expect((fetcher.mock.calls[3][1]!.body as URLSearchParams).get("token")).toBe( + "refresh-replacement", + ); }); it("retains a blocked previous grant if replacement revocation fails", async () => { @@ -303,8 +440,16 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { callbackUrl = callbackUrl.replace("opaque-hook", "replacement-hook"); fetcher.mockResolvedValueOnce(new Response(null, { status: 503 })); await expect(begin()).rejects.toMatchObject({ reason: "revocation_failed" }); - await expect(service.getToken(principal)).rejects.toMatchObject({ reason: "disconnect_pending" }); - expect((await pool.query("SELECT encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", [principal.id])).rows[0].encrypted_tokens).not.toBeNull(); + await expect(service.getToken(principal)).rejects.toMatchObject({ + reason: "disconnect_pending", + }); + expect( + ( + await pool.query("SELECT encrypted_tokens FROM bell_link_grants WHERE principal_id = $1", [ + principal.id, + ]) + ).rows[0].encrypted_tokens, + ).not.toBeNull(); }); it("supersedes old authorization attempts without allowing late wallet replacement", async () => { @@ -312,72 +457,137 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { await service.forward({ state: old.state, code: "old-code" }); callbackUrl = callbackUrl.replace("opaque-hook", "new-hook"); const current = await authorize(); - await expect(service.complete(principal, callbackUrl, old.start.resume, { state: old.state, code: "old-code" })).rejects.toMatchObject({ reason: "invalid_state" }); + await expect( + service.complete(principal, callbackUrl, old.start.resume, { + state: old.state, + code: "old-code", + }), + ).rejects.toMatchObject({ reason: "invalid_state" }); expect(await service.getToken(principal)).toEqual(current.result); }); it("rejects malformed framework callback capabilities", async () => { - for (const destination of ["https://bell.example/other", "https://user:pass@bell.example/eve/v1/connections/link/callback/a/b", "file:///eve/v1/connections/link/callback/a/b"]) { - await expect(service.start(principal, destination)).rejects.toMatchObject({ reason: "invalid_callback_origin" }); + for (const destination of [ + "https://bell.example/other", + "https://user:pass@bell.example/eve/v1/connections/link/callback/a/b", + "file:///eve/v1/connections/link/callback/a/b", + ]) { + await expect(service.start(principal, destination)).rejects.toMatchObject({ + reason: "invalid_callback_origin", + }); } }); - it.each(["development", "production"])("resumes Eve authorization in the original Linq chat with a %s callback origin", async (environment) => { - vi.stubEnv("VERCEL_ENV", environment); - vi.stubEnv("WORKFLOW_LOCAL_BASE_URL", "http://127.0.0.1:54321"); - vi.stubEnv("VERCEL_PROJECT_PRODUCTION_URL", "bell-project.vercel.app"); - const { context, sessionId } = eveContext(); - const provider = createLinkAuth(() => service); - const scoped = { scope: "link__retrieve_user_info", authorization: provider, connection: { url: "" } }; - const { adapter } = linq as unknown as { adapter: ChannelAdapter }; - const post = vi.fn(async () => ({ id: "consent-message" })); - const editMessage = vi.fn(); - const channel = { thread: { id: "original-linq-chat", isDM: true, post, adapter: { editMessage }, startTyping: vi.fn() }, state: { pendingAuthMessageIds: {} } }; - await contextStorage.run(context, async () => { - const execution = createAuthorizationExecution(); - const signal = await execution.run(() => execution.getToken(scoped)); - if (!isAuthorizationSignal(signal)) throw new Error("Expected Eve to suspend for consent."); - const challenge = signal.challenges[0]; - expect(new URL(challenge.hookUrl).origin).toBe(environment === "production" ? "https://bell-project.vercel.app" : "http://127.0.0.1:54321"); - await adapter["authorization.required"]!({ name: challenge.name, authorization: challenge.challenge } as never, channel as never); - expect(post).toHaveBeenCalledWith({ markdown: expect.stringContaining("https://login.link.com/auth?") }); - const state = new URL(challenge.challenge.url!).searchParams.get("state")!; - const response = await linkCallback(new Request(`${config.redirectUri}?state=${state}&code=runtime-code`), () => service); - expect(response.status).toBe(303); - const redirected = new URL(response.headers.get("location")!); - expect(redirected.origin).toBe(config.origin); - expect(redirected.pathname).toBe(new URL(challenge.hookUrl).pathname); - context.set(PendingAuthorizationResultKey, [{ - name: challenge.name, attemptId: challenge.attemptId, instanceId: challenge.instanceId, - principal: challenge.principal, resume: challenge.resume, hookUrl: challenge.hookUrl, - callback: { method: "GET", params: Object.fromEntries(redirected.searchParams) }, - }]); - const resumed = createAuthorizationExecution(); - expect(await resumed.getToken(scoped)).toMatchObject({ token: "access-first" }); - expect(context.require(SessionIdKey)).toBe(sessionId); - await adapter["authorization.completed"]!({ name: challenge.name, authorization: challenge.challenge, outcome: "authorized" } as never, channel as never); - expect(editMessage).toHaveBeenCalledWith("original-linq-chat", "consent-message", { markdown: "Link connected." }); - expect(fetcher).toHaveBeenCalledOnce(); - }); - }); + it.each(["development", "production"])( + "resumes Eve authorization in the original Linq chat with a %s callback origin", + async (environment) => { + vi.stubEnv("VERCEL_ENV", environment); + vi.stubEnv("WORKFLOW_LOCAL_BASE_URL", "http://127.0.0.1:54321"); + vi.stubEnv("VERCEL_PROJECT_PRODUCTION_URL", "bell-project.vercel.app"); + const { context, sessionId } = eveContext(); + const provider = createLinkAuth(() => service); + const scoped = { + scope: "link__retrieve_user_info", + authorization: provider, + connection: { url: "" }, + }; + const { adapter } = linq as unknown as { adapter: ChannelAdapter }; + const post = vi.fn(async () => ({ id: "consent-message" })); + const editMessage = vi.fn(); + const channel = { + thread: { + id: "original-linq-chat", + isDM: true, + post, + adapter: { editMessage }, + startTyping: vi.fn(), + }, + state: { pendingAuthMessageIds: {} }, + }; + await contextStorage.run(context, async () => { + const execution = createAuthorizationExecution(); + const signal = await execution.run(() => execution.getToken(scoped)); + if (!isAuthorizationSignal(signal)) throw new Error("Expected Eve to suspend for consent."); + const challenge = signal.challenges[0]; + expect(new URL(challenge.hookUrl).origin).toBe( + environment === "production" + ? "https://bell-project.vercel.app" + : "http://127.0.0.1:54321", + ); + await adapter["authorization.required"]!( + { name: challenge.name, authorization: challenge.challenge } as never, + channel as never, + ); + expect(post).toHaveBeenCalledWith({ + markdown: expect.stringContaining("https://login.link.com/auth?"), + }); + const state = new URL(challenge.challenge.url!).searchParams.get("state")!; + const response = await linkCallback( + new Request(`${config.redirectUri}?state=${state}&code=runtime-code`), + () => service, + ); + expect(response.status).toBe(303); + const redirected = new URL(response.headers.get("location")!); + expect(redirected.origin).toBe(config.origin); + expect(redirected.pathname).toBe(new URL(challenge.hookUrl).pathname); + context.set(PendingAuthorizationResultKey, [ + { + name: challenge.name, + attemptId: challenge.attemptId, + instanceId: challenge.instanceId, + principal: challenge.principal, + resume: challenge.resume, + hookUrl: challenge.hookUrl, + callback: { method: "GET", params: Object.fromEntries(redirected.searchParams) }, + }, + ]); + const resumed = createAuthorizationExecution(); + expect(await resumed.getToken(scoped)).toMatchObject({ token: "access-first" }); + expect(context.require(SessionIdKey)).toBe(sessionId); + await adapter["authorization.completed"]!( + { + name: challenge.name, + authorization: challenge.challenge, + outcome: "authorized", + } as never, + channel as never, + ); + expect(editMessage).toHaveBeenCalledWith("original-linq-chat", "consent-message", { + markdown: "Link connected.", + }); + expect(fetcher).toHaveBeenCalledOnce(); + }); + }, + ); it("checks a failed disconnect even when the current Eve step still caches the bearer", async () => { await authorize(); const { context, auth, sessionId } = eveContext(); const provider = createLinkAuth(() => service); - const scoped = { scope: "link__retrieve_spend_request", authorization: provider, connection: { url: "" } }; + const scoped = { + scope: "link__retrieve_spend_request", + authorization: provider, + connection: { url: "" }, + }; await contextStorage.run(context, async () => { const execution = createAuthorizationExecution(); const ctx = { session: { id: sessionId, auth: { current: auth } }, - getToken: () => execution.getToken(scoped), requireAuth: () => execution.requireAuth(scoped), + getToken: () => execution.getToken(scoped), + requireAuth: () => execution.requireAuth(scoped), } as unknown as ToolContext; - expect(await freshLinkContext(ctx).getToken(provider)).toMatchObject({ token: "access-first" }); + expect(await freshLinkContext(ctx).getToken(provider)).toMatchObject({ + token: "access-first", + }); // Model another conversation disconnecting between requests in this step. fetcher.mockResolvedValue(new Response(null, { status: 503 })); - await expect(service.disconnect(principal)).rejects.toMatchObject({ reason: "revocation_failed" }); + await expect(service.disconnect(principal)).rejects.toMatchObject({ + reason: "revocation_failed", + }); expect(await ctx.getToken(provider)).toMatchObject({ token: "access-first" }); - await expect(freshLinkContext(ctx).getToken(provider)).rejects.toMatchObject({ reason: "disconnect_pending" }); + await expect(freshLinkContext(ctx).getToken(provider)).rejects.toMatchObject({ + reason: "disconnect_pending", + }); expect(fetcher).toHaveBeenCalledTimes(2); }); }); @@ -387,9 +597,13 @@ describe.skipIf(!databaseUrl)("Link OAuth with PostgreSQL", () => { const params = { state, code: "code" }; await service.forward(params); // The durable state left by a process exiting after the marker commit. - await pool.query("UPDATE bell_link_oauth_attempts SET status = 'exchanging' WHERE id = $1", [start.resume.attemptId]); + await pool.query("UPDATE bell_link_oauth_attempts SET status = 'exchanging' WHERE id = $1", [ + start.resume.attemptId, + ]); const restarted = new LinkOAuth(config, new LinkStore(pool), fetcher, () => now); - await expect(restarted.complete(principal, callbackUrl, start.resume, params)).rejects.toMatchObject({ reason: "authorization_expired" }); + await expect( + restarted.complete(principal, callbackUrl, start.resume, params), + ).rejects.toMatchObject({ reason: "authorization_expired" }); expect(fetcher).not.toHaveBeenCalled(); }); }); diff --git a/tests/link-payment.test.ts b/tests/link-payment.test.ts index bc4cbca..590bb1e 100644 --- a/tests/link-payment.test.ts +++ b/tests/link-payment.test.ts @@ -1,29 +1,93 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { checkoutInput, paymentOrigins, paymentValues, type CheckoutInput } from "../server/link/payment"; +import { + checkoutInput, + paymentOrigins, + paymentValues, + type CheckoutInput, +} from "../server/link/payment"; -const input: CheckoutInput = { id: "spend_fixture", amount: 100, currency: "usd", credential: "card", fields: [{ field: "number", selector: "#card" }, { field: "expiry", selector: "#expiry" }], submit: { selector: "#pay" } }; -const approved = { id: input.id, status: "approved", amount: 100, currency: "usd", merchant_url: "https://merchant.example/item", credential_type: "card" }; +const input: CheckoutInput = { + id: "spend_fixture", + amount: 100, + currency: "usd", + credential: "card", + fields: [ + { field: "number", selector: "#card" }, + { field: "expiry", selector: "#expiry" }, + ], + submit: { selector: "#pay" }, +}; +const approved = { + id: input.id, + status: "approved", + amount: 100, + currency: "usd", + merchant_url: "https://merchant.example/item", + credential_type: "card", +}; afterEach(() => vi.unstubAllEnvs()); describe("approved Link payment contract", () => { - it.each([{ status: "pending_approval" }, { status: "canceled" }, { amount: 101 }, { currency: "eur" }, { id: "other" }, { credential_type: "link_pay_token" }, { expires_at: 1 }])("refuses mismatched or expired approval: %j", change => { + it.each([ + { status: "pending_approval" }, + { status: "canceled" }, + { amount: 101 }, + { currency: "eur" }, + { id: "other" }, + { credential_type: "link_pay_token" }, + { expires_at: 1 }, + ])("refuses mismatched or expired approval: %j", (change) => { expect(() => paymentOrigins({ ...approved, ...change }, input)).toThrow("approval"); }); it("derives frame origins only from the approved merchant and server configuration", () => { - vi.stubEnv("LINK_CHECKOUT_ORIGINS", '{"https://merchant.example":["https://payments.example"],"https://other.example":["https://attacker.example"]}'); - expect(paymentOrigins(approved, input)).toEqual(["https://merchant.example", "https://payments.example"]); - expect(() => paymentOrigins({ ...approved, merchant_url: "http://merchant.example" }, input)).toThrow(); - expect(() => paymentOrigins({ ...approved, merchant_url: "https://user:pass@merchant.example" }, input)).toThrow(); + vi.stubEnv( + "LINK_CHECKOUT_ORIGINS", + '{"https://merchant.example":["https://payments.example"],"https://other.example":["https://attacker.example"]}', + ); + expect(paymentOrigins(approved, input)).toEqual([ + "https://merchant.example", + "https://payments.example", + ]); + expect(() => + paymentOrigins({ ...approved, merchant_url: "http://merchant.example" }, input), + ).toThrow(); + expect(() => + paymentOrigins({ ...approved, merchant_url: "https://user:pass@merchant.example" }, input), + ).toThrow(); }); it("maps requested card fields and rejects expired credentials", () => { - expect(paymentValues({ card: { number: "synthetic-number", exp_month: 3, exp_year: 2031 } }, input)).toEqual(["synthetic-number", "03/31"]); - expect(() => paymentValues({ card: { number: "synthetic-number", exp_month: 3, exp_year: 2031, valid_until: "2000-01-01" } }, input)).toThrow("expired"); - }); - it.each(["shared_payment_token", "link_pay_token"])("rejects unsupported token protocol %s", credential => { - expect(checkoutInput.safeParse({ ...input, credential }).success).toBe(false); + expect( + paymentValues({ card: { number: "synthetic-number", exp_month: 3, exp_year: 2031 } }, input), + ).toEqual(["synthetic-number", "03/31"]); + expect(() => + paymentValues( + { + card: { + number: "synthetic-number", + exp_month: 3, + exp_year: 2031, + valid_until: "2000-01-01", + }, + }, + input, + ), + ).toThrow("expired"); }); + it.each(["shared_payment_token", "link_pay_token"])( + "rejects unsupported token protocol %s", + (credential) => { + expect(checkoutInput.safeParse({ ...input, credential }).success).toBe(false); + }, + ); it("rejects raw credentials, cross-frame selector syntax, and missing requested values", () => { expect(checkoutInput.safeParse({ ...input, number: "secret" }).success).toBe(false); - expect(checkoutInput.safeParse({ ...input, submit: { selector: "iframe >> input" } }).success).toBe(false); - expect(() => paymentValues({ card: { number: "synthetic-number", exp_month: 3, exp_year: 2031 } }, { ...input, fields: [{ field: "cvc", selector: "#cvc" }] })).toThrow("unavailable"); + expect( + checkoutInput.safeParse({ ...input, submit: { selector: "iframe >> input" } }).success, + ).toBe(false); + expect(() => + paymentValues( + { card: { number: "synthetic-number", exp_month: 3, exp_year: 2031 } }, + { ...input, fields: [{ field: "cvc", selector: "#cvc" }] }, + ), + ).toThrow("unavailable"); }); }); diff --git a/tests/link-sandbox.test.ts b/tests/link-sandbox.test.ts index 430507e..484cf06 100644 --- a/tests/link-sandbox.test.ts +++ b/tests/link-sandbox.test.ts @@ -14,94 +14,210 @@ import profile from "../agent/extensions/link/tools/retrieve_user_info"; import disconnect from "../agent/tools/link_disconnect"; import { createLinkAuth, freshLinkContext } from "../agent/lib/link-auth"; import { linkCallback } from "../server/link/callback"; -import { assertSandboxRequest, rememberSandboxRequest, sandboxPurchase } from "../server/link/sandbox"; +import { + assertSandboxRequest, + rememberSandboxRequest, + sandboxPurchase, +} from "../server/link/sandbox"; import { quarantineBrowser, withBrowserPrivacyLock } from "../server/browser/privacy-lock"; const principal = { type: "user" as const, issuer: "linq", id: "linq:sandbox-operator" }; -const context = { session: { id: "sandbox-session", auth: { current: { principalType: "user", principalId: principal.id, issuer: "linq", authenticator: "linq-message" } } }, abortSignal: new AbortController().signal, getToken: vi.fn(), requireAuth: vi.fn() } as unknown as ToolContext; -const purchase = { credential_type: "card" as const, amount: 100, currency: "usd", request_approval: true, test: false, merchant_name: "Fixture", merchant_url: "https://merchant.invalid", context: "The operator requested one synthetic fixture item for exactly one dollar, including all tax and shipping, solely to test this integration.", idempotency_key: "existing-live-key" }; -const metadata = (id = `spend_${randomUUID()}`) => ({ id, status: "approved", amount: 100, currency: "usd", merchant_url: purchase.merchant_url, created_at: "now", updated_at: "now" }); +const context = { + session: { + id: "sandbox-session", + auth: { + current: { + principalType: "user", + principalId: principal.id, + issuer: "linq", + authenticator: "linq-message", + }, + }, + }, + abortSignal: new AbortController().signal, + getToken: vi.fn(), + requireAuth: vi.fn(), +} as unknown as ToolContext; +const purchase = { + credential_type: "card" as const, + amount: 100, + currency: "usd", + request_approval: true, + test: false, + merchant_name: "Fixture", + merchant_url: "https://merchant.invalid", + context: + "The operator requested one synthetic fixture item for exactly one dollar, including all tax and shipping, solely to test this integration.", + idempotency_key: "existing-live-key", +}; +const metadata = (id = `spend_${randomUUID()}`) => ({ + id, + status: "approved", + amount: 100, + currency: "usd", + merchant_url: purchase.merchant_url, + created_at: "now", + updated_at: "now", +}); beforeEach(() => { - vi.stubEnv("LINK_SANDBOX", "1"); vi.stubEnv("LINK_SANDBOX_ACCESS_TOKEN", "synthetic-process-token"); vi.stubEnv("LINK_SANDBOX_PRINCIPAL_ID", principal.id); - for (const name of ["DATABASE_URL", "LINK_CLIENT_ID", "LINK_CLIENT_SECRET", "LINK_TOKEN_ENCRYPTION_KEY"]) vi.stubEnv(name, ""); + vi.stubEnv("LINK_SANDBOX", "1"); + vi.stubEnv("LINK_SANDBOX_ACCESS_TOKEN", "synthetic-process-token"); + vi.stubEnv("LINK_SANDBOX_PRINCIPAL_ID", principal.id); + for (const name of [ + "DATABASE_URL", + "LINK_CLIENT_ID", + "LINK_CLIENT_SECRET", + "LINK_TOKEN_ENCRYPTION_KEY", + ]) + vi.stubEnv(name, ""); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.unstubAllEnvs(); }); -afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); vi.unstubAllEnvs(); }); describe("database-free Link sandbox", () => { it("forces test purchases and isolates idempotency keys from existing real requests", async () => { const result = metadata(); - const execute = vi.spyOn(native.create_spend_request, "execute").mockResolvedValue(result as never); + const execute = vi + .spyOn(native.create_spend_request, "execute") + .mockResolvedValue(result as never); expect(await create.execute(purchase, context)).toEqual({ ...result, test: true }); const input = execute.mock.calls[0][0]; - expect(input.test).toBe(true); expect(input.idempotency_key).not.toBe(purchase.idempotency_key); + expect(input.test).toBe(true); + expect(input.idempotency_key).not.toBe(purchase.idempotency_key); expect(sandboxPurchase(purchase).idempotency_key).toBe(input.idempotency_key); - expect(sandboxPurchase({ ...purchase, idempotency_key: "other" }).idempotency_key).not.toBe(input.idempotency_key); + expect(sandboxPurchase({ ...purchase, idempotency_key: "other" }).idempotency_key).not.toBe( + input.idempotency_key, + ); expect(() => assertSandboxRequest(result.id)).not.toThrow(); expect(create.approval).toBe(native.create_spend_request.approval); }); it("exposes only current test requests, without caching credentials in Eve", async () => { - const result = metadata(); rememberSandboxRequest(result.id); + const result = metadata(); + rememberSandboxRequest(result.id); vi.spyOn(native.retrieve_spend_request, "execute").mockImplementation(async (_input, ctx) => { - expect(await ctx.getToken({ getToken: vi.fn() })).toEqual({ token: "synthetic-process-token" }); + expect(await ctx.getToken({ getToken: vi.fn() })).toEqual({ + token: "synthetic-process-token", + }); return { ...result, card: { number: "synthetic-card" } } as never; }); expect(await retrieve.execute({ id: result.id }, context)).toEqual({ ...result, test: true }); - vi.spyOn(native.list_spend_requests, "execute").mockResolvedValue([metadata("spend_real"), result] as never); + vi.spyOn(native.list_spend_requests, "execute").mockResolvedValue([ + metadata("spend_real"), + result, + ] as never); expect(await list.execute({}, context)).toEqual([{ ...result, test: true }]); expect(context.getToken).not.toHaveBeenCalled(); expect(JSON.stringify(result)).not.toMatch(/synthetic-process-token|synthetic-card/); }); it("rejects existing or forgotten request IDs before any native mutation or retrieval", async () => { - for (const [tool, original, input] of [[retrieve, native.retrieve_spend_request, { id: "spend_real" }], [update, native.update_spend_request, { id: "spend_real", amount: 100 }], [cancel, native.cancel_spend_request, { id: "spend_real" }], [approval, native.request_spend_approval, { id: "spend_real" }]] as const) { + for (const [tool, original, input] of [ + [retrieve, native.retrieve_spend_request, { id: "spend_real" }], + [update, native.update_spend_request, { id: "spend_real", amount: 100 }], + [cancel, native.cancel_spend_request, { id: "spend_real" }], + [approval, native.request_spend_approval, { id: "spend_real" }], + ] as const) { const execute = vi.spyOn(original, "execute"); - await expect(tool.execute(input as never, context)).rejects.toThrow("test requests created by this process"); + await expect(tool.execute(input as never, context)).rejects.toThrow( + "test requests created by this process", + ); expect(execute).not.toHaveBeenCalled(); } }); it("rejects other senders, anonymous callers, and a missing runtime token", async () => { const execute = vi.spyOn(native.create_spend_request, "execute"); vi.stubEnv("LINK_SANDBOX_PRINCIPAL_ID", "linq:another-sender"); - await expect(create.execute(purchase, context)).rejects.toThrow("configured Link sandbox operator"); - expect(() => freshLinkContext({ ...context, session: { auth: { current: undefined } } } as never, undefined, "create")).toThrow(); - vi.stubEnv("LINK_SANDBOX_PRINCIPAL_ID", principal.id); vi.stubEnv("LINK_SANDBOX_ACCESS_TOKEN", ""); + await expect(create.execute(purchase, context)).rejects.toThrow( + "configured Link sandbox operator", + ); + expect(() => + freshLinkContext( + { ...context, session: { auth: { current: undefined } } } as never, + undefined, + "create", + ), + ).toThrow(); + vi.stubEnv("LINK_SANDBOX_PRINCIPAL_ID", principal.id); + vi.stubEnv("LINK_SANDBOX_ACCESS_TOKEN", ""); await expect(create.execute(purchase, context)).rejects.toThrow("LINK_SANDBOX_ACCESS_TOKEN"); expect(execute).not.toHaveBeenCalled(); }); it("does not fetch saved wallet profiles, payment methods, or addresses", async () => { - for (const [tool, original] of [[methods, native.list_payment_methods], [addresses, native.list_shipping_addresses], [profile, native.retrieve_user_info]] as const) { + for (const [tool, original] of [ + [methods, native.list_payment_methods], + [addresses, native.list_shipping_addresses], + [profile, native.retrieve_user_info], + ] as const) { const execute = vi.spyOn(original, "execute"); - await expect(Promise.resolve().then(() => tool.execute({}, context))).rejects.toThrow("not saved wallet details"); + await expect(Promise.resolve().then(() => tool.execute({}, context))).rejects.toThrow( + "not saved wallet details", + ); expect(execute).not.toHaveBeenCalled(); } }); it("never opens the OAuth store or callback in sandbox mode", async () => { - const service = vi.fn(); vi.spyOn(console, "error").mockImplementation(() => {}); - await expect(createLinkAuth(service).getToken({ principal, connection: { url: "" } })).rejects.toThrow(); - expect((await linkCallback(new Request("https://bell.invalid/api/link/oauth/callback?state=x&code=y"), service)).status).toBe(404); + const service = vi.fn(); + vi.spyOn(console, "error").mockImplementation(() => {}); + await expect( + createLinkAuth(service).getToken({ principal, connection: { url: "" } }), + ).rejects.toThrow(); + expect( + ( + await linkCallback( + new Request("https://bell.invalid/api/link/oauth/callback?state=x&code=y"), + service, + ) + ).status, + ).toBe(404); await expect(disconnect.execute({}, context)).rejects.toThrow("no saved wallet connection"); expect(service).not.toHaveBeenCalled(); }); it("serializes readers behind private entry and keeps an interrupted browser quarantined", async () => { - const id = randomUUID(); let release!: () => void, entered!: () => void; - const gate = new Promise(resolve => { release = resolve; }), started = new Promise(resolve => { entered = resolve; }); - const payment = withBrowserPrivacyLock(id, async () => { entered(); await gate; await quarantineBrowser(); throw new Error("interrupted"); }); - const failure = expect(payment).rejects.toThrow("interrupted"); await started; - const read = expect(withBrowserPrivacyLock(id, async () => "private snapshot")).rejects.toThrow("Payment details may remain"); - release(); await failure; await read; + const id = randomUUID(); + let release!: () => void, entered!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }), + started = new Promise((resolve) => { + entered = resolve; + }); + const payment = withBrowserPrivacyLock(id, async () => { + entered(); + await gate; + await quarantineBrowser(); + throw new Error("interrupted"); + }); + const failure = expect(payment).rejects.toThrow("interrupted"); + await started; + const read = expect(withBrowserPrivacyLock(id, async () => "private snapshot")).rejects.toThrow( + "Payment details may remain", + ); + release(); + await failure; + await read; expect(await withBrowserPrivacyLock(randomUUID(), async () => "safe")).toBe("safe"); }); it("refuses recovered browsers and loses test-request access after process state is replaced", async () => { - const id = randomUUID(); rememberSandboxRequest(id); expect(() => assertSandboxRequest(id)).not.toThrow(); + const id = randomUUID(); + rememberSandboxRequest(id); + expect(() => assertSandboxRequest(id)).not.toThrow(); vi.resetModules(); const restarted = await import("../server/link/sandbox"); - expect(() => restarted.assertSandboxRequest(id)).toThrow("test requests created by this process"); + expect(() => restarted.assertSandboxRequest(id)).toThrow( + "test requests created by this process", + ); const privacy = await import("../server/browser/privacy-lock"); const operation = vi.fn(async () => "private snapshot"); - await expect(privacy.withBrowserPrivacyLock(randomUUID(), operation, undefined, true)).rejects.toThrow("cannot recover"); + await expect( + privacy.withBrowserPrivacyLock(randomUUID(), operation, undefined, true), + ).rejects.toThrow("cannot recover"); expect(operation).not.toHaveBeenCalled(); }); it("preserves real Link inputs when sandbox mode is disabled", () => { - vi.stubEnv("LINK_SANDBOX", "0"); expect(sandboxPurchase(purchase)).toBe(purchase); + vi.stubEnv("LINK_SANDBOX", "0"); + expect(sandboxPurchase(purchase)).toBe(purchase); expect(() => assertSandboxRequest("existing_real_request")).not.toThrow(); }); }); diff --git a/tests/linq.test.ts b/tests/linq.test.ts index affba1e..42430b1 100644 --- a/tests/linq.test.ts +++ b/tests/linq.test.ts @@ -2,7 +2,10 @@ import { createHmac, randomUUID } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import linq from "../agent/channels/linq"; -import { ContextContainer, contextStorage } from "../node_modules/eve/dist/src/context/container.js"; +import { + ContextContainer, + contextStorage, +} from "../node_modules/eve/dist/src/context/container.js"; import { SessionKey } from "../node_modules/eve/dist/src/context/keys.js"; import type { ChannelAdapter } from "../node_modules/eve/dist/src/channel/adapter.js"; @@ -17,13 +20,19 @@ beforeEach(() => { vi.stubEnv("LINQ_WEBHOOK_SECRET", `whsec_${signingKey.toString("base64")}`); vi.stubEnv("BELL_LINQ_PHONE_NUMBER", receivingNumber); vi.stubEnv("BELL_LINQ_ALLOWED_SENDERS", senderNumber); - vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - if (!url.startsWith("https://api.linqapp.com/api/partner/v3/chats/") || !url.endsWith("/read")) { - throw new Error(`Unexpected provider request in Linq test: ${url}`); - } - return new Response(null, { status: 204 }); - })); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if ( + !url.startsWith("https://api.linqapp.com/api/partner/v3/chats/") || + !url.endsWith("/read") + ) { + throw new Error(`Unexpected provider request in Linq test: ${url}`); + } + return new Response(null, { status: 204 }); + }), + ); }); afterEach(() => { @@ -49,11 +58,15 @@ function message(chatId = randomUUID()) { }; } -async function deliver(event: ReturnType, options: { tamper?: boolean; age?: number; unsigned?: boolean } = {}) { +async function deliver( + event: ReturnType, + options: { tamper?: boolean; age?: number; unsigned?: boolean } = {}, +) { const body = JSON.stringify(event); const timestamp = String(Math.floor(Date.now() / 1_000) - (options.age ?? 0)); const signature = createHmac("sha256", signingKey) - .update(`${event.event_id}.${timestamp}.${body}`).digest("base64"); + .update(`${event.event_id}.${timestamp}.${body}`) + .digest("base64"); const headers = new Headers({ "content-type": "application/json" }); if (!options.unsigned) { headers.set("webhook-id", event.event_id); @@ -61,22 +74,42 @@ async function deliver(event: ReturnType, options: { tamper?: bo headers.set("webhook-signature", `v1,${signature}`); } const request = new Request("https://bell.example/eve/v1/linq", { - method: "POST", headers, body: options.tamper ? `${body} ` : body, + method: "POST", + headers, + body: options.tamper ? `${body} ` : body, }); const tasks: Promise[] = []; const dispatch = vi.fn(async () => undefined); // Stop at the real adapter's Eve dispatch boundary; never run a model or browser. - const from = vi.fn(() => new Proxy({}, { - get(_target, property) { - if (property === "send") return dispatch; - throw new Error(`Unexpected Eve operation: ${String(property)}`); - }, - })) as unknown as RouteContext["from"]; - const unsupported = () => { throw new Error("Unexpected Eve route operation"); }; + const from = vi.fn( + () => + new Proxy( + {}, + { + get(_target, property) { + if (property === "send") return dispatch; + throw new Error(`Unexpected Eve operation: ${String(property)}`); + }, + }, + ), + ) as unknown as RouteContext["from"]; + const unsupported = () => { + throw new Error("Unexpected Eve route operation"); + }; const response = await route.handler(request, { - from, resolveSession: unsupported, attachSession: unsupported, to: unsupported, - describe: unsupported, listSkillFiles: unsupported, readSkill: unsupported, invokeTool: unsupported, - params: {}, requestIp: null, waitUntil: (task) => { tasks.push(task); }, + from, + resolveSession: unsupported, + attachSession: unsupported, + to: unsupported, + describe: unsupported, + listSkillFiles: unsupported, + readSkill: unsupported, + invokeTool: unsupported, + params: {}, + requestIp: null, + waitUntil: (task) => { + tasks.push(task); + }, }); if (!(response instanceof Response)) throw new Error("Expected an HTTP webhook response."); for (const task of tasks) await task; @@ -90,11 +123,22 @@ describe("Bell's signed Linq webhook", () => { expect(response.status).toBe(200); expect(from).toHaveBeenCalledWith(expect.stringContaining(event.data.chat.id)); expect(dispatch).toHaveBeenCalledOnce(); - expect(dispatch).toHaveBeenCalledWith(event.data.parts[0].value, expect.objectContaining({ - context: [expect.stringContaining("Bell's private Linq")], - auth: expect.objectContaining({ principalId: "linq:sender-handle-id", principalType: "user", attributes: expect.objectContaining({ bell_watch_chat: event.data.chat.id, bell_watch_sender: senderNumber, bell_watch_line: receivingNumber }) }), - state: { thread: expect.objectContaining({ isDM: true }) }, - })); + expect(dispatch).toHaveBeenCalledWith( + event.data.parts[0].value, + expect.objectContaining({ + context: [expect.stringContaining("Bell's private Linq")], + auth: expect.objectContaining({ + principalId: "linq:sender-handle-id", + principalType: "user", + attributes: expect.objectContaining({ + bell_watch_chat: event.data.chat.id, + bell_watch_sender: senderNumber, + bell_watch_line: receivingNumber, + }), + }), + state: { thread: expect.objectContaining({ isDM: true }) }, + }), + ); expect(fetch).toHaveBeenCalledOnce(); }); @@ -118,7 +162,8 @@ describe("Bell's signed Linq webhook", () => { state: { pendingAuthMessageIds: {} }, }; const authorization = { - displayName: "Link", url: "https://login.link.com/auth?state=opaque-state", + displayName: "Link", + url: "https://login.link.com/auth?state=opaque-state", instructions: "Connect your wallet and return to this chat.", }; const { adapter } = linq as unknown as { adapter: ChannelAdapter }; @@ -126,15 +171,21 @@ describe("Bell's signed Linq webhook", () => { context.set(SessionKey, { sessionId: "original-eve-session" } as never); await contextStorage.run(context, async () => { await adapter["authorization.required"]!( - { name: "link", authorization } as never, channel as never, + { name: "link", authorization } as never, + channel as never, ); - expect(post).toHaveBeenCalledExactlyOnceWith({ markdown: expect.stringContaining(authorization.url) }); + expect(post).toHaveBeenCalledExactlyOnceWith({ + markdown: expect.stringContaining(authorization.url), + }); expect(channel.state.pendingAuthMessageIds).toEqual({ link: "oauth-message" }); await adapter["authorization.completed"]!( - { name: "link", authorization, outcome: "authorized" } as never, channel as never, + { name: "link", authorization, outcome: "authorized" } as never, + channel as never, ); }); - expect(editMessage).toHaveBeenCalledWith("original-linq-chat", "oauth-message", { markdown: "Link connected." }); + expect(editMessage).toHaveBeenCalledWith("original-linq-chat", "oauth-message", { + markdown: "Link connected.", + }); expect(startTyping).toHaveBeenCalledWith("Connected. Resuming..."); expect(channel.state.pendingAuthMessageIds).toEqual({}); }); @@ -151,12 +202,42 @@ describe("Bell's signed Linq webhook", () => { }); it.each([ - ["another line", (event: ReturnType) => { event.data.chat.owner_handle.handle = "+12025550199"; }], - ["another sender", (event: ReturnType) => { event.data.sender_handle.handle = "+12025550199"; }], - ["a group chat", (event: ReturnType) => { event.data.chat.is_group = true; }], - ["a bot echo", (event: ReturnType) => { event.data.sender_handle.is_me = true; }], - ["an outbound message", (event: ReturnType) => { event.data.direction = "outbound"; }], - ["an email handle", (event: ReturnType) => { event.data.sender_handle.handle = "demo@example.com"; }], + [ + "another line", + (event: ReturnType) => { + event.data.chat.owner_handle.handle = "+12025550199"; + }, + ], + [ + "another sender", + (event: ReturnType) => { + event.data.sender_handle.handle = "+12025550199"; + }, + ], + [ + "a group chat", + (event: ReturnType) => { + event.data.chat.is_group = true; + }, + ], + [ + "a bot echo", + (event: ReturnType) => { + event.data.sender_handle.is_me = true; + }, + ], + [ + "an outbound message", + (event: ReturnType) => { + event.data.direction = "outbound"; + }, + ], + [ + "an email handle", + (event: ReturnType) => { + event.data.sender_handle.handle = "demo@example.com"; + }, + ], ])("ignores a signed message from %s", async (_name, change) => { const event = message(); change(event); diff --git a/tests/merchant-access.test.ts b/tests/merchant-access.test.ts index e80fede..8dced8c 100644 --- a/tests/merchant-access.test.ts +++ b/tests/merchant-access.test.ts @@ -176,7 +176,10 @@ describe("one-request merchant header boundary", () => { ])("blocks a different first document request without presenting proof", async (request) => { const send = vi.fn(async () => ({})); const handler = merchantRequestHandler( - { send } as never, url, "main", { "KYA-Credential": "private-proof" }, + { send } as never, + url, + "main", + { "KYA-Credential": "private-proof" }, new AbortController().signal, ); await handler.handle(event({ request }) as never); diff --git a/tests/monitor-http.test.ts b/tests/monitor-http.test.ts index b5fac87..99593ac 100644 --- a/tests/monitor-http.test.ts +++ b/tests/monitor-http.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { Webhook, WebhookVerificationError } from "standardwebhooks"; import { receiveWebhook, processMonitors } from "../server/monitors/http"; import { MonitorService } from "../server/monitors/service"; -import { parallelMonitors, ProviderError, readBounded, sendLinq } from "../server/monitors/provider"; +import { + parallelMonitors, + ProviderError, + readBounded, + sendLinq, +} from "../server/monitors/provider"; import { frequencyInput, monitorTimestamp } from "../server/monitors/config"; import { eventSchema } from "../server/monitors/provider"; import { watchDestination, watchIdentity } from "../agent/lib/monitors"; @@ -10,24 +15,44 @@ import { MonitorStore, monitorStore } from "../server/monitors/store"; import { Pool } from "pg"; import type { ToolContext } from "eve/tools"; -afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); describe("monitor HTTP boundaries", () => { - it.each(["1h", "6h", "1d", "1w", "30d", "720h", "4w"])("accepts supported frequency %s", value => { - expect(frequencyInput.safeParse(value).success).toBe(true); - }); - it.each(["0h", "5m", "1.5h", "31d", "5w", "721h", "-1h", "daily", ""])("rejects unsupported frequency %s", value => { - expect(frequencyInput.safeParse(value).success).toBe(false); - }); + it.each(["1h", "6h", "1d", "1w", "30d", "720h", "4w"])( + "accepts supported frequency %s", + (value) => { + expect(frequencyInput.safeParse(value).success).toBe(true); + }, + ); + it.each(["0h", "5m", "1.5h", "31d", "5w", "721h", "-1h", "daily", ""])( + "rejects unsupported frequency %s", + (value) => { + expect(frequencyInput.safeParse(value).success).toBe(false); + }, + ); it("normalizes equivalent timestamps without losing fractional precision", () => { - expect(monitorTimestamp.parse("2026-10-30T05:00:00.123456-07:00")).toBe("2026-10-30T12:00:00.123456Z"); + expect(monitorTimestamp.parse("2026-10-30T05:00:00.123456-07:00")).toBe( + "2026-10-30T12:00:00.123456Z", + ); expect(monitorTimestamp.parse("2026-10-30T12:00:00.000+00:00")).toBe("2026-10-30T12:00:00Z"); - for (const timestamp of ["2026-10-30T12:00:00Z", "2026-10-30T05:00:00-07:00", "2026-10-30T12:00:00+00:00"]) { - for (const event_type of ["completion", "error"]) expect(eventSchema.parse({ event_type, timestamp, error_message: "quota" })).toMatchObject({ timestamp: "2026-10-30T12:00:00Z" }); + for (const timestamp of [ + "2026-10-30T12:00:00Z", + "2026-10-30T05:00:00-07:00", + "2026-10-30T12:00:00+00:00", + ]) { + for (const event_type of ["completion", "error"]) + expect(eventSchema.parse({ event_type, timestamp, error_message: "quota" })).toMatchObject({ + timestamp: "2026-10-30T12:00:00Z", + }); } }); it("passes selected frequency to create and updates only that field on the existing monitor", async () => { vi.stubEnv("PARALLEL_API_KEY", "test"); - const fetcher = vi.fn(async () => Response.json({ monitor_id: "monitor-id", status: "active", frequency: "6h" })); + const fetcher = vi.fn(async () => + Response.json({ monitor_id: "monitor-id", status: "active", frequency: "6h" }), + ); vi.stubGlobal("fetch", fetcher); await parallelMonitors.create("Public releases", "watch-id", "https://bell.example.com", "6h"); await parallelMonitors.update("monitor-id", "6h"); @@ -38,8 +63,14 @@ describe("monitor HTTP boundaries", () => { }); it.each([ ["monitor.event.detected", { event_group_id: "group-1" }], - ["monitor.execution.completed", { type: "completion", monitor_ts: "completed_2026-10-30T12:00:00Z" }], - ["monitor.execution.failed", { type: "error", error: "Execution failed", id: "error-1", date: "2026-10-30T12:00:00Z" }], + [ + "monitor.execution.completed", + { type: "completion", monitor_ts: "completed_2026-10-30T12:00:00Z" }, + ], + [ + "monitor.execution.failed", + { type: "error", error: "Execution failed", id: "error-1", date: "2026-10-30T12:00:00Z" }, + ], ])("accepts a signed Parallel %s callback", async (type, event) => { const secret = `whsec_${Buffer.alloc(32, 3).toString("base64")}`; vi.stubEnv("PARALLEL_WEBHOOK_SECRET", secret); @@ -48,12 +79,29 @@ describe("monitor HTTP boundaries", () => { const transaction = vi.spyOn(store, "transaction").mockResolvedValue(undefined); const service = new MonitorService(store); const now = new Date(); - const body = JSON.stringify({ type, timestamp: now.toISOString().replace("Z", "+00:00"), data: { monitor_id: "monitor-1", event, metadata: {} } }); - const headers = { "webhook-id": "callback-1", "webhook-timestamp": String(Math.floor(now.getTime() / 1000)), "webhook-signature": new Webhook(secret).sign("callback-1", now, body) }; + const body = JSON.stringify({ + type, + timestamp: now.toISOString().replace("Z", "+00:00"), + data: { monitor_id: "monitor-1", event, metadata: {} }, + }); + const headers = { + "webhook-id": "callback-1", + "webhook-timestamp": String(Math.floor(now.getTime() / 1000)), + "webhook-signature": new Webhook(secret).sign("callback-1", now, body), + }; try { - expect((await receiveWebhook(new Request("https://bell.example/webhook", { method: "POST", headers, body }), () => service)).status).toBe(204); + expect( + ( + await receiveWebhook( + new Request("https://bell.example/webhook", { method: "POST", headers, body }), + () => service, + ) + ).status, + ).toBe(204); expect(transaction).toHaveBeenCalledOnce(); - } finally { await pool.end(); } + } finally { + await pool.end(); + } }); it("handles idle database errors without exposing connection details", async () => { vi.stubEnv("DATABASE_URL", "postgresql://unused"); @@ -62,26 +110,36 @@ describe("monitor HTTP boundaries", () => { try { expect(() => pool.emit("error", new Error("sensitive connection details"))).not.toThrow(); expect(log).toHaveBeenCalledWith("Monitor database connection failed."); - } finally { await pool.end(); } + } finally { + await pool.end(); + } }); it("authenticates both scheduler methods and fails closed without a secret", async () => { const process = vi.fn(async () => ({ processed: 1 })); const service = () => ({ process }) as unknown as MonitorService; - const request = (method: string, token: string) => new Request("https://bell.example/process", { method, headers: { Authorization: `Bearer ${token}` } }); + const request = (method: string, token: string) => + new Request("https://bell.example/process", { + method, + headers: { Authorization: `Bearer ${token}` }, + }); expect((await processMonitors(request("GET", "undefined"), service)).status).toBe(401); vi.stubEnv("CRON_SECRET", "secret"); expect((await processMonitors(request("POST", "wrong"), service)).status).toBe(401); for (const method of ["GET", "POST"]) { const response = await processMonitors(request(method, "secret"), service); - expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); } expect(process).toHaveBeenCalledTimes(2); }); it("does not acknowledge failed persistence, invalid signatures, or oversized callbacks", async () => { vi.stubEnv("PARALLEL_WEBHOOK_SECRET", "test"); - const ingest = vi.fn(async () => { throw new Error("database unavailable"); }); + const ingest = vi.fn(async () => { + throw new Error("database unavailable"); + }); const service = () => ({ ingest }) as unknown as MonitorService; - const request = (body = "{}") => new Request("https://bell.example/webhook", { method: "POST", body }); + const request = (body = "{}") => + new Request("https://bell.example/webhook", { method: "POST", body }); expect((await receiveWebhook(request(), service)).status).toBe(503); ingest.mockRejectedValueOnce(new WebhookVerificationError("invalid")); expect((await receiveWebhook(request(), service)).status).toBe(401); @@ -90,30 +148,91 @@ describe("monitor HTTP boundaries", () => { }); it("accepts a valid signature among multiple signatures", () => { const verifier = new Webhook(`whsec_${Buffer.alloc(32, 3).toString("base64")}`); - const now = new Date(), body = '{"test":true}'; - expect(verifier.verify(body, { "webhook-id": "id", "webhook-timestamp": String(Math.floor(now.getTime()/1000)), "webhook-signature": `v1,invalid ${verifier.sign("id", now, body)}` })).toEqual({ test: true }); + const now = new Date(), + body = '{"test":true}'; + expect( + verifier.verify(body, { + "webhook-id": "id", + "webhook-timestamp": String(Math.floor(now.getTime() / 1000)), + "webhook-signature": `v1,invalid ${verifier.sign("id", now, body)}`, + }), + ).toEqual({ test: true }); }); it("rejects web identities and requires trusted destination attributes", () => { const context = (auth: unknown) => ({ session: { auth: { current: auth } } }) as ToolContext; - expect(() => watchIdentity(context({ authenticator: "basic", principalType: "user", principalId: "linq:fake", issuer: "linq" }))).toThrow(); - expect(() => watchDestination(context({ authenticator: "linq-message", principalType: "user", principalId: "linq:real", issuer: "linq", attributes: {} }))).toThrow(); + expect(() => + watchIdentity( + context({ + authenticator: "basic", + principalType: "user", + principalId: "linq:fake", + issuer: "linq", + }), + ), + ).toThrow(); + expect(() => + watchDestination( + context({ + authenticator: "linq-message", + principalType: "user", + principalId: "linq:real", + issuer: "linq", + attributes: {}, + }), + ), + ).toThrow(); }); it("uses the v1 create contract and passes stable Linq idempotency in the message body", async () => { - vi.stubEnv("PARALLEL_API_KEY", "parallel-test"); vi.stubEnv("LINQ_API_KEY", "linq-test"); - const fetcher = vi.fn(async (url: string) => Response.json(url.includes("parallel.ai") ? { monitor_id: "monitor-id", status: "active", frequency: "1d" } : { message: { id: "message-id" } })); + vi.stubEnv("PARALLEL_API_KEY", "parallel-test"); + vi.stubEnv("LINQ_API_KEY", "linq-test"); + const fetcher = vi.fn(async (url: string) => + Response.json( + url.includes("parallel.ai") + ? { monitor_id: "monitor-id", status: "active", frequency: "1d" } + : { message: { id: "message-id" } }, + ), + ); vi.stubGlobal("fetch", fetcher); await parallelMonitors.create("Public releases", "watch-id", "https://bell.example.com"); - expect(fetcher).toHaveBeenCalledWith("https://api.parallel.ai/v1/monitors", expect.objectContaining({ redirect: "error", headers: expect.objectContaining({ "x-api-key": "parallel-test" }), body: expect.any(String) })); + expect(fetcher).toHaveBeenCalledWith( + "https://api.parallel.ai/v1/monitors", + expect.objectContaining({ + redirect: "error", + headers: expect.objectContaining({ "x-api-key": "parallel-test" }), + body: expect.any(String), + }), + ); const init = (fetcher.mock.calls[0] as unknown as [string, RequestInit])[1]; - expect(JSON.parse(init.body as string)).toMatchObject({ type: "event_stream", frequency: "1d", processor: "lite", settings: { include_backfill: false }, metadata: { bell_watch_id: "watch-id" } }); - await sendLinq({ chat: "chat-id", sender: "+12025550123", line: "+12025550100" }, "text", "stable"); - expect(JSON.parse((fetcher.mock.calls[1] as unknown as [string, RequestInit])[1].body as string)).toEqual({ message: { parts: [{ type: "text", value: "text" }], idempotency_key: "stable" } }); + expect(JSON.parse(init.body as string)).toMatchObject({ + type: "event_stream", + frequency: "1d", + processor: "lite", + settings: { include_backfill: false }, + metadata: { bell_watch_id: "watch-id" }, + }); + await sendLinq( + { chat: "chat-id", sender: "+12025550123", line: "+12025550100" }, + "text", + "stable", + ); + expect( + JSON.parse((fetcher.mock.calls[1] as unknown as [string, RequestInit])[1].body as string), + ).toEqual({ message: { parts: [{ type: "text", value: "text" }], idempotency_key: "stable" } }); }); it("bounds response reads and exposes retry delay without leaking error bodies", async () => { await expect(readBounded(new Response("too long"), 3)).rejects.toThrow("size limit"); vi.stubEnv("PARALLEL_API_KEY", "test"); - vi.stubGlobal("fetch", vi.fn(async () => new Response("secret provider detail", { status: 429, headers: { "Retry-After": "60" } }))); - await expect(parallelMonitors.list()).rejects.toMatchObject({ status: 429, retryAfter: 60_000 }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("secret provider detail", { status: 429, headers: { "Retry-After": "60" } }), + ), + ); + await expect(parallelMonitors.list()).rejects.toMatchObject({ + status: 429, + retryAfter: 60_000, + }); await expect(parallelMonitors.list()).rejects.toBeInstanceOf(ProviderError); }); }); diff --git a/tests/monitors.test.ts b/tests/monitors.test.ts index 2cacbdd..ed345b2 100644 --- a/tests/monitors.test.ts +++ b/tests/monitors.test.ts @@ -13,23 +13,46 @@ if (process.env.CI && !databaseUrl) throw new Error("TEST_MONITOR_DATABASE_URL i const owner: Owner = { issuer: "linq", id: "linq:sender" }; const destination: Destination = { chat: "chat-1", sender: "+12025550123", line: "+12025550100" }; const secret = `whsec_${Buffer.alloc(32, 7).toString("base64")}`; -const detected = { event_type: "event_stream", event_id: "event-1", event_group_id: "group-1", event_date: "2026-10-30", output: { type: "text", content: "A new public release.", basis: [{ citations: [{ url: "https://example.com/release" }] }] } }; +const detected = { + event_type: "event_stream", + event_id: "event-1", + event_group_id: "group-1", + event_date: "2026-10-30", + output: { + type: "text", + content: "A new public release.", + basis: [{ citations: [{ url: "https://example.com/release" }] }], + }, +}; describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { const schema = `monitor_${randomUUID().replaceAll("-", "")}`; let admin: Pool, pool: Pool, store: MonitorStore, service: MonitorService; let provider: MonitorProvider; - let send: ReturnType Promise>>; - let remote: { monitor_id: string; status: string; frequency: string; metadata: { bell_watch_id: string } }[]; + let send: ReturnType< + typeof vi.fn<(destination: Destination, text: string, key: string) => Promise> + >; + let remote: { + monitor_id: string; + status: string; + frequency: string; + metadata: { bell_watch_id: string }; + }[]; beforeAll(async () => { admin = new Pool({ connectionString: databaseUrl }); await admin.query(`CREATE SCHEMA ${schema}`); pool = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}`, max: 8 }); - await pool.query(await readFile(new URL("../migrations/004_parallel_monitors.sql", import.meta.url), "utf8")); - await pool.query(await readFile(new URL("../migrations/005_monitor_frequency.sql", import.meta.url), "utf8")); + await pool.query( + await readFile(new URL("../migrations/004_parallel_monitors.sql", import.meta.url), "utf8"), + ); + await pool.query( + await readFile(new URL("../migrations/005_monitor_frequency.sql", import.meta.url), "utf8"), + ); }); beforeEach(async () => { - await pool.query("TRUNCATE bell_monitor_jobs, bell_monitor_events, bell_monitor_inbox, bell_monitors CASCADE"); + await pool.query( + "TRUNCATE bell_monitor_jobs, bell_monitor_events, bell_monitor_inbox, bell_monitors CASCADE", + ); vi.stubEnv("DATABASE_URL", databaseUrl!); vi.stubEnv("PARALLEL_API_KEY", "test"); vi.stubEnv("PARALLEL_WEBHOOK_SECRET", secret); @@ -41,54 +64,109 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { remote = []; provider = { create: vi.fn(async (_query, id, _origin, frequency = "1d") => { - const result = { monitor_id: `monitor-${id}`, status: "active", frequency, metadata: { bell_watch_id: id } }; - remote.push(result); return result; + const result = { + monitor_id: `monitor-${id}`, + status: "active", + frequency, + metadata: { bell_watch_id: id }, + }; + remote.push(result); + return result; }), list: vi.fn(async () => ({ monitors: remote, next_cursor: null })), events: vi.fn(async () => ({ events: [detected], next_cursor: null })), update: vi.fn(async (id, frequency) => { - const monitor = remote.find(m => m.monitor_id === id)!; + const monitor = remote.find((m) => m.monitor_id === id)!; monitor.frequency = frequency; return { ...monitor }; }), - cancel: vi.fn(async id => { remote.find(m => m.monitor_id === id)!.status = "cancelled"; }), + cancel: vi.fn(async (id) => { + remote.find((m) => m.monitor_id === id)!.status = "cancelled"; + }), }; send = vi.fn(async () => "accepted-message"); store = new MonitorStore(pool); service = new MonitorService(store, provider, send); }); afterEach(() => vi.unstubAllEnvs()); - afterAll(async () => { await pool?.end(); if (admin) { await admin.query(`DROP SCHEMA ${schema} CASCADE`); await admin.end(); } }); - const create = (operation = "session/call") => service.create(owner, destination, operation, "Watch public product releases"); + afterAll(async () => { + await pool?.end(); + if (admin) { + await admin.query(`DROP SCHEMA ${schema} CASCADE`); + await admin.end(); + } + }); + const create = (operation = "session/call") => + service.create(owner, destination, operation, "Watch public product releases"); const listed = async () => (await service.list(owner)).watches; async function callback(id: string, webhookId = randomUUID()) { - const body = JSON.stringify({ type: "monitor.event.detected", timestamp: new Date().toISOString(), data: { monitor_id: `monitor-${id}`, event: { event_group_id: "group-1" }, metadata: { bell_watch_id: id } } }); + const body = JSON.stringify({ + type: "monitor.event.detected", + timestamp: new Date().toISOString(), + data: { + monitor_id: `monitor-${id}`, + event: { event_group_id: "group-1" }, + metadata: { bell_watch_id: id }, + }, + }); const timestamp = new Date(); - return service.ingest(body, { "webhook-id": webhookId, "webhook-timestamp": String(Math.floor(timestamp.getTime() / 1000)), "webhook-signature": new Webhook(secret).sign(webhookId, timestamp, body) }); + return service.ingest(body, { + "webhook-id": webhookId, + "webhook-timestamp": String(Math.floor(timestamp.getTime() / 1000)), + "webhook-signature": new Webhook(secret).sign(webhookId, timestamp, body), + }); } it("upgrades existing daily watches without resetting their state", async () => { const legacySchema = `${schema}_legacy`; await admin.query(`CREATE SCHEMA ${legacySchema}`); - const legacy = new Pool({ connectionString: databaseUrl, options: `-c search_path=${legacySchema}` }); + const legacy = new Pool({ + connectionString: databaseUrl, + options: `-c search_path=${legacySchema}`, + }); try { - await legacy.query(await readFile(new URL("../migrations/004_parallel_monitors.sql", import.meta.url), "utf8")); - await legacy.query("INSERT INTO bell_monitors(id,issuer,owner_id,operation,query,destination,status) VALUES($1,'linq','linq:sender','call','query',$2,'active')", [randomUUID(), destination]); - const migration = await readFile(new URL("../migrations/005_monitor_frequency.sql", import.meta.url), "utf8"); - await legacy.query(migration); await legacy.query(migration); - expect((await legacy.query("SELECT status,frequency,creation_frequency,pending_frequency FROM bell_monitors")).rows).toEqual([{ status: "active", frequency: "1d", creation_frequency: "1d", pending_frequency: null }]); - } finally { await legacy.end(); await admin.query(`DROP SCHEMA ${legacySchema} CASCADE`); } + await legacy.query( + await readFile(new URL("../migrations/004_parallel_monitors.sql", import.meta.url), "utf8"), + ); + await legacy.query( + "INSERT INTO bell_monitors(id,issuer,owner_id,operation,query,destination,status) VALUES($1,'linq','linq:sender','call','query',$2,'active')", + [randomUUID(), destination], + ); + const migration = await readFile( + new URL("../migrations/005_monitor_frequency.sql", import.meta.url), + "utf8", + ); + await legacy.query(migration); + await legacy.query(migration); + expect( + ( + await legacy.query( + "SELECT status,frequency,creation_frequency,pending_frequency FROM bell_monitors", + ) + ).rows, + ).toEqual([ + { status: "active", frequency: "1d", creation_frequency: "1d", pending_frequency: null }, + ]); + } finally { + await legacy.end(); + await admin.query(`DROP SCHEMA ${legacySchema} CASCADE`); + } }); it("defaults to daily and preserves an explicit frequency through creation and listing", async () => { expect(await create()).toMatchObject({ frequency: "1d" }); const watch = await service.create(owner, destination, "six-hour", "Public releases", "6h"); expect(watch.frequency).toBe("6h"); - expect(remote.find(m => m.monitor_id === `monitor-${watch.id}`)?.frequency).toBe("6h"); - expect((await listed()).find(w => w.id === watch.id)?.frequency).toBe("6h"); - await expect(service.create(owner, destination, "six-hour", "Public releases", "1d")).rejects.toThrow("different input"); + expect(remote.find((m) => m.monitor_id === `monitor-${watch.id}`)?.frequency).toBe("6h"); + expect((await listed()).find((w) => w.id === watch.id)?.frequency).toBe("6h"); + await expect( + service.create(owner, destination, "six-hour", "Public releases", "1d"), + ).rejects.toThrow("different input"); }); it("keeps creation replay stable after a confirmed frequency change", async () => { const watch = await create(); - expect(await service.update(owner, watch.id, "6h")).toMatchObject({ frequency: "1d", pending_frequency: "6h" }); + expect(await service.update(owner, watch.id, "6h")).toMatchObject({ + frequency: "1d", + pending_frequency: "6h", + }); expect((await listed())[0]).toMatchObject({ frequency: "1d", pending_frequency: "6h" }); await new MonitorService(new MonitorStore(pool), provider, send).process(); expect(await create()).toMatchObject({ frequency: "6h", pending_frequency: null }); @@ -100,24 +178,36 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { }); it("does not overwrite a confirmed cadence with a delayed creation response", async () => { const watch = await create(); - await service.update(owner, watch.id, "6h"); await service.process(); + await service.update(owner, watch.id, "6h"); + await service.process(); await store.bind(watch.id, `monitor-${watch.id}`, false, "1d"); expect((await store.get(watch.id)).frequency).toBe("6h"); }); it("rejects invalid cadence before creating or changing a watch", async () => { - await expect(service.create(owner, destination, "bad", "Public releases", "5m")).rejects.toThrow(); + await expect( + service.create(owner, destination, "bad", "Public releases", "5m"), + ).rejects.toThrow(); expect(provider.create).not.toHaveBeenCalled(); const watch = await create(); await expect(service.update(owner, watch.id, "31d")).rejects.toThrow(); - expect((await pool.query("SELECT * FROM bell_monitor_jobs WHERE kind='update'")).rowCount).toBe(0); + expect((await pool.query("SELECT * FROM bell_monitor_jobs WHERE kind='update'")).rowCount).toBe( + 0, + ); const prepared = await store.prepare(owner, destination, "not-active", "Public releases"); await expect(service.update(owner, prepared.id, "6h")).rejects.toThrow("active watch"); }); it("scopes updates to their owner and permits only one pending change", async () => { const watch = await create(); - await expect(service.update({ issuer: "linq", id: "linq:other" }, watch.id, "6h")).rejects.toThrow("Watch not found"); - await Promise.all([service.update(owner, watch.id, "7d"), service.update(owner, watch.id, "1w")]); - expect((await pool.query("SELECT * FROM bell_monitor_jobs WHERE kind='update'")).rowCount).toBe(1); + await expect( + service.update({ issuer: "linq", id: "linq:other" }, watch.id, "6h"), + ).rejects.toThrow("Watch not found"); + await Promise.all([ + service.update(owner, watch.id, "7d"), + service.update(owner, watch.id, "1w"), + ]); + expect((await pool.query("SELECT * FROM bell_monitor_jobs WHERE kind='update'")).rowCount).toBe( + 1, + ); await expect(service.update(owner, watch.id, "6h")).rejects.toThrow("pending frequency change"); await service.stop(owner, watch.id); await service.process(); @@ -127,10 +217,17 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { it("retries a lost update response after restart without claiming premature success", async () => { const watch = await create(); const update = provider.update; - provider.update = vi.fn(async (id, frequency) => { await update(id, frequency); throw new Error("response lost"); }); + provider.update = vi.fn(async (id, frequency) => { + await update(id, frequency); + throw new Error("response lost"); + }); await service.update(owner, watch.id, "6h"); await service.process(); - expect((await listed())[0]).toMatchObject({ frequency: "1d", pending_frequency: "6h", problems: [expect.objectContaining({ kind: "update" })] }); + expect((await listed())[0]).toMatchObject({ + frequency: "1d", + pending_frequency: "6h", + problems: [expect.objectContaining({ kind: "update" })], + }); provider.update = update; await pool.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE kind='update'"); await new MonitorService(new MonitorStore(pool), provider, send).process(); @@ -139,13 +236,22 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { }); it("retains a failed update for repair and rejects an unconfirmed response", async () => { const watch = await create(); - provider.update = vi.fn(async id => ({ ...remote[0], monitor_id: id, frequency: "1d" })); - await service.update(owner, watch.id, "6h"); await service.process(); + provider.update = vi.fn(async (id) => ({ ...remote[0], monitor_id: id, frequency: "1d" })); + await service.update(owner, watch.id, "6h"); + await service.process(); expect((await store.get(watch.id)).pending_frequency).toBe("6h"); - provider.update = vi.fn(async () => { throw new ProviderError(403); }); + provider.update = vi.fn(async () => { + throw new ProviderError(403); + }); await pool.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE kind='update'"); await service.process(); - expect((await listed())[0]).toMatchObject({ frequency: "1d", pending_frequency: "6h", problems: [expect.objectContaining({ kind: "update", failed: true, error: "provider_http_403" })] }); + expect((await listed())[0]).toMatchObject({ + frequency: "1d", + pending_frequency: "6h", + problems: [ + expect.objectContaining({ kind: "update", failed: true, error: "provider_http_403" }), + ], + }); await service.stop(owner, watch.id); expect((await store.get(watch.id)).pending_frequency).toBeNull(); }); @@ -153,7 +259,7 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { const watch = await create(); await service.update(owner, watch.id, "6h"); const get = store.get.bind(store); - vi.spyOn(store, "get").mockImplementationOnce(async id => { + vi.spyOn(store, "get").mockImplementationOnce(async (id) => { const snapshot = await get(id); await new MonitorStore(pool).stop(owner, id); return snapshot; @@ -165,27 +271,49 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { it("cancels after an in-flight update completes", async () => { const watch = await create(); let release!: () => void; - const gate = new Promise(resolve => { release = resolve; }); + const gate = new Promise((resolve) => { + release = resolve; + }); const update = provider.update; - provider.update = vi.fn(async (id, frequency) => { await gate; return update(id, frequency); }); + provider.update = vi.fn(async (id, frequency) => { + await gate; + return update(id, frequency); + }); await service.update(owner, watch.id, "6h"); const processing = service.process(); let stopping: Promise | undefined; try { await vi.waitFor(() => expect(provider.update).toHaveBeenCalledOnce()); stopping = new MonitorStore(pool).stop(owner, watch.id); - await vi.waitFor(async () => expect((await pool.query("SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%owner_id=$3 FOR UPDATE%' ")).rowCount).toBeGreaterThan(0)); - } finally { release(); await Promise.all([processing, stopping]); } + await vi.waitFor(async () => + expect( + ( + await pool.query( + "SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%owner_id=$3 FOR UPDATE%' ", + ) + ).rowCount, + ).toBeGreaterThan(0), + ); + } finally { + release(); + await Promise.all([processing, stopping]); + } await service.process(); expect(remote[0]).toMatchObject({ status: "cancelled", frequency: "6h" }); expect((await store.get(watch.id)).pending_frequency).toBeNull(); }); it("processes valid records while surfacing a malformed record as incomplete recovery", async () => { await create(); - provider.events = vi.fn(async () => ({ events: [detected, { event_type: "completion", timestamp: "invalid" }] })); + provider.events = vi.fn(async () => ({ + events: [detected, { event_type: "completion", timestamp: "invalid" }], + })); await service.process(); expect(send).toHaveBeenCalledOnce(); - expect((await listed())[0]).toMatchObject({ recovery_gap: true, last_scan_at: null, problems: [expect.objectContaining({ kind: "sync", error: "invalid_monitor_event" })] }); + expect((await listed())[0]).toMatchObject({ + recovery_gap: true, + last_scan_at: null, + problems: [expect.objectContaining({ kind: "sync", error: "invalid_monitor_event" })], + }); await pool.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE kind='sync'"); await service.process(); expect(send).toHaveBeenCalledOnce(); @@ -194,13 +322,23 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { const [a, b] = await Promise.all([create(), create()]); expect(a.id).toBe(b.id); expect(provider.create).toHaveBeenCalledTimes(1); - expect(await service.list({ issuer: "linq", id: "linq:other" })).toMatchObject({ watches: [], next_cursor: null }); - await expect(service.stop({ issuer: "linq", id: "linq:other" }, a.id)).rejects.toThrow("Watch not found"); - await expect(service.create(owner, destination, "session/call", "Changed query")).rejects.toThrow("different input"); + expect(await service.list({ issuer: "linq", id: "linq:other" })).toMatchObject({ + watches: [], + next_cursor: null, + }); + await expect(service.stop({ issuer: "linq", id: "linq:other" }, a.id)).rejects.toThrow( + "Watch not found", + ); + await expect( + service.create(owner, destination, "session/call", "Changed query"), + ).rejects.toThrow("different input"); }); it("reconciles an accepted creation with a lost response without another create", async () => { const original = provider.create; - provider.create = vi.fn(async (query, id, origin) => { await original(query, id, origin); throw new Error("response lost"); }); + provider.create = vi.fn(async (query, id, origin) => { + await original(query, id, origin); + throw new Error("response lost"); + }); const watch = await create(); expect(watch.status).toBe("uncertain"); await create(); @@ -222,7 +360,8 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { expect((await listed())[0].updates).toHaveLength(1); }); it("reuses frozen delivery and key after an accepted send loses its response", async () => { - const watch = await create(); await callback(watch.id); + const watch = await create(); + await callback(watch.id); send.mockRejectedValueOnce(new Error("response lost")); await service.process(); await pool.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE kind='send'"); @@ -231,9 +370,12 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { expect(send.mock.calls[0]).toEqual(send.mock.calls[1]); }); it("stops queued deliveries and cancels without webhook or messaging configuration", async () => { - const watch = await create(); await callback(watch.id); - vi.stubEnv("LINQ_API_KEY", ""); vi.stubEnv("PARALLEL_WEBHOOK_SECRET", ""); - await service.stop(owner, watch.id); await service.process(); + const watch = await create(); + await callback(watch.id); + vi.stubEnv("LINQ_API_KEY", ""); + vi.stubEnv("PARALLEL_WEBHOOK_SECRET", ""); + await service.stop(owner, watch.id); + await service.process(); expect(send).not.toHaveBeenCalled(); expect(provider.cancel).toHaveBeenCalledWith(`monitor-${watch.id}`); expect((await listed())[0].status).toBe("cancelled"); @@ -242,9 +384,13 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { const watch = await create(); await pool.query("UPDATE bell_monitors SET next_scan_at=now()+interval '1 hour'"); await pool.query("DELETE FROM bell_monitor_jobs"); - await store.enqueue("send", "queued-send", watch.id, { text: "update", key: "stable", eventId: "event-1" }); + await store.enqueue("send", "queued-send", watch.id, { + text: "update", + key: "stable", + eventId: "event-1", + }); const get = store.get.bind(store); - vi.spyOn(store, "get").mockImplementationOnce(async id => { + vi.spyOn(store, "get").mockImplementationOnce(async (id) => { const snapshot = await get(id); await new MonitorService(new MonitorStore(pool), provider, send).stop(owner, id); return snapshot; @@ -256,15 +402,22 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { it("waits for an in-flight send before confirming stop", async () => { const watch = await create(); let release!: () => void; - const gate = new Promise(resolve => { release = resolve; }); - send.mockImplementationOnce(async () => { await gate; return "accepted-message"; }); + const gate = new Promise((resolve) => { + release = resolve; + }); + send.mockImplementationOnce(async () => { + await gate; + return "accepted-message"; + }); const processing = service.process(); let stopping: Promise | undefined; try { await vi.waitFor(() => expect(send).toHaveBeenCalledOnce()); stopping = new MonitorStore(pool).stop(owner, watch.id); await vi.waitFor(async () => { - const waiting = await pool.query("SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%owner_id=$3 FOR UPDATE%'"); + const waiting = await pool.query( + "SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%owner_id=$3 FOR UPDATE%'", + ); expect(waiting.rowCount).toBeGreaterThan(0); }); expect((await store.get(watch.id)).stopped).toBe(false); @@ -277,45 +430,94 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { expect(send).toHaveBeenCalledOnce(); }); it.each([ - ["monitor.execution.completed", { type: "completion", monitor_ts: "completed_2026-10-30T12:00:00Z" }, "completed"], - ["monitor.execution.failed", { type: "error", error: "Execution failed", id: "error-1", date: "2026-10-30T12:00:00Z" }, "failed"], + [ + "monitor.execution.completed", + { type: "completion", monitor_ts: "completed_2026-10-30T12:00:00Z" }, + "completed", + ], + [ + "monitor.execution.failed", + { type: "error", error: "Execution failed", id: "error-1", date: "2026-10-30T12:00:00Z" }, + "failed", + ], ])("stores and processes a signed %s without sending an alert", async (type, event, health) => { const watch = await create(); provider.events = vi.fn(async () => ({ events: [] })); - const now = new Date(), id = randomUUID(); - const body = JSON.stringify({ type, timestamp: now.toISOString().replace("Z", "+00:00"), data: { monitor_id: `monitor-${watch.id}`, event } }); - await service.ingest(body, { "webhook-id": id, "webhook-timestamp": String(Math.floor(now.getTime() / 1000)), "webhook-signature": new Webhook(secret).sign(id, now, body) }); - expect((await pool.query("SELECT id FROM bell_monitor_inbox WHERE id=$1", [id])).rowCount).toBe(1); + const now = new Date(), + id = randomUUID(); + const body = JSON.stringify({ + type, + timestamp: now.toISOString().replace("Z", "+00:00"), + data: { monitor_id: `monitor-${watch.id}`, event }, + }); + await service.ingest(body, { + "webhook-id": id, + "webhook-timestamp": String(Math.floor(now.getTime() / 1000)), + "webhook-signature": new Webhook(secret).sign(id, now, body), + }); + expect((await pool.query("SELECT id FROM bell_monitor_inbox WHERE id=$1", [id])).rowCount).toBe( + 1, + ); await service.process(); expect((await store.get(watch.id)).health).toBe(health); expect(send).not.toHaveBeenCalled(); }); it("suppresses delivery when the sender is removed from the allowlist", async () => { - const watch = await create(); await callback(watch.id); + const watch = await create(); + await callback(watch.id); vi.stubEnv("BELL_LINQ_ALLOWED_SENDERS", ""); await service.process(); expect(send).not.toHaveBeenCalled(); }); it("rejects forged and stale signatures without durable ingestion", async () => { - await expect(service.ingest("{}", { "webhook-id": "fake", "webhook-timestamp": "0", "webhook-signature": "v1,fake" })).rejects.toThrow(); - const date = new Date(Date.now() - 600_000), body = "{}"; - await expect(service.ingest(body, { "webhook-id": "old", "webhook-timestamp": String(Math.floor(date.getTime()/1000)), "webhook-signature": new Webhook(secret).sign("old", date, body) })).rejects.toThrow(); + await expect( + service.ingest("{}", { + "webhook-id": "fake", + "webhook-timestamp": "0", + "webhook-signature": "v1,fake", + }), + ).rejects.toThrow(); + const date = new Date(Date.now() - 600_000), + body = "{}"; + await expect( + service.ingest(body, { + "webhook-id": "old", + "webhook-timestamp": String(Math.floor(date.getTime() / 1000)), + "webhook-signature": new Webhook(secret).sign("old", date, body), + }), + ).rejects.toThrow(); expect((await pool.query("SELECT * FROM bell_monitor_inbox")).rowCount).toBe(0); }); it("recovers valid events beside offset timestamps and preserves equivalent scan anchors", async () => { const watch = await create(); - await pool.query("UPDATE bell_monitors SET scan_anchor='completion:2026-10-30T12:00:00Z' WHERE id=$1", [watch.id]); - provider.events = vi.fn(async () => ({ events: [ - { event_type: "completion", timestamp: "2026-10-30T05:00:00-07:00" }, - { event_type: "error", timestamp: "2026-10-29T12:00:00+00:00", error_message: "quota" }, detected, - ] })); + await pool.query( + "UPDATE bell_monitors SET scan_anchor='completion:2026-10-30T12:00:00Z' WHERE id=$1", + [watch.id], + ); + provider.events = vi.fn(async () => ({ + events: [ + { event_type: "completion", timestamp: "2026-10-30T05:00:00-07:00" }, + { event_type: "error", timestamp: "2026-10-29T12:00:00+00:00", error_message: "quota" }, + detected, + ], + })); await service.process(); expect(send).toHaveBeenCalledOnce(); - expect((await listed())[0]).toMatchObject({ health: "completed", recovery_gap: false, problems: [] }); + expect((await listed())[0]).toMatchObject({ + health: "completed", + recovery_gap: false, + problems: [], + }); }); it("records completion and errors without event IDs and does not send health messages", async () => { await create(); - provider.events = vi.fn(async () => ({ events: [{ event_type: "completion", timestamp: "2026-10-30T12:00:00Z" }, { event_type: "error", timestamp: "2026-10-29T12:00:00Z", error_message: "quota" }], next_cursor: null })); + provider.events = vi.fn(async () => ({ + events: [ + { event_type: "completion", timestamp: "2026-10-30T12:00:00Z" }, + { event_type: "error", timestamp: "2026-10-29T12:00:00Z", error_message: "quota" }, + ], + next_cursor: null, + })); await service.process(); expect(send).not.toHaveBeenCalled(); const [watch] = await listed(); @@ -332,7 +534,10 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { }); it("cancels an uncertain remote creation when stop arrives before reconciliation", async () => { const original = provider.create; - provider.create = vi.fn(async (query, id, origin) => { await original(query, id, origin); throw new Error("lost"); }); + provider.create = vi.fn(async (query, id, origin) => { + await original(query, id, origin); + throw new Error("lost"); + }); const watch = await create(); await service.stop(owner, watch.id); await service.process(); @@ -357,14 +562,19 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { await callback(watch.id); send.mockRejectedValueOnce(new Error("interrupted")); await service.process(); - await pool.query("UPDATE bell_monitor_jobs SET next_at=now(),lease_until=now()-interval '1 minute',lease_token=$1 WHERE kind='send'", [randomUUID()]); + await pool.query( + "UPDATE bell_monitor_jobs SET next_at=now(),lease_until=now()-interval '1 minute',lease_token=$1 WHERE kind='send'", + [randomUUID()], + ); await new MonitorService(new MonitorStore(pool), provider, send).process(); expect(send.mock.calls[0]).toEqual(send.mock.calls[1]); }); it("records local suppression when the provider key is missing", async () => { const watch = await create(); vi.stubEnv("PARALLEL_API_KEY", ""); - provider.cancel = vi.fn(async () => { throw new Error("missing key"); }); + provider.cancel = vi.fn(async () => { + throw new Error("missing key"); + }); expect((await service.stop(owner, watch.id)).status).toBe("cancel_pending"); await service.process(); expect((await store.get(watch.id)).stopped).toBe(true); @@ -376,7 +586,9 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { await service.process(); await pool.query("UPDATE bell_monitors SET next_scan_at=now() WHERE id=$1", [watch.id]); const newer = { ...detected, event_id: "event-2", event_group_id: "group-2" }; - provider.events = vi.fn(async (_id, _group, cursor) => cursor ? { events: [newer], next_cursor: null } : { events: [newer], next_cursor: "page-2" }); + provider.events = vi.fn(async (_id, _group, cursor) => + cursor ? { events: [newer], next_cursor: null } : { events: [newer], next_cursor: "page-2" }, + ); await service.process(); expect(send).toHaveBeenCalledTimes(2); expect((await listed())[0].recovery_gap).toBe(true); @@ -385,16 +597,24 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { const watch = await create(); await service.process(); expect(send).toHaveBeenCalledTimes(1); - const page = (id: number, next: string | null) => ({ events: [{ ...detected, event_id: `event-${id}`, event_group_id: `group-${id}` }], next_cursor: next }); + const page = (id: number, next: string | null) => ({ + events: [{ ...detected, event_id: `event-${id}`, event_group_id: `group-${id}` }], + next_cursor: next, + }); const pages: Record = { - start: page(2, "cursor-2"), "cursor-2": page(3, "cursor-3"), "cursor-3": page(4, "cursor-4"), + start: page(2, "cursor-2"), + "cursor-2": page(3, "cursor-3"), + "cursor-3": page(4, "cursor-4"), // The oldest page overlaps the previous scan's anchor, so coverage is complete and event-1 must not be resent. - "cursor-4": { events: [{ ...detected, event_id: "event-5", event_group_id: "group-5" }, detected], next_cursor: null }, + "cursor-4": { + events: [{ ...detected, event_id: "event-5", event_group_id: "group-5" }, detected], + next_cursor: null, + }, }; const fetched: string[] = []; provider.events = vi.fn(async (_id, _group, cursor) => { fetched.push(cursor ?? "start"); - await new Promise(resolve => setTimeout(resolve, 80)); + await new Promise((resolve) => setTimeout(resolve, 80)); return pages[cursor ?? "start"]; }); await pool.query("UPDATE bell_monitors SET next_scan_at=now() WHERE id=$1", [watch.id]); @@ -403,7 +623,16 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { const saved: unknown[] = []; for (let run = 0; run < 10; run++) { await slow.process(); - const job = (await pool.query<{ attempts: number; failed: boolean; last_error: string | null; payload: { cursor?: string } }>("SELECT attempts,failed,last_error,payload FROM bell_monitor_jobs WHERE id=$1", [`sync:${watch.id}`])).rows[0]; + const job = ( + await pool.query<{ + attempts: number; + failed: boolean; + last_error: string | null; + payload: { cursor?: string }; + }>("SELECT attempts,failed,last_error,payload FROM bell_monitor_jobs WHERE id=$1", [ + `sync:${watch.id}`, + ]) + ).rows[0]; if (!job) break; expect(job).toMatchObject({ attempts: 0, failed: false, last_error: null }); saved.push(job.payload.cursor); @@ -415,16 +644,19 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { expect(listedWatch).toMatchObject({ recovery_gap: false, problems: [] }); expect(listedWatch.last_scan_at).not.toBeNull(); expect(send).toHaveBeenCalledTimes(5); - expect(new Set(send.mock.calls.map(call => call[2])).size).toBe(5); + expect(new Set(send.mock.calls.map((call) => call[2])).size).toBe(5); }); it("keeps a budget-limited recovery scan alive past the eight-attempt limit", async () => { const watch = await create(); await service.process(); provider.events = vi.fn(async (_id, _group, cursor) => { const read = cursor ? Number(cursor.slice("cursor-".length)) : 1; - await new Promise(resolve => setTimeout(resolve, 80)); + await new Promise((resolve) => setTimeout(resolve, 80)); if (read > 11) return { events: [detected], next_cursor: null }; - return { events: [{ ...detected, event_id: `event-${read}`, event_group_id: `group-${read}` }], next_cursor: `cursor-${read + 1}` }; + return { + events: [{ ...detected, event_id: `event-${read}`, event_group_id: `group-${read}` }], + next_cursor: `cursor-${read + 1}`, + }; }); await pool.query("UPDATE bell_monitors SET next_scan_at=now() WHERE id=$1", [watch.id]); // Twelve pages at one page per budget-limited run take more runs than the eight attempts that used to mark the job failed. @@ -432,67 +664,109 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { let runs = 0; for (; runs < 20; runs++) { await slow.process(); - const job = (await pool.query<{ attempts: number; failed: boolean }>("SELECT attempts,failed FROM bell_monitor_jobs WHERE id=$1", [`sync:${watch.id}`])).rows[0]; + const job = ( + await pool.query<{ attempts: number; failed: boolean }>( + "SELECT attempts,failed FROM bell_monitor_jobs WHERE id=$1", + [`sync:${watch.id}`], + ) + ).rows[0]; if (!job) break; expect(job).toMatchObject({ attempts: 0, failed: false }); } expect(runs).toBeGreaterThan(8); await service.process(); expect((await listed())[0]).toMatchObject({ recovery_gap: false, problems: [] }); - expect(new Set(send.mock.calls.map(call => call[2])).size).toBe(send.mock.calls.length); + expect(new Set(send.mock.calls.map((call) => call[2])).size).toBe(send.mock.calls.length); }); it("restarts a recovery scan that already exhausted its attempts on the next scheduled check", async () => { const watch = await create(); await service.process(); await pool.query("UPDATE bell_monitors SET next_scan_at=now() WHERE id=$1", [watch.id]); - provider.events = vi.fn(async () => { throw new Error("recovery unavailable"); }); + provider.events = vi.fn(async () => { + throw new Error("recovery unavailable"); + }); for (let attempt = 0; attempt < 8; attempt++) { await service.process(); - await pool.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE id=$1", [`sync:${watch.id}`]); + await pool.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE id=$1", [ + `sync:${watch.id}`, + ]); } - expect((await pool.query<{ failed: boolean }>("SELECT failed FROM bell_monitor_jobs WHERE id=$1", [`sync:${watch.id}`])).rows[0]).toMatchObject({ failed: true }); + expect( + ( + await pool.query<{ failed: boolean }>("SELECT failed FROM bell_monitor_jobs WHERE id=$1", [ + `sync:${watch.id}`, + ]) + ).rows[0], + ).toMatchObject({ failed: true }); provider.events = vi.fn(async () => ({ events: [detected], next_cursor: null })); await service.process(); expect(provider.events).not.toHaveBeenCalled(); await pool.query("UPDATE bell_monitors SET next_scan_at=now() WHERE id=$1", [watch.id]); await service.process(); expect(provider.events).toHaveBeenCalled(); - expect((await pool.query("SELECT 1 FROM bell_monitor_jobs WHERE id=$1", [`sync:${watch.id}`])).rowCount).toBe(0); + expect( + (await pool.query("SELECT 1 FROM bell_monitor_jobs WHERE id=$1", [`sync:${watch.id}`])) + .rowCount, + ).toBe(0); expect((await listed())[0].last_scan_at).not.toBeNull(); }); - it.each(["malformed record", "resume limit"])("recovers after a saved scan fails on its %s", async failure => { - const watch = await create(); - let clock = Date.now(), repaired = false; - vi.spyOn(Date, "now").mockImplementation(() => clock); - const lastPage = failure === "malformed record" ? 3 : 63; - provider.events = vi.fn(async (_id, _group, cursor) => { - clock += repaired && failure === "malformed record" ? 1 : 7_000; - const page = Number(cursor ?? 0); - const events: unknown[] = [{ event_type: "completion", timestamp: new Date(Date.UTC(2026, 9, 30) - page * 3_600_000).toISOString() }]; - if (failure === "malformed record" && !page) events.push(repaired ? detected : { event_type: "completion", timestamp: "invalid" }); - if (failure === "resume limit" && page === lastPage) events.push(detected); - return { events, next_cursor: page < lastPage ? String(page + 1) : null }; - }); - let failed = false; - for (let run = 0; run < 28; run++) { - await service.process(); - failed = (await pool.query("SELECT failed FROM bell_monitor_jobs WHERE id=$1", [`sync:${watch.id}`])).rows[0]?.failed === true; - if (failed) break; - await pool.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE id=$1", [`sync:${watch.id}`]); - } - expect(failed).toBe(true); - repaired = true; - await pool.query("UPDATE bell_monitors SET next_scan_at=now() WHERE id=$1", [watch.id]); - for (let run = 0; run < 3; run++) await new MonitorService(new MonitorStore(pool), provider, send).process(); - expect((await pool.query("SELECT 1 FROM bell_monitor_jobs WHERE id=$1", [`sync:${watch.id}`])).rowCount).toBe(0); - expect((await store.get(watch.id)).last_scan_at).not.toBeNull(); - expect(send).toHaveBeenCalledOnce(); - }); + it.each(["malformed record", "resume limit"])( + "recovers after a saved scan fails on its %s", + async (failure) => { + const watch = await create(); + let clock = Date.now(), + repaired = false; + vi.spyOn(Date, "now").mockImplementation(() => clock); + const lastPage = failure === "malformed record" ? 3 : 63; + provider.events = vi.fn(async (_id, _group, cursor) => { + clock += repaired && failure === "malformed record" ? 1 : 7_000; + const page = Number(cursor ?? 0); + const events: unknown[] = [ + { + event_type: "completion", + timestamp: new Date(Date.UTC(2026, 9, 30) - page * 3_600_000).toISOString(), + }, + ]; + if (failure === "malformed record" && !page) + events.push(repaired ? detected : { event_type: "completion", timestamp: "invalid" }); + if (failure === "resume limit" && page === lastPage) events.push(detected); + return { events, next_cursor: page < lastPage ? String(page + 1) : null }; + }); + let failed = false; + for (let run = 0; run < 28; run++) { + await service.process(); + failed = + ( + await pool.query("SELECT failed FROM bell_monitor_jobs WHERE id=$1", [ + `sync:${watch.id}`, + ]) + ).rows[0]?.failed === true; + if (failed) break; + await pool.query("UPDATE bell_monitor_jobs SET next_at=now() WHERE id=$1", [ + `sync:${watch.id}`, + ]); + } + expect(failed).toBe(true); + repaired = true; + await pool.query("UPDATE bell_monitors SET next_scan_at=now() WHERE id=$1", [watch.id]); + for (let run = 0; run < 3; run++) + await new MonitorService(new MonitorStore(pool), provider, send).process(); + expect( + (await pool.query("SELECT 1 FROM bell_monitor_jobs WHERE id=$1", [`sync:${watch.id}`])) + .rowCount, + ).toBe(0); + expect((await store.get(watch.id)).last_scan_at).not.toBeNull(); + expect(send).toHaveBeenCalledOnce(); + }, + ); it("keeps an active watch reachable behind newer cancelled watches and pages the rest", async () => { const active = await create(); for (let index = 0; index < 50; index++) { - await pool.query(`INSERT INTO bell_monitors(id,issuer,owner_id,operation,query,destination,status,creation_frequency,frequency,created_at) - VALUES($1,$2,$3,$4,'Cancelled watch',$5,'cancelled','1d','1d',now()+($6||' seconds')::interval)`, [randomUUID(), owner.issuer, owner.id, `cancelled-${index}`, destination, index + 1]); + await pool.query( + `INSERT INTO bell_monitors(id,issuer,owner_id,operation,query,destination,status,creation_frequency,frequency,created_at) + VALUES($1,$2,$3,$4,'Cancelled watch',$5,'cancelled','1d','1d',now()+($6||' seconds')::interval)`, + [randomUUID(), owner.issuer, owner.id, `cancelled-${index}`, destination, index + 1], + ); } const first = await service.list(owner); expect(first.watches).toHaveLength(50); @@ -501,29 +775,56 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { const second = await service.list(owner, { cursor: first.next_cursor! }); expect(second.watches).toHaveLength(1); expect(second.next_cursor).toBeNull(); - expect(new Set([...first.watches, ...second.watches].map(w => w.id)).size).toBe(51); - expect(await service.list(owner, { status: "open" })).toMatchObject({ watches: [expect.objectContaining({ id: active.id })], next_cursor: null }); + expect(new Set([...first.watches, ...second.watches].map((w) => w.id)).size).toBe(51); + expect(await service.list(owner, { status: "open" })).toMatchObject({ + watches: [expect.objectContaining({ id: active.id })], + next_cursor: null, + }); const closed = await service.list(owner, { status: "closed" }); expect(closed.watches).toHaveLength(50); - expect(closed.watches.every(w => w.status === "cancelled")).toBe(true); + expect(closed.watches.every((w) => w.status === "cancelled")).toBe(true); expect(closed.next_cursor).toBeNull(); await expect(service.list(owner, { cursor: "not-a-cursor" })).rejects.toThrow("page cursor"); await expect(service.list(owner, { status: "retired" })).rejects.toThrow(); }); - it.each(["2026-10-31T12:00:00.123000Z", "2026-10-31T12:00:00.123456Z"])("pages every watch sharing timestamp %s", async timestamp => { - await pool.query(`INSERT INTO bell_monitors(id,issuer,owner_id,operation,query,destination,status,created_at) - SELECT gen_random_uuid(),$1,$2,'page-'||i,'Public releases',$3,'active',$4::timestamptz FROM generate_series(1,51) i`, [owner.issuer, owner.id, destination, timestamp]); - const first = await service.list(owner); - expect(first.watches).toHaveLength(50); - expect(first.next_cursor).not.toBeNull(); - const second = await service.list(owner, { cursor: first.next_cursor! }); - expect(second.watches).toHaveLength(1); - expect(second.next_cursor).toBeNull(); - expect(new Set([...first.watches, ...second.watches].map(w => w.id)).size).toBe(51); - }); + it.each(["2026-10-31T12:00:00.123000Z", "2026-10-31T12:00:00.123456Z"])( + "pages every watch sharing timestamp %s", + async (timestamp) => { + await pool.query( + `INSERT INTO bell_monitors(id,issuer,owner_id,operation,query,destination,status,created_at) + SELECT gen_random_uuid(),$1,$2,'page-'||i,'Public releases',$3,'active',$4::timestamptz FROM generate_series(1,51) i`, + [owner.issuer, owner.id, destination, timestamp], + ); + const first = await service.list(owner); + expect(first.watches).toHaveLength(50); + expect(first.next_cursor).not.toBeNull(); + const second = await service.list(owner, { cursor: first.next_cursor! }); + expect(second.watches).toHaveLength(1); + expect(second.next_cursor).toBeNull(); + expect(new Set([...first.watches, ...second.watches].map((w) => w.id)).size).toBe(51); + }, + ); it("keeps valid citations when another URL is unsafe", async () => { await create(); - provider.events = vi.fn(async () => ({ events: [{ ...detected, output: { ...detected.output, basis: [{ citations: [{ url: "http://localhost/private" }, { url: "https://example.com/release" }] }] } }], next_cursor: null })); + provider.events = vi.fn(async () => ({ + events: [ + { + ...detected, + output: { + ...detected.output, + basis: [ + { + citations: [ + { url: "http://localhost/private" }, + { url: "https://example.com/release" }, + ], + }, + ], + }, + }, + ], + next_cursor: null, + })); await service.process(); expect(send.mock.calls[0][1]).toContain("https://example.com/release"); expect(send.mock.calls[0][1]).not.toContain("localhost"); @@ -534,5 +835,4 @@ describe.skipIf(!databaseUrl)("Persistent Parallel watches", () => { expect(provider.events).not.toHaveBeenCalled(); expect(send).not.toHaveBeenCalled(); }); - }); diff --git a/tests/onepassword-availability.test.ts b/tests/onepassword-availability.test.ts index 361b0c4..b28b4d8 100644 --- a/tests/onepassword-availability.test.ts +++ b/tests/onepassword-availability.test.ts @@ -8,7 +8,10 @@ import status from "../agent/tools/onepassword_status"; import disconnect from "../agent/tools/onepassword_disconnect"; import { onePasswordGet, onePasswordPost } from "../server/onepassword/http"; import { browserbaseLaunchOptions } from "../server/browser/browser-session"; -import { requireReusableContexts, withPersonalBrowserLock } from "../server/onepassword/browser-policy"; +import { + requireReusableContexts, + withPersonalBrowserLock, +} from "../server/onepassword/browser-policy"; describe("personal 1Password rollout", () => { let ctx: ToolContext; @@ -18,25 +21,50 @@ describe("personal 1Password rollout", () => { vi.stubEnv("BROWSERBASE_1PASSWORD_EXTENSION_ID", ""); vi.stubEnv("BROWSERBASE_CONTEXT_ID", ""); mocks.service.mockReturnValue({ disconnect: mocks.disconnect }); - ctx = { session: { id: "conversation", auth: { current: { principalType: "user", authenticator: "linq-message", issuer: "linq", principalId: "linq:sender" } } }, getToken: vi.fn() } as unknown as ToolContext; + ctx = { + session: { + id: "conversation", + auth: { + current: { + principalType: "user", + authenticator: "linq-message", + issuer: "linq", + principalId: "linq:sender", + }, + }, + }, + getToken: vi.fn(), + } as unknown as ToolContext; }); afterEach(() => vi.unstubAllEnvs()); - it.each([undefined, "", "false", "0", "TRUE"])("blocks consent and login before side effects when enabled=%s", async enabled => { - vi.stubEnv("ONEPASSWORD_ENABLED", enabled); - vi.stubEnv("ONEPASSWORD_CLIENT_ID", "saved-client"); - vi.stubEnv("ONEPASSWORD_CLIENT_SECRET", "saved-secret"); - await expect(connect.execute({}, ctx)).rejects.toThrow("personal_1password_disabled"); - await expect(login.execute({ loginUrl: "https://example.com/login", successUrl: "https://example.com/home" }, ctx)).rejects.toThrow("personal_1password_disabled"); - expect(ctx.getToken).not.toHaveBeenCalled(); - expect(mocks.service).not.toHaveBeenCalled(); - }); + it.each([undefined, "", "false", "0", "TRUE"])( + "blocks consent and login before side effects when enabled=%s", + async (enabled) => { + vi.stubEnv("ONEPASSWORD_ENABLED", enabled); + vi.stubEnv("ONEPASSWORD_CLIENT_ID", "saved-client"); + vi.stubEnv("ONEPASSWORD_CLIENT_SECRET", "saved-secret"); + await expect(connect.execute({}, ctx)).rejects.toThrow("personal_1password_disabled"); + await expect( + login.execute( + { loginUrl: "https://example.com/login", successUrl: "https://example.com/home" }, + ctx, + ), + ).rejects.toThrow("personal_1password_disabled"); + expect(ctx.getToken).not.toHaveBeenCalled(); + expect(mocks.service).not.toHaveBeenCalled(); + }, + ); it("reports disabled without requiring a database or provider configuration", async () => { expect(await status.execute({}, ctx)).toEqual({ status: "disabled" }); expect(mocks.service).not.toHaveBeenCalled(); }); it("rejects connection-start links without calling the provider or setting cookies", async () => { const service = vi.fn(); - const response = await onePasswordGet(new Request(`https://bell.example/api/onepassword/start?state=${"s".repeat(43)}`), "start", service); + const response = await onePasswordGet( + new Request(`https://bell.example/api/onepassword/start?state=${"s".repeat(43)}`), + "start", + service, + ); expect(response.status).toBe(503); expect(response.headers.get("location")).toBeNull(); expect(response.headers.get("set-cookie")).toBeNull(); @@ -46,9 +74,23 @@ describe("personal 1Password rollout", () => { it("keeps disconnect and previously issued callback completion available", async () => { mocks.disconnect.mockResolvedValue({ status: "disconnected" }); expect(await disconnect.execute({}, ctx)).toEqual({ status: "disconnected" }); - expect(mocks.disconnect).toHaveBeenCalledWith({ type: "user", issuer: "linq", id: "linq:sender" }); - const capture = vi.fn().mockResolvedValue("https://bell.example/eve/v1/connections/op/callback/id/hook"); - const response = await onePasswordPost(new Request("https://bell.example/api/onepassword/complete", { method: "POST", headers: { origin: "https://bell.example", "content-type": "application/json" }, body: JSON.stringify({ state: "s".repeat(43), code: "issued-code" }) }), "complete", () => ({ config: { origin: "https://bell.example" }, capture }) as never); + expect(mocks.disconnect).toHaveBeenCalledWith({ + type: "user", + issuer: "linq", + id: "linq:sender", + }); + const capture = vi + .fn() + .mockResolvedValue("https://bell.example/eve/v1/connections/op/callback/id/hook"); + const response = await onePasswordPost( + new Request("https://bell.example/api/onepassword/complete", { + method: "POST", + headers: { origin: "https://bell.example", "content-type": "application/json" }, + body: JSON.stringify({ state: "s".repeat(43), code: "issued-code" }), + }), + "complete", + () => ({ config: { origin: "https://bell.example" }, capture }) as never, + ); expect(response.status).toBe(200); expect(capture).toHaveBeenCalledOnce(); }); @@ -68,6 +110,8 @@ describe("personal 1Password rollout", () => { vi.stubEnv("ONEPASSWORD_ENABLED", "false"); vi.stubEnv("BROWSERBASE_1PASSWORD_EXTENSION_ID", "previous-private-extension"); vi.stubEnv("DATABASE_URL", ""); - await expect(withPersonalBrowserLock("conversation", async () => "ran")).rejects.toThrow("DATABASE_URL"); + await expect(withPersonalBrowserLock("conversation", async () => "ran")).rejects.toThrow( + "DATABASE_URL", + ); }); }); diff --git a/tests/onepassword-browser.test.ts b/tests/onepassword-browser.test.ts index a736030..2a40d4b 100644 --- a/tests/onepassword-browser.test.ts +++ b/tests/onepassword-browser.test.ts @@ -6,63 +6,134 @@ import { createAccess, fillAccess } from "../server/onepassword/extension"; import { callbackPage } from "../server/onepassword/http"; const connectUrl = "http://127.0.0.1:9353"; -describe.skipIf(process.env.BELL_TEST_LOCAL_CHROME !== "1")("real Chrome with a synthetic Autofill extension", () => { - let hosted: StagehandBrowser, stagehand: Stagehand, browser: Browser, context: BrowserContext; - beforeAll(async () => { - hosted = await localBrowser.launch({ executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", port: 9353, headless: true }); - stagehand = await Stagehand.create({ browser: hosted, logging: { level: "off" } }); - browser = await chromium.connectOverCDP(connectUrl); - context = browser.contexts()[0]; - const cdp = await browser.newBrowserCDPSession(); - await cdp.send("Extensions.loadUnpacked", { path: fileURLToPath(new URL("./fixtures/onepassword-extension", import.meta.url)) }); - await cdp.detach(); - await context.route("https://login.bell-test.example/**", async route => { - if (new URL(route.request().url()).pathname === "/home") { - const body = route.request().postData() ?? ""; - await route.fulfill({ contentType: "text/html", body: body.includes("fixture-password") ? "Signed in

Welcome fixture-user

" : "Denied" }); - } else await route.fulfill({ contentType: "text/html", body: 'Login
' }); +describe.skipIf(process.env.BELL_TEST_LOCAL_CHROME !== "1")( + "real Chrome with a synthetic Autofill extension", + () => { + let hosted: StagehandBrowser, stagehand: Stagehand, browser: Browser, context: BrowserContext; + beforeAll(async () => { + hosted = await localBrowser.launch({ + executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + port: 9353, + headless: true, + }); + stagehand = await Stagehand.create({ browser: hosted, logging: { level: "off" } }); + browser = await chromium.connectOverCDP(connectUrl); + context = browser.contexts()[0]; + const cdp = await browser.newBrowserCDPSession(); + await cdp.send("Extensions.loadUnpacked", { + path: fileURLToPath(new URL("./fixtures/onepassword-extension", import.meta.url)), + }); + await cdp.detach(); + await context.route("https://login.bell-test.example/**", async (route) => { + if (new URL(route.request().url()).pathname === "/home") { + const body = route.request().postData() ?? ""; + await route.fulfill({ + contentType: "text/html", + body: body.includes("fixture-password") + ? "Signed in

Welcome fixture-user

" + : "Denied", + }); + } else + await route.fulfill({ + contentType: "text/html", + body: 'Login
', + }); + }); + }, 30_000); + afterAll(async () => { + await browser?.close(); + await stagehand?.close(); + await hosted?.close(); }); - }, 30_000); - afterAll(async () => { await browser?.close(); await stagehand?.close(); await hosted?.close(); }); - it("loads the Autofill API alongside Stagehand and continues in the same browser after a verified fill", async () => { - const page = await context.newPage(); - await page.goto("https://login.bell-test.example/login"); - const created = await createAccess(connectUrl, "synthetic-access", "synthetic-key", page.url()); - expect(created).toMatchObject({ requestId: "synthetic-request", entryId: "synthetic-entry" }); - await fillAccess(connectUrl, "synthetic-access", "synthetic-key", "synthetic-reference", page.url(), "https://login.bell-test.example/home"); - expect(await page.title()).toBe("Signed in"); - const pages = await hosted.context.pages(); - expect(await Promise.all(pages.map(page => page.url()))).toContain("https://login.bell-test.example/home"); - await page.close(); - }, 30_000); - it("rejects duplicate matching tabs instead of choosing a login target arbitrarily", async () => { - const first = await context.newPage(), second = await context.newPage(); - try { - await Promise.all([first.goto("https://login.bell-test.example/login"), second.goto("https://login.bell-test.example/login")]); - await expect(fillAccess(connectUrl, "synthetic-access", "synthetic-key", "synthetic-reference", first.url(), "https://login.bell-test.example/home")).rejects.toThrow(); - expect(await first.locator("#password").inputValue()).toBe(""); - expect(await second.locator("#password").inputValue()).toBe(""); - } finally { await first.close(); await second.close(); } - }); - it("captures the OAuth fragment in a POST, clears it, and resumes only at a same-origin Eve callback", async () => { - const requests: { url: string; body?: string | null }[] = []; - await context.route("https://bell.bell-test.example/**", async route => { - const request = route.request(), url = new URL(request.url()); - requests.push({ url: request.url(), body: request.postData() }); - if (url.pathname === "/api/onepassword/callback") { - const response = callbackPage(); - await route.fulfill({ status: response.status, headers: Object.fromEntries(response.headers), body: await response.text() }); - } else if (url.pathname === "/api/onepassword/complete") await route.fulfill({ json: { location: "https://bell.bell-test.example/eve/v1/connections/op/callback/id/hook?result=opaque" } }); - else await route.fulfill({ body: "Connected", contentType: "text/html" }); + it("loads the Autofill API alongside Stagehand and continues in the same browser after a verified fill", async () => { + const page = await context.newPage(); + await page.goto("https://login.bell-test.example/login"); + const created = await createAccess( + connectUrl, + "synthetic-access", + "synthetic-key", + page.url(), + ); + expect(created).toMatchObject({ requestId: "synthetic-request", entryId: "synthetic-entry" }); + await fillAccess( + connectUrl, + "synthetic-access", + "synthetic-key", + "synthetic-reference", + page.url(), + "https://login.bell-test.example/home", + ); + expect(await page.title()).toBe("Signed in"); + const pages = await hosted.context.pages(); + expect(await Promise.all(pages.map((page) => page.url()))).toContain( + "https://login.bell-test.example/home", + ); + await page.close(); + }, 30_000); + it("rejects duplicate matching tabs instead of choosing a login target arbitrarily", async () => { + const first = await context.newPage(), + second = await context.newPage(); + try { + await Promise.all([ + first.goto("https://login.bell-test.example/login"), + second.goto("https://login.bell-test.example/login"), + ]); + await expect( + fillAccess( + connectUrl, + "synthetic-access", + "synthetic-key", + "synthetic-reference", + first.url(), + "https://login.bell-test.example/home", + ), + ).rejects.toThrow(); + expect(await first.locator("#password").inputValue()).toBe(""); + expect(await second.locator("#password").inputValue()).toBe(""); + } finally { + await first.close(); + await second.close(); + } }); - const page = await context.newPage(); - try { - await page.goto("https://bell.bell-test.example/api/onepassword/callback?code=synthetic-code&state=synthetic-state#integration_key=ops_synthetic"); - await page.waitForURL("**/eve/v1/connections/**"); - const posted = requests.find(request => request.url.endsWith("/complete")); - expect(JSON.parse(posted!.body!)).toEqual({ code: "synthetic-code", state: "synthetic-state", integrationKey: "ops_synthetic" }); - expect(requests.every(request => !request.url.includes("ops_synthetic"))).toBe(true); - expect(page.url()).not.toMatch(/integration_key|synthetic-code/); - } finally { await page.close(); } - }); -}); + it("captures the OAuth fragment in a POST, clears it, and resumes only at a same-origin Eve callback", async () => { + const requests: { url: string; body?: string | null }[] = []; + await context.route("https://bell.bell-test.example/**", async (route) => { + const request = route.request(), + url = new URL(request.url()); + requests.push({ url: request.url(), body: request.postData() }); + if (url.pathname === "/api/onepassword/callback") { + const response = callbackPage(); + await route.fulfill({ + status: response.status, + headers: Object.fromEntries(response.headers), + body: await response.text(), + }); + } else if (url.pathname === "/api/onepassword/complete") + await route.fulfill({ + json: { + location: + "https://bell.bell-test.example/eve/v1/connections/op/callback/id/hook?result=opaque", + }, + }); + else await route.fulfill({ body: "Connected", contentType: "text/html" }); + }); + const page = await context.newPage(); + try { + await page.goto( + "https://bell.bell-test.example/api/onepassword/callback?code=synthetic-code&state=synthetic-state#integration_key=ops_synthetic", + ); + await page.waitForURL("**/eve/v1/connections/**"); + const posted = requests.find((request) => request.url.endsWith("/complete")); + expect(JSON.parse(posted!.body!)).toEqual({ + code: "synthetic-code", + state: "synthetic-state", + integrationKey: "ops_synthetic", + }); + expect(requests.every((request) => !request.url.includes("ops_synthetic"))).toBe(true); + expect(page.url()).not.toMatch(/integration_key|synthetic-code/); + } finally { + await page.close(); + } + }); + }, +); diff --git a/tests/onepassword-callback.test.ts b/tests/onepassword-callback.test.ts index d2fa089..5fe48bd 100644 --- a/tests/onepassword-callback.test.ts +++ b/tests/onepassword-callback.test.ts @@ -6,12 +6,21 @@ import { approvalPage } from "../server/onepassword/approval-http"; import { browserbaseLaunchOptions } from "../server/browser/browser-session"; import { requireReusableContexts } from "../server/onepassword/browser-policy"; import { validateLogin } from "../server/onepassword/login"; -const key = (path = "//api.1password.com/oauth-clients/client/accounts/account/users/user") => `ops_${Buffer.from(JSON.stringify({ ver: 1, typ: "oauth_integration_key", key: "a".repeat(64), path })).toString("base64url")}`; +const key = (path = "//api.1password.com/oauth-clients/client/accounts/account/users/user") => + `ops_${Buffer.from(JSON.stringify({ ver: 1, typ: "oauth_integration_key", key: "a".repeat(64), path })).toString("base64url")}`; afterEach(() => vi.unstubAllEnvs()); describe("personal 1Password boundaries", () => { it("validates the complete key envelope and binds it to the registered client and API host", () => { - expect(integrationIdentity(key(), "client")).toMatchObject({ accountId: "account", userId: "user" }); - for (const value of ["ops_bad", key("//attacker.example/oauth-clients/client/accounts/account/users/user"), key("//api.1password.com/oauth-clients/other/accounts/account/users/user")]) expect(() => integrationIdentity(value, "client")).toThrow(); + expect(integrationIdentity(key(), "client")).toMatchObject({ + accountId: "account", + userId: "user", + }); + for (const value of [ + "ops_bad", + key("//attacker.example/oauth-clients/client/accounts/account/users/user"), + key("//api.1password.com/oauth-clients/other/accounts/account/users/user"), + ]) + expect(() => integrationIdentity(value, "client")).toThrow(); }); it("rejects application principals and non-Linq user identities", () => { expect(() => ownerOf({ type: "app" })).toThrow(); @@ -19,7 +28,14 @@ describe("personal 1Password boundaries", () => { expect(ownerOf({ type: "user", issuer: "linq", id: "linq:user" })).toBe('["linq","linq:user"]'); }); it("requires an exact HTTPS callback and a canonical encryption key", () => { - const env = { BELL_PUBLIC_URL: "https://bell.example", ONEPASSWORD_REDIRECT_URI: "https://bell.example/api/onepassword/callback", ONEPASSWORD_TOKEN_ENCRYPTION_KEY: randomBytes(32).toString("base64"), ONEPASSWORD_CLIENT_ID: "client", ONEPASSWORD_CLIENT_SECRET: "secret", DATABASE_URL: "postgres://test" }; + const env = { + BELL_PUBLIC_URL: "https://bell.example", + ONEPASSWORD_REDIRECT_URI: "https://bell.example/api/onepassword/callback", + ONEPASSWORD_TOKEN_ENCRYPTION_KEY: randomBytes(32).toString("base64"), + ONEPASSWORD_CLIENT_ID: "client", + ONEPASSWORD_CLIENT_SECRET: "secret", + DATABASE_URL: "postgres://test", + }; for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value); expect(configuration().origin).toBe(env.BELL_PUBLIC_URL); vi.stubEnv("ONEPASSWORD_REDIRECT_URI", env.ONEPASSWORD_REDIRECT_URI + "?bypass=1"); @@ -35,7 +51,15 @@ describe("personal 1Password boundaries", () => { it("rejects a cross-origin callback before consuming a code", async () => { const capture = vi.fn(); const service = () => ({ config: { origin: "https://bell.example" }, capture }) as never; - const response = await onePasswordPost(new Request("https://bell.example/api/onepassword/complete", { method: "POST", headers: { origin: "https://attacker.example", "content-type": "application/json" }, body: "{}" }), "complete", service); + const response = await onePasswordPost( + new Request("https://bell.example/api/onepassword/complete", { + method: "POST", + headers: { origin: "https://attacker.example", "content-type": "application/json" }, + body: "{}", + }), + "complete", + service, + ); expect(response.status).toBe(400); expect(capture).not.toHaveBeenCalled(); }); @@ -52,6 +76,12 @@ describe("personal 1Password boundaries", () => { expect(requireReusableContexts).toThrow(); }); it("rejects ambiguous success destinations and cross-origin or credential-bearing URLs", () => { - for (const successUrl of ["http://example.com/home", "https://other.example/home", "https://example.com/login", "https://user:pass@example.com/home"]) expect(() => validateLogin({ loginUrl: "https://example.com/login", successUrl })).toThrow(); + for (const successUrl of [ + "http://example.com/home", + "https://other.example/home", + "https://example.com/login", + "https://user:pass@example.com/home", + ]) + expect(() => validateLogin({ loginUrl: "https://example.com/login", successUrl })).toThrow(); }); }); diff --git a/tests/onepassword-integration.test.ts b/tests/onepassword-integration.test.ts index 9d19303..2c65495 100644 --- a/tests/onepassword-integration.test.ts +++ b/tests/onepassword-integration.test.ts @@ -13,58 +13,126 @@ import { ExtensionError } from "../server/onepassword/extension"; import { createOnePasswordAuth } from "../agent/lib/onepassword-auth"; import { closeBrowserRuntime, runBrowserOperation } from "../agent/lib/browser-runtime"; import { quarantineBrowser } from "../server/browser/privacy-lock"; -import { ContextContainer, contextStorage } from "../node_modules/eve/dist/src/context/container.js"; +import { + ContextContainer, + contextStorage, +} from "../node_modules/eve/dist/src/context/container.js"; import { AuthKey, SessionIdKey, SessionKey } from "../node_modules/eve/dist/src/context/keys.js"; -import { CallbackBaseUrlKey, PendingAuthorizationResultKey, isAuthorizationSignal } from "../node_modules/eve/dist/src/harness/authorization.js"; +import { + CallbackBaseUrlKey, + PendingAuthorizationResultKey, + isAuthorizationSignal, +} from "../node_modules/eve/dist/src/harness/authorization.js"; import { createAuthorizationExecution } from "../node_modules/eve/dist/src/runtime/connections/scoped-authorization.js"; const databaseUrl = process.env.TEST_ONEPASSWORD_DATABASE_URL; -const integrationKey = (user = "user", account = "account") => `ops_${Buffer.from(JSON.stringify({ ver: 1, typ: "oauth_integration_key", key: "b".repeat(64), path: `//api.1password.com/oauth-clients/client/accounts/${account}/users/${user}` })).toString("base64url")}`; +const integrationKey = (user = "user", account = "account") => + `ops_${Buffer.from(JSON.stringify({ ver: 1, typ: "oauth_integration_key", key: "b".repeat(64), path: `//api.1password.com/oauth-clients/client/accounts/${account}/users/${user}` })).toString("base64url")}`; // Every run uses its own schema and synthetic credentials; no provider network calls. describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with PostgreSQL", () => { const schema = `op_test_${randomUUID().replaceAll("-", "")}`; - let admin: Pool, pool: Pool, config: Config, store: OnePasswordStore, oauth: OnePasswordOAuth, now: number; + let admin: Pool, + pool: Pool, + config: Config, + store: OnePasswordStore, + oauth: OnePasswordOAuth, + now: number; let principal: { type: "user"; issuer: string; id: string }, callback: string; let fetcher: ReturnType>; - const tokens = (suffix = "first", user = "user") => ({ access_token: `access-${suffix}`, refresh_token: `refresh-${suffix}`, expires_in: 900, token_type: "Bearer", scope: scopes, user_id: user, account_id: "account" }); + const tokens = (suffix = "first", user = "user") => ({ + access_token: `access-${suffix}`, + refresh_token: `refresh-${suffix}`, + expires_in: 900, + token_type: "Bearer", + scope: scopes, + user_id: user, + account_id: "account", + }); beforeAll(async () => { admin = new Pool({ connectionString: databaseUrl }); await admin.query(`CREATE SCHEMA ${schema}`); pool = new Pool({ connectionString: databaseUrl, options: `-c search_path=${schema}`, max: 8 }); - await pool.query(await readFile(new URL("../migrations/002_onepassword.sql", import.meta.url), "utf8")); - await pool.query(await readFile(new URL("../migrations/003_link_checkout.sql", import.meta.url), "utf8")); + await pool.query( + await readFile(new URL("../migrations/002_onepassword.sql", import.meta.url), "utf8"), + ); + await pool.query( + await readFile(new URL("../migrations/003_link_checkout.sql", import.meta.url), "utf8"), + ); + }); + afterAll(async () => { + await pool?.end(); + if (admin) { + await admin.query(`DROP SCHEMA ${schema} CASCADE`); + await admin.end(); + } }); - afterAll(async () => { await pool?.end(); if (admin) { await admin.query(`DROP SCHEMA ${schema} CASCADE`); await admin.end(); } }); afterEach(() => vi.unstubAllEnvs()); beforeEach(() => { vi.stubEnv("ONEPASSWORD_ENABLED", "true"); now = Date.now(); principal = { type: "user", issuer: "linq", id: `linq:${randomUUID()}` }; - config = { origin: "https://bell.example", redirectUri: "https://bell.example/api/onepassword/callback", clientId: "client", clientSecret: "secret", encryptionKey: randomBytes(32), databaseUrl: databaseUrl! }; + config = { + origin: "https://bell.example", + redirectUri: "https://bell.example/api/onepassword/callback", + clientId: "client", + clientSecret: "secret", + encryptionKey: randomBytes(32), + databaseUrl: databaseUrl!, + }; callback = `https://bell.example/eve/v1/connections/onepassword/callback/${randomUUID()}/hook`; store = new OnePasswordStore(pool, config.encryptionKey); fetcher = vi.fn().mockImplementation(async () => Response.json(tokens())); oauth = new OnePasswordOAuth(config, store, fetcher, () => now); }); - const credentials = (service: OnePasswordOAuth, operation: (access: string, key: string) => Promise) => service.store.withOwner(ownerOf(principal), async db => { - const keys = await service.activeCredentials(db, await db.read()); - return operation(keys.accessToken, keys.integrationKey); - }); + const credentials = ( + service: OnePasswordOAuth, + operation: (access: string, key: string) => Promise, + ) => + service.store.withOwner(ownerOf(principal), async (db) => { + const keys = await service.activeCredentials(db, await db.read()); + return operation(keys.accessToken, keys.integrationKey); + }); async function begin(callbackUrl = callback) { - const started = await oauth.start(principal, callbackUrl), state = new URL(started.challenge.url).searchParams.get("state")!; + const started = await oauth.start(principal, callbackUrl), + state = new URL(started.challenge.url).searchParams.get("state")!; const response = await onePasswordGet(new Request(started.challenge.url), "start", () => oauth); expect(response.status).toBe(303); - return { started, state, cookie: response.headers.get("set-cookie")!, url: new URL(response.headers.get("location")!) }; + return { + started, + state, + cookie: response.headers.get("set-cookie")!, + url: new URL(response.headers.get("location")!), + }; } - async function capture(flow: Awaited>, key: string | undefined = integrationKey()) { - return onePasswordPost(new Request(`${config.origin}/api/onepassword/complete`, { method: "POST", headers: { cookie: flow.cookie, origin: config.origin, "content-type": "application/json" }, body: JSON.stringify({ state: flow.state, code: "code", ...(key ? { integrationKey: key } : {}) }) }), "complete", () => oauth); + async function capture( + flow: Awaited>, + key: string | undefined = integrationKey(), + ) { + return onePasswordPost( + new Request(`${config.origin}/api/onepassword/complete`, { + method: "POST", + headers: { cookie: flow.cookie, origin: config.origin, "content-type": "application/json" }, + body: JSON.stringify({ + state: flow.state, + code: "code", + ...(key ? { integrationKey: key } : {}), + }), + }), + "complete", + () => oauth, + ); } async function connect() { const flow = await begin(); const response = await capture(flow); expect(response.status).toBe(200); const target = new URL((await response.json()).location); - const marker = await oauth.complete(principal, callback, flow.started.resume, Object.fromEntries(target.searchParams)); + const marker = await oauth.complete( + principal, + callback, + flow.started.resume, + Object.fromEntries(target.searchParams), + ); return { flow, marker, target }; } it("connects through the HTTP callback, keeps secrets encrypted, and resumes with only an opaque marker", async () => { @@ -74,19 +142,47 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg expect(hash(body.get("code_verifier")!)).toBe(flow.url.searchParams.get("code_challenge")); expect(body.get("client_secret")).toBeNull(); expect(JSON.stringify(marker)).not.toMatch(/access-first|refresh-first|ops_/); - const rows = await pool.query("SELECT encrypted_state FROM bell_onepassword_connections WHERE owner=$1", [ownerOf(principal)]); + const rows = await pool.query( + "SELECT encrypted_state FROM bell_onepassword_connections WHERE owner=$1", + [ownerOf(principal)], + ); expect(JSON.stringify(rows.rows)).not.toMatch(/access-first|refresh-first|ops_/); - const restarted = new OnePasswordOAuth(config, new OnePasswordStore(pool, config.encryptionKey), fetcher, () => now); - expect(await restarted.complete(principal, callback, flow.started.resume, Object.fromEntries(target.searchParams))).toEqual(marker); - expect(await credentials(restarted, async access => access)).toBe("access-first"); + const restarted = new OnePasswordOAuth( + config, + new OnePasswordStore(pool, config.encryptionKey), + fetcher, + () => now, + ); + expect( + await restarted.complete( + principal, + callback, + flow.started.resume, + Object.fromEntries(target.searchParams), + ), + ).toEqual(marker); + expect(await credentials(restarted, async (access) => access)).toBe("access-first"); expect(fetcher).toHaveBeenCalledOnce(); }); it("rejects another sender and a mismatched browser before a code exchange", async () => { const flow = await begin(); - await expect(oauth.capture({ state: flow.state, code: "code", integrationKey: integrationKey() }, "wrong-cookie")).rejects.toThrow(); + await expect( + oauth.capture( + { state: flow.state, code: "code", integrationKey: integrationKey() }, + "wrong-cookie", + ), + ).rejects.toThrow(); expect(fetcher).not.toHaveBeenCalled(); - const response = await capture(flow), target = new URL((await response.json()).location); - await expect(oauth.complete({ ...principal, id: "linq:other" }, callback, flow.started.resume, Object.fromEntries(target.searchParams))).rejects.toThrow(); + const response = await capture(flow), + target = new URL((await response.json()).location); + await expect( + oauth.complete( + { ...principal, id: "linq:other" }, + callback, + flow.started.resume, + Object.fromEntries(target.searchParams), + ), + ).rejects.toThrow(); }); it("retains the one-time key across a failed exchange and a reconnect without a fragment", async () => { const flow = await begin(); @@ -103,13 +199,25 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg fetcher.mockRejectedValueOnce(new Error("lost exchange")); expect((await capture(flow)).status).toBe(400); await expect(oauth.disconnect(principal)).rejects.toThrow(); - expect(await store.withOwner(ownerOf(principal), db => db.read())).toHaveProperty("integrationKey", integrationKey()); + expect(await store.withOwner(ownerOf(principal), (db) => db.read())).toHaveProperty( + "integrationKey", + integrationKey(), + ); }); it("finishes a denied OAuth authorization without exchanging credentials", async () => { const flow = await begin(); const nonce = flow.cookie.split("=")[1].split(";")[0]; - const target = new URL(await oauth.capture({ state: flow.state, error: "access_denied" }, nonce)); - await expect(oauth.complete(principal, callback, flow.started.resume, Object.fromEntries(target.searchParams))).rejects.toMatchObject({ reason: "authorization_denied" }); + const target = new URL( + await oauth.capture({ state: flow.state, error: "access_denied" }, nonce), + ); + await expect( + oauth.complete( + principal, + callback, + flow.started.resume, + Object.fromEntries(target.searchParams), + ), + ).rejects.toMatchObject({ reason: "authorization_denied" }); expect(fetcher).not.toHaveBeenCalled(); }); it("never activates a callback without a key, or reuses a key for a different provider user", async () => { @@ -125,11 +233,24 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg await expect(oauth.getToken(principal)).rejects.toThrow(); }); it("serializes refreshes across instances and never reuses a possibly consumed refresh token", async () => { - await connect(); now += 850_000; - fetcher.mockImplementation(async () => { await new Promise(resolve => setTimeout(resolve, 20)); return Response.json(tokens("rotated")); }); - const other = new OnePasswordOAuth(config, new OnePasswordStore(pool, config.encryptionKey), fetcher, () => now); - const read = (service: OnePasswordOAuth) => credentials(service, async access => access); - expect(await Promise.all([read(oauth), read(other), read(oauth)])).toEqual(["access-rotated", "access-rotated", "access-rotated"]); + await connect(); + now += 850_000; + fetcher.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + return Response.json(tokens("rotated")); + }); + const other = new OnePasswordOAuth( + config, + new OnePasswordStore(pool, config.encryptionKey), + fetcher, + () => now, + ); + const read = (service: OnePasswordOAuth) => credentials(service, async (access) => access); + expect(await Promise.all([read(oauth), read(other), read(oauth)])).toEqual([ + "access-rotated", + "access-rotated", + "access-rotated", + ]); expect(fetcher).toHaveBeenCalledTimes(2); now += 850_000; fetcher.mockRejectedValue(new Error("lost refresh response")); @@ -144,44 +265,77 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg await expect(credentials(oauth, async () => true)).rejects.toThrow(); fetcher.mockImplementation(async () => Response.json({})); expect(await oauth.disconnect(principal)).toEqual({ status: "disconnected" }); - expect(fetcher.mock.calls.at(-1)![0]).toBe("https://api.1password.com/v1alpha1/oauth-integrations/self:revoke"); - expect(await store.withOwner(ownerOf(principal), db => db.read())).not.toHaveProperty("integrationKey"); + expect(fetcher.mock.calls.at(-1)![0]).toBe( + "https://api.1password.com/v1alpha1/oauth-integrations/self:revoke", + ); + expect(await store.withOwner(ownerOf(principal), (db) => db.read())).not.toHaveProperty( + "integrationKey", + ); }); it("rejects ciphertext swapped between owners", async () => { await connect(); - await pool.query("INSERT INTO bell_onepassword_connections(owner,encrypted_state) SELECT $1,encrypted_state FROM bell_onepassword_connections WHERE owner=$2", [ownerOf({ ...principal, id: "linq:other" }), ownerOf(principal)]); + await pool.query( + "INSERT INTO bell_onepassword_connections(owner,encrypted_state) SELECT $1,encrypted_state FROM bell_onepassword_connections WHERE owner=$2", + [ownerOf({ ...principal, id: "linq:other" }), ownerOf(principal)], + ); await expect(oauth.getToken({ ...principal, id: "linq:other" })).rejects.toThrow(); }); async function startLogin(check: ConstructorParameters[1]) { await connect(); - const service = new OnePasswordLogin(oauth, check, () => now), id = loginId("session", "call"); - const created = vi.fn(async () => ({ browserSessionId: "browser-id", requestId: "request-id", entryId: "entry-id", appLink: "onepassword://approve/synthetic" })); + const service = new OnePasswordLogin(oauth, check, () => now), + id = loginId("session", "call"); + const created = vi.fn(async () => ({ + browserSessionId: "browser-id", + requestId: "request-id", + entryId: "entry-id", + appLink: "onepassword://approve/synthetic", + })); const input = { loginUrl: "https://example.com/login", successUrl: "https://example.com/home" }; const started = await service.start(principal, "session", id, input, callback, created); return { service, id, started, cap: new URL(started.challenge.url).hash.slice(1), created }; } it("binds approval to the requested entry and conversation, then fills once on durable replay", async () => { - const check = vi.fn(async () => ({ state: "resolved", resolved: [{ entryId: "unrelated", reference: "wrong" }, { entryId: "entry-id", reference: "approved-reference" }] })); + const check = vi.fn(async () => ({ + state: "resolved", + resolved: [ + { entryId: "unrelated", reference: "wrong" }, + { entryId: "entry-id", reference: "approved-reference" }, + ], + })); const { service, id, cap, started } = await startLogin(check); await expect(service.poll(cap, false)).rejects.toThrow(); - await service.opened(cap); now += 5000; + await service.opened(cap); + now += 5000; const approved = await service.poll(cap, false); const target = new URL(approved.location!); - await service.complete(principal, id, callback, started.resume, Object.fromEntries(target.searchParams)); - const fill = vi.fn(async (_credentials, request) => { expect(request.reference).toBe("approved-reference"); }); + await service.complete( + principal, + id, + callback, + started.resume, + Object.fromEntries(target.searchParams), + ); + const fill = vi.fn(async (_credentials, request) => { + expect(request.reference).toBe("approved-reference"); + }); await expect(service.fill(principal, id, "other-session", fill)).rejects.toThrow(); expect(fill).not.toHaveBeenCalled(); - expect(await service.fill(principal, id, "session", fill)).toMatchObject({ status: "destination_verified" }); - expect(await service.fill(principal, id, "session", fill)).toMatchObject({ status: "destination_verified" }); + expect(await service.fill(principal, id, "session", fill)).toMatchObject({ + status: "destination_verified", + }); + expect(await service.fill(principal, id, "session", fill)).toMatchObject({ + status: "destination_verified", + }); expect(fill).toHaveBeenCalledOnce(); - const saved = await store.withOwner(ownerOf(principal), db => db.read()); + const saved = await store.withOwner(ownerOf(principal), (db) => db.read()); expect(saved.login?.status).toBe("filled"); expect(saved.login).not.toHaveProperty("reference"); }); it("enforces one checker, respects rate limits, and stops automatic polling after two minutes", async () => { const check = vi.fn(async () => ({ state: "pending" })); const { service, cap } = await startLogin(check); - await service.opened(cap); now += 5000; + await service.opened(cap); + now += 5000; check.mockRejectedValueOnce(new ExtensionError("rateLimitExceeded")); expect(await service.poll(cap, false)).toMatchObject({ retryAfter: 60 }); await Promise.all([service.poll(cap, false), service.poll(cap, false)]); @@ -193,8 +347,12 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg expect(check).toHaveBeenCalledTimes(2); }); it("never fills a missing/skipped grant or after disconnect", async () => { - const { service, id, cap } = await startLogin(async () => ({ state: "resolved", resolved: [{ entryId: "other", reference: "not-ours" }] })); - await service.opened(cap); now += 5000; + const { service, id, cap } = await startLogin(async () => ({ + state: "resolved", + resolved: [{ entryId: "other", reference: "not-ours" }], + })); + await service.opened(cap); + now += 5000; expect(await service.poll(cap, false)).toMatchObject({ status: "not_approved" }); const fill = vi.fn(); await expect(service.fill(principal, id, "session", fill)).rejects.toThrow(); @@ -208,52 +366,117 @@ describe.skipIf(!databaseUrl)("1Password OAuth and approval lifecycle with Postg url.searchParams.set("options", `-c search_path=${schema}`); vi.stubEnv("DATABASE_URL", url.href); vi.stubEnv("BROWSERBASE_1PASSWORD_EXTENSION_ID", "synthetic-extension"); - const sessionId = randomUUID(), order: string[] = []; + const sessionId = randomUUID(), + order: string[] = []; let started!: () => void; - const ready = new Promise(resolve => { started = resolve; }); - const first = withPersonalBrowserLock(sessionId, async () => { order.push("fill-start"); started(); await new Promise(resolve => setTimeout(resolve, 20)); order.push("fill-end"); }); + const ready = new Promise((resolve) => { + started = resolve; + }); + const first = withPersonalBrowserLock(sessionId, async () => { + order.push("fill-start"); + started(); + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push("fill-end"); + }); await ready; - await Promise.all([first, withPersonalBrowserLock(sessionId, async () => { order.push("read"); })]); + await Promise.all([ + first, + withPersonalBrowserLock(sessionId, async () => { + order.push("read"); + }), + ]); expect(order).toEqual(["fill-start", "fill-end", "read"]); - await expect(withPersonalBrowserLock(sessionId, async () => { await markSecretFill(true); throw new Error("process lost"); })).rejects.toThrow(); + await expect( + withPersonalBrowserLock(sessionId, async () => { + await markSecretFill(true); + throw new Error("process lost"); + }), + ).rejects.toThrow(); const read = vi.fn(); - await expect(withPersonalBrowserLock(sessionId, read)).rejects.toThrow("Start a new conversation"); + await expect(withPersonalBrowserLock(sessionId, read)).rejects.toThrow( + "Start a new conversation", + ); expect(read).not.toHaveBeenCalled(); - await expect(withPersonalBrowserLock(randomUUID(), async () => "clean browser")).resolves.toBe("clean browser"); - }); - it.each(["onepassword", "link"])("keeps %s quarantine enforced when both integrations share the runtime", async integration => { - const url = new URL(databaseUrl!); - url.searchParams.set("options", `-c search_path=${schema}`); - vi.stubEnv("DATABASE_URL", url.href); - vi.stubEnv("LINK_SANDBOX", "0"); - vi.stubEnv("LINK_CLIENT_ID", "synthetic-client"); - vi.stubEnv("BROWSERBASE_1PASSWORD_EXTENSION_ID", "synthetic-extension"); - const sessionId = randomUUID(), read = vi.fn(); - await contextStorage.run(new ContextContainer(), async () => { - try { - await expect(runBrowserOperation(sessionId, async () => { - await markSecretFill(true); - if (integration === "link") { await markSecretFill(false); await quarantineBrowser(); } - throw new Error("interrupted private operation"); - })).rejects.toThrow("interrupted private operation"); - await expect(runBrowserOperation(sessionId, read)).rejects.toThrow(integration === "link" ? "Payment details may remain" : "Start a new conversation"); - expect(read).not.toHaveBeenCalled(); - } finally { await closeBrowserRuntime(sessionId); } - }); + await expect(withPersonalBrowserLock(randomUUID(), async () => "clean browser")).resolves.toBe( + "clean browser", + ); }); + it.each(["onepassword", "link"])( + "keeps %s quarantine enforced when both integrations share the runtime", + async (integration) => { + const url = new URL(databaseUrl!); + url.searchParams.set("options", `-c search_path=${schema}`); + vi.stubEnv("DATABASE_URL", url.href); + vi.stubEnv("LINK_SANDBOX", "0"); + vi.stubEnv("LINK_CLIENT_ID", "synthetic-client"); + vi.stubEnv("BROWSERBASE_1PASSWORD_EXTENSION_ID", "synthetic-extension"); + const sessionId = randomUUID(), + read = vi.fn(); + await contextStorage.run(new ContextContainer(), async () => { + try { + await expect( + runBrowserOperation(sessionId, async () => { + await markSecretFill(true); + if (integration === "link") { + await markSecretFill(false); + await quarantineBrowser(); + } + throw new Error("interrupted private operation"); + }), + ).rejects.toThrow("interrupted private operation"); + await expect(runBrowserOperation(sessionId, read)).rejects.toThrow( + integration === "link" ? "Payment details may remain" : "Start a new conversation", + ); + expect(read).not.toHaveBeenCalled(); + } finally { + await closeBrowserRuntime(sessionId); + } + }); + }, + ); it("resumes Eve in the original authenticated session without journaling provider credentials", async () => { - const context = new ContextContainer(), auth = { principalType: "user" as const, principalId: principal.id, issuer: "linq", authenticator: "linq-message", attributes: {} }; - context.set(AuthKey, auth); context.set(SessionIdKey, "original-session"); context.set(SessionKey, { sessionId: "original-session", auth: { current: auth } } as never); context.set(CallbackBaseUrlKey, config.origin); - const scoped = { scope: "onepassword", authorization: createOnePasswordAuth(() => oauth), connection: { url: "" } }; + const context = new ContextContainer(), + auth = { + principalType: "user" as const, + principalId: principal.id, + issuer: "linq", + authenticator: "linq-message", + attributes: {}, + }; + context.set(AuthKey, auth); + context.set(SessionIdKey, "original-session"); + context.set(SessionKey, { sessionId: "original-session", auth: { current: auth } } as never); + context.set(CallbackBaseUrlKey, config.origin); + const scoped = { + scope: "onepassword", + authorization: createOnePasswordAuth(() => oauth), + connection: { url: "" }, + }; await contextStorage.run(context, async () => { - const execution = createAuthorizationExecution(), signal = await execution.run(() => execution.getToken(scoped)); + const execution = createAuthorizationExecution(), + signal = await execution.run(() => execution.getToken(scoped)); if (!isAuthorizationSignal(signal)) throw new Error("Expected Eve authorization pause"); const challenge = signal.challenges[0]; expect(JSON.stringify(challenge.resume)).not.toMatch(/verifier|ops_|refresh/); const state = new URL(challenge.challenge.url!).searchParams.get("state")!; await oauth.open(state, "browser-nonce"); - const target = new URL(await oauth.capture({ state, code: "code", integrationKey: integrationKey() }, "browser-nonce")); - context.set(PendingAuthorizationResultKey, [{ name: challenge.name, attemptId: challenge.attemptId, instanceId: challenge.instanceId, principal: challenge.principal, resume: challenge.resume, hookUrl: challenge.hookUrl, callback: { method: "GET", params: Object.fromEntries(target.searchParams) } }]); + const target = new URL( + await oauth.capture( + { state, code: "code", integrationKey: integrationKey() }, + "browser-nonce", + ), + ); + context.set(PendingAuthorizationResultKey, [ + { + name: challenge.name, + attemptId: challenge.attemptId, + instanceId: challenge.instanceId, + principal: challenge.principal, + resume: challenge.resume, + hookUrl: challenge.hookUrl, + callback: { method: "GET", params: Object.fromEntries(target.searchParams) }, + }, + ]); const result = await createAuthorizationExecution().getToken(scoped); expect(result.token).toMatch(/^onepassword-connected-/); expect(context.require(SessionIdKey)).toBe("original-session"); diff --git a/tests/onepassword-tool.test.ts b/tests/onepassword-tool.test.ts index fe13d72..c206384 100644 --- a/tests/onepassword-tool.test.ts +++ b/tests/onepassword-tool.test.ts @@ -1,24 +1,73 @@ import type { ToolContext } from "eve/tools"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ fill: vi.fn(), run: vi.fn(), closeRuntime: vi.fn(), mark: vi.fn(), extensionFill: vi.fn(), create: vi.fn() })); -vi.mock("../server/onepassword/login", async importOriginal => ({ ...await importOriginal(), getOnePasswordLogin: () => ({ fill: mocks.fill }) })); +const mocks = vi.hoisted(() => ({ + fill: vi.fn(), + run: vi.fn(), + closeRuntime: vi.fn(), + mark: vi.fn(), + extensionFill: vi.fn(), + create: vi.fn(), +})); +vi.mock("../server/onepassword/login", async (importOriginal) => ({ + ...(await importOriginal()), + getOnePasswordLogin: () => ({ fill: mocks.fill }), +})); vi.mock("../server/onepassword/browser-policy", () => ({ markSecretFill: mocks.mark })); -vi.mock("../server/onepassword/extension", () => ({ createAccess: mocks.create, fillAccess: mocks.extensionFill })); -vi.mock("../agent/lib/browser-runtime", () => ({ runBrowserOperation: mocks.run, closeBrowserRuntime: mocks.closeRuntime, saveBrowserRuntime: vi.fn(), runCancelableBrowserWork: (_id: string, _signal: AbortSignal, operation: () => Promise) => operation() })); +vi.mock("../server/onepassword/extension", () => ({ + createAccess: mocks.create, + fillAccess: mocks.extensionFill, +})); +vi.mock("../agent/lib/browser-runtime", () => ({ + runBrowserOperation: mocks.run, + closeBrowserRuntime: mocks.closeRuntime, + saveBrowserRuntime: vi.fn(), + runCancelableBrowserWork: ( + _id: string, + _signal: AbortSignal, + operation: () => Promise, + ) => operation(), +})); import tool from "../agent/tools/onepassword_login"; const input = { loginUrl: "https://example.com/login", successUrl: "https://example.com/home" }; describe("personal login tool orchestration", () => { - let ctx: ToolContext, browser: { withHostedPage: ReturnType; close: ReturnType }; + let ctx: ToolContext, + browser: { withHostedPage: ReturnType; close: ReturnType }; beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("ONEPASSWORD_ENABLED", "true"); vi.stubEnv("BROWSERBASE_1PASSWORD_EXTENSION_ID", "synthetic-extension"); - ctx = { session: { id: "conversation", auth: { current: { principalType: "user", authenticator: "linq-message", issuer: "linq", principalId: "linq:sender" } } }, callId: "call", abortSignal: new AbortController().signal, getToken: vi.fn(async () => ({ token: "opaque-marker" })) } as unknown as ToolContext; - browser = { withHostedPage: vi.fn(async (_signal, operation) => operation({ url: async () => input.loginUrl }, "private-cdp-url", "browser-id")), close: vi.fn() }; - mocks.run.mockImplementation(async (_id, operation) => ({ result: await operation(browser), workbench: { browser: { sessionId: "browser-id" } } })); + ctx = { + session: { + id: "conversation", + auth: { + current: { + principalType: "user", + authenticator: "linq-message", + issuer: "linq", + principalId: "linq:sender", + }, + }, + }, + callId: "call", + abortSignal: new AbortController().signal, + getToken: vi.fn(async () => ({ token: "opaque-marker" })), + } as unknown as ToolContext; + browser = { + withHostedPage: vi.fn(async (_signal, operation) => + operation({ url: async () => input.loginUrl }, "private-cdp-url", "browser-id"), + ), + close: vi.fn(), + }; + mocks.run.mockImplementation(async (_id, operation) => ({ + result: await operation(browser), + workbench: { browser: { sessionId: "browser-id" } }, + })); mocks.extensionFill.mockResolvedValue(undefined); mocks.fill.mockImplementation(async (_principal, _id, _session, operation) => { - await operation({ accessToken: "private-access", integrationKey: "private-key" }, { ...input, browserSessionId: "browser-id", reference: "private-reference" }); + await operation( + { accessToken: "private-access", integrationKey: "private-key" }, + { ...input, browserSessionId: "browser-id", reference: "private-reference" }, + ); return { status: "destination_verified", url: input.successUrl }; }); }); @@ -28,7 +77,14 @@ describe("personal login tool orchestration", () => { expect(result).toEqual({ status: "destination_verified", url: input.successUrl }); expect(JSON.stringify(result)).not.toMatch(/private-/); expect(mocks.mark.mock.calls).toEqual([[true], [false]]); - expect(mocks.extensionFill).toHaveBeenCalledWith("private-cdp-url", "private-access", "private-key", "private-reference", input.loginUrl, input.successUrl); + expect(mocks.extensionFill).toHaveBeenCalledWith( + "private-cdp-url", + "private-access", + "private-key", + "private-reference", + input.loginUrl, + input.successUrl, + ); }); it("closes a failed fill while the operation is still locked and leaves the durable guard set", async () => { mocks.extensionFill.mockRejectedValueOnce(new Error("provider error with private-access")); @@ -38,17 +94,35 @@ describe("personal login tool orchestration", () => { expect(mocks.closeRuntime).toHaveBeenCalledOnce(); }); it("does not report success when the runtime returns a CAPTCHA handoff without running the fill", async () => { - mocks.run.mockResolvedValueOnce({ result: { status: "waiting_for_user", reason: "captcha" }, workbench: {} }); + mocks.run.mockResolvedValueOnce({ + result: { status: "waiting_for_user", reason: "captcha" }, + workbench: {}, + }); await expect(tool.execute(input, ctx)).rejects.toThrow(); expect(mocks.extensionFill).not.toHaveBeenCalled(); }); it("rejects a replacement browser instead of moving a grant into another session", async () => { - browser.withHostedPage.mockImplementation(async (_signal, operation) => operation({ url: async () => input.loginUrl }, "private-cdp-url", "different-browser")); + browser.withHostedPage.mockImplementation(async (_signal, operation) => + operation({ url: async () => input.loginUrl }, "private-cdp-url", "different-browser"), + ); await expect(tool.execute(input, ctx)).rejects.toThrow(); expect(mocks.extensionFill).not.toHaveBeenCalled(); }); it("rejects an operator session before requesting personal authorization", async () => { - ctx = { ...ctx, session: { ...ctx.session, auth: { current: { principalType: "user", authenticator: "http-basic", issuer: "bell", principalId: "operator" } } } } as unknown as ToolContext; + ctx = { + ...ctx, + session: { + ...ctx.session, + auth: { + current: { + principalType: "user", + authenticator: "http-basic", + issuer: "bell", + principalId: "operator", + }, + }, + }, + } as unknown as ToolContext; await expect(tool.execute(input, ctx)).rejects.toThrow("authenticated_sender_required"); expect(ctx.getToken).not.toHaveBeenCalled(); }); diff --git a/tests/parallel.test.ts b/tests/parallel.test.ts index 29b82c9..00aea33 100644 --- a/tests/parallel.test.ts +++ b/tests/parallel.test.ts @@ -4,38 +4,90 @@ import search from "../agent/tools/web_search"; import extract from "../agent/tools/web_fetch"; import { extractWeb, searchWeb } from "../server/parallel"; -const query = { objective: "Find official product documentation", search_queries: ["official product docs"] }; -const source = { url: "https://example.com/docs", title: "Documentation", publish_date: "2026-10-28", excerpts: ["Public evidence"] }; -const reply = (value: unknown = { results: [source] }) => vi.fn(async () => Response.json(value)); +const query = { + objective: "Find official product documentation", + search_queries: ["official product docs"], +}; +const source = { + url: "https://example.com/docs", + title: "Documentation", + publish_date: "2026-10-28", + excerpts: ["Public evidence"], +}; +const reply = (value: unknown = { results: [source] }) => + vi.fn(async () => Response.json(value)); beforeEach(() => vi.stubEnv("PARALLEL_API_KEY", "test-server-key")); -afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); describe("Parallel public research", () => { it("sends bounded v1 search requests and retains citable evidence without provider metadata", async () => { - const fetcher = reply({ results: [source], session_id: "provider-private-id", usage: [{ count: 1 }] }); + const fetcher = reply({ + results: [source], + session_id: "provider-private-id", + usage: [{ count: 1 }], + }); const output = await searchWeb(query, { fetch: fetcher }); const [url, options] = fetcher.mock.calls[0]; expect(url).toBe("https://api.parallel.ai/v1/search"); - expect(options).toMatchObject({ method: "POST", redirect: "error", headers: { "x-api-key": "test-server-key" } }); + expect(options).toMatchObject({ + method: "POST", + redirect: "error", + headers: { "x-api-key": "test-server-key" }, + }); expect(JSON.parse(options!.body as string)).toEqual({ - ...query, mode: "fast", max_chars_total: 16_000, + ...query, + mode: "fast", + max_chars_total: 16_000, advanced_settings: { max_results: 5, excerpt_settings: { max_chars_per_result: 2_000 } }, }); - expect(output).toMatchObject({ results: [source], errors: [], truncated: false, warning_count: 0 }); + expect(output).toMatchObject({ + results: [source], + errors: [], + truncated: false, + warning_count: 0, + }); expect(JSON.stringify(output)).not.toMatch(/test-server-key|provider-private-id|usage/); }); it("deduplicates extract URLs, nests v1 settings, and reports partial failure without error content", async () => { const failed = "https://example.com/unavailable"; - const fetcher = reply({ results: [source], errors: [{ url: failed, error_type: "fetch_error", http_status_code: 403, content: "private diagnostic" }], warnings: [{ detail: "private diagnostic" }] }); - const output = await extractWeb({ objective: query.objective, urls: [source.url, failed, source.url] }, { fetch: fetcher }); + const fetcher = reply({ + results: [source], + errors: [ + { + url: failed, + error_type: "fetch_error", + http_status_code: 403, + content: "private diagnostic", + }, + ], + warnings: [{ detail: "private diagnostic" }], + }); + const output = await extractWeb( + { objective: query.objective, urls: [source.url, failed, source.url] }, + { fetch: fetcher }, + ); expect(fetcher.mock.calls[0][0]).toBe("https://api.parallel.ai/v1/extract"); expect(JSON.parse(fetcher.mock.calls[0][1]!.body as string)).toEqual({ - objective: query.objective, urls: [source.url, failed], max_chars_total: 16_000, - advanced_settings: { full_content: false, fetch_policy: { timeout_seconds: 20 }, excerpt_settings: { max_chars_per_result: 6_000 } }, + objective: query.objective, + urls: [source.url, failed], + max_chars_total: 16_000, + advanced_settings: { + full_content: false, + fetch_policy: { timeout_seconds: 20 }, + excerpt_settings: { max_chars_per_result: 6_000 }, + }, + }); + expect(output).toMatchObject({ + results: [source], + errors: [{ url: failed, error_type: "fetch_error", http_status_code: 403 }], + warning_count: 1, }); - expect(output).toMatchObject({ results: [source], errors: [{ url: failed, error_type: "fetch_error", http_status_code: 403 }], warning_count: 1 }); expect(JSON.stringify(output)).not.toContain("private diagnostic"); }); @@ -43,42 +95,72 @@ describe("Parallel public research", () => { const result = { ...source, url: source.url + "?author=2&author_name=example" }; const fetcher = reply({ results: [result] }); expect((await searchWeb(query, { fetch: fetcher })).results).toEqual([result]); - expect((await extractWeb({ urls: [result.url], objective: query.objective }, { fetch: fetcher })).results).toEqual([result]); + expect( + (await extractWeb({ urls: [result.url], objective: query.objective }, { fetch: fetcher })) + .results, + ).toEqual([result]); expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body)).urls).toEqual([result.url]); }); it("keeps usable sources and errors when other returned URLs are excluded", async () => { - const error = { url: source.url + "/missing", error_type: "fetch_error", http_status_code: 404 }; - const output = await searchWeb(query, { fetch: reply({ - results: [{ ...source, url: source.url + "?access_token=private" }, source], - errors: [{ ...error, url: "http://localhost" }, error], - }) }); + const error = { + url: source.url + "/missing", + error_type: "fetch_error", + http_status_code: 404, + }; + const output = await searchWeb(query, { + fetch: reply({ + results: [{ ...source, url: source.url + "?access_token=private" }, source], + errors: [{ ...error, url: "http://localhost" }, error], + }), + }); expect(output).toMatchObject({ results: [source], errors: [error], truncated: true }); expect(JSON.stringify(output)).not.toMatch(/private|localhost/); }); it("returns partial extraction results before the client deadline", async () => { vi.useFakeTimers(); - vi.spyOn(AbortSignal, "timeout").mockImplementation(milliseconds => { + vi.spyOn(AbortSignal, "timeout").mockImplementation((milliseconds) => { const controller = new AbortController(); - setTimeout(() => controller.abort(new DOMException("Request timed out", "TimeoutError")), milliseconds); + setTimeout( + () => controller.abort(new DOMException("Request timed out", "TimeoutError")), + milliseconds, + ); return controller.signal; }); const error = { url: source.url + "/slow", error_type: "timeout" }; try { - const outcome = extractWeb({ urls: [source.url, error.url], objective: query.objective }, { - fetch: async (_url, options) => { - const body = z.object({ advanced_settings: z.object({ fetch_policy: z.object({ timeout_seconds: z.number() }).optional() }) }).parse(JSON.parse(String(options?.body))); - return new Promise((resolve, reject) => { - const onAbort = () => { clearTimeout(timer); reject(options?.signal?.reason); }; - const timer = setTimeout(() => { - options?.signal?.removeEventListener("abort", onAbort); - resolve(Response.json({ results: [source], errors: [error] })); - }, (body.advanced_settings.fetch_policy?.timeout_seconds ?? 60) * 1_000 + 1_000); - options?.signal?.addEventListener("abort", onAbort, { once: true }); - }); + const outcome = extractWeb( + { urls: [source.url, error.url], objective: query.objective }, + { + fetch: async (_url, options) => { + const body = z + .object({ + advanced_settings: z.object({ + fetch_policy: z.object({ timeout_seconds: z.number() }).optional(), + }), + }) + .parse(JSON.parse(String(options?.body))); + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(options?.signal?.reason); + }; + const timer = setTimeout( + () => { + options?.signal?.removeEventListener("abort", onAbort); + resolve(Response.json({ results: [source], errors: [error] })); + }, + (body.advanced_settings.fetch_policy?.timeout_seconds ?? 60) * 1_000 + 1_000, + ); + options?.signal?.addEventListener("abort", onAbort, { once: true }); + }); + }, }, - }).then(output => ({ output }), (error: unknown) => ({ error })); + ).then( + (output) => ({ output }), + (error: unknown) => ({ error }), + ); await vi.advanceTimersByTimeAsync(30_000); expect(await outcome).toMatchObject({ output: { results: [source], errors: [error] } }); } finally { @@ -87,40 +169,83 @@ describe("Parallel public research", () => { }); it("enforces count, per-source and total excerpt limits even when the provider ignores them", async () => { - const results = Array.from({ length: 12 }, (_, i) => ({ ...source, url: `https://example.com/${i}`, excerpts: ["a".repeat(1_500), "b".repeat(3_000)] })); + const results = Array.from({ length: 12 }, (_, i) => ({ + ...source, + url: `https://example.com/${i}`, + excerpts: ["a".repeat(1_500), "b".repeat(3_000)], + })); const output = await searchWeb({ ...query, max_results: 10 }, { fetch: reply({ results }) }); expect(output.results).toHaveLength(10); expect(output.truncated).toBe(true); expect(output.results.every((r) => r.excerpts.join("").length <= 2_000)).toBe(true); expect(output.results.flatMap((r) => r.excerpts).join("")).toHaveLength(16_000); - const extracted = await extractWeb({ objective: query.objective, urls: [source.url] }, { fetch: reply({ results: [{ ...source, excerpts: ["a".repeat(8_000)] }] }) }); + const extracted = await extractWeb( + { objective: query.objective, urls: [source.url] }, + { fetch: reply({ results: [{ ...source, excerpts: ["a".repeat(8_000)] }] }) }, + ); expect(extracted.results[0].excerpts.join("")).toHaveLength(6_000); expect(extracted.truncated).toBe(true); }); - it.each(["file:///etc/passwd", "http://localhost", "https://service.internal", "https://a.local.", "https://127.0.0.1", "https://0x7f000001", "https://[::1]", "https://[::ffff:127.0.0.1]", "https://user:secret@example.com", "https://example.com:8443"])("rejects nonpublic or credential-bearing URL %s before a request", (url) => { + it.each([ + "file:///etc/passwd", + "http://localhost", + "https://service.internal", + "https://a.local.", + "https://127.0.0.1", + "https://0x7f000001", + "https://[::1]", + "https://[::ffff:127.0.0.1]", + "https://user:secret@example.com", + "https://example.com:8443", + ])("rejects nonpublic or credential-bearing URL %s before a request", (url) => { const fetcher = reply(); - expect(() => extractWeb({ urls: [url], objective: query.objective }, { fetch: fetcher })).toThrow(); + expect(() => + extractWeb({ urls: [url], objective: query.objective }, { fetch: fetcher }), + ).toThrow(); expect(fetcher).not.toHaveBeenCalled(); }); it("validates empty and excessive inputs before a request", () => { const fetcher = reply(); - for (const input of [{ ...query, objective: " " }, { ...query, search_queries: [] }, { ...query, max_results: 11 }]) + for (const input of [ + { ...query, objective: " " }, + { ...query, search_queries: [] }, + { ...query, max_results: 11 }, + ]) expect(() => searchWeb(input, { fetch: fetcher })).toThrow(); - expect(() => extractWeb({ objective: query.objective, urls: Array(6).fill(source.url) }, { fetch: fetcher })).toThrow(); + expect(() => + extractWeb( + { objective: query.objective, urls: Array(6).fill(source.url) }, + { fetch: fetcher }, + ), + ).toThrow(); expect(fetcher).not.toHaveBeenCalled(); }); - it.each(["?access_token=private", "?X-Amz-Signature=private", "?X-Goog-Credential=private", "?sig=private", "?api%5Fkey=private", "?auth=private", "?authorization=private", "?auth_code=private"])("rejects credential-bearing URL suffix %s before transmission", (suffix) => { + it.each([ + "?access_token=private", + "?X-Amz-Signature=private", + "?X-Goog-Credential=private", + "?sig=private", + "?api%5Fkey=private", + "?auth=private", + "?authorization=private", + "?auth_code=private", + ])("rejects credential-bearing URL suffix %s before transmission", (suffix) => { const fetcher = reply(); - expect(() => extractWeb({ urls: [source.url + suffix], objective: query.objective }, { fetch: fetcher })).toThrow(); + expect(() => + extractWeb({ urls: [source.url + suffix], objective: query.objective }, { fetch: fetcher }), + ).toThrow(); expect(fetcher).not.toHaveBeenCalled(); }); it("removes fragments from submitted and returned URLs without rejecting public page anchors", async () => { const fetcher = reply({ results: [{ ...source, url: source.url + "#overview" }] }); - const output = await extractWeb({ urls: [source.url + "#access_token=private"], objective: query.objective }, { fetch: fetcher }); + const output = await extractWeb( + { urls: [source.url + "#access_token=private"], objective: query.objective }, + { fetch: fetcher }, + ); expect(JSON.parse(fetcher.mock.calls[0][1]!.body as string).urls).toEqual([source.url]); expect(output.results[0].url).toBe(source.url); expect(JSON.stringify(output)).not.toContain("private"); @@ -130,9 +255,15 @@ describe("Parallel public research", () => { const cancel = vi.fn(); let chunks = 0; const body = new ReadableStream({ - pull(controller) { if (++chunks > 8) controller.close(); else controller.enqueue(new Uint8Array(256 * 1_024)); }, cancel, + pull(controller) { + if (++chunks > 8) controller.close(); + else controller.enqueue(new Uint8Array(256 * 1_024)); + }, + cancel, }); - await expect(searchWeb(query, { fetch: async () => new Response(body) })).rejects.toThrow("Parallel returned an invalid response"); + await expect(searchWeb(query, { fetch: async () => new Response(body) })).rejects.toThrow( + "Parallel returned an invalid response", + ); expect(cancel).toHaveBeenCalledOnce(); expect(chunks).toBeLessThanOrEqual(6); }); @@ -141,96 +272,191 @@ describe("Parallel public research", () => { const url = "https://example.com/docs?q=public+research&page=2"; const result = { ...source, url, excerpts: ["café 東京"] }; const bytes = new TextEncoder().encode(JSON.stringify({ results: [result] })); - const body = new ReadableStream({ start(controller) { - for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); - controller.close(); - } }); - expect((await extractWeb({ urls: [url], objective: query.objective }, { fetch: async () => new Response(body) })).results).toEqual([result]); + const body = new ReadableStream({ + start(controller) { + for (const byte of bytes) controller.enqueue(Uint8Array.of(byte)); + controller.close(); + }, + }); + expect( + ( + await extractWeb( + { urls: [url], objective: query.objective }, + { fetch: async () => new Response(body) }, + ) + ).results, + ).toEqual([result]); }); it("does not disclose errors while discarding provider error bodies", async () => { - const body = new ReadableStream({ cancel() { throw new Error("private diagnostic"); } }); - await expect(searchWeb(query, { fetch: async () => new Response(body, { status: 401 }) })).rejects.toThrow(/^Parallel returned HTTP 401\. Check the server API key and app permissions\.$/); + const body = new ReadableStream({ + cancel() { + throw new Error("private diagnostic"); + }, + }); + await expect( + searchWeb(query, { fetch: async () => new Response(body, { status: 401 }) }), + ).rejects.toThrow( + /^Parallel returned HTTP 401\. Check the server API key and app permissions\.$/, + ); }); it("fails clearly without a key and never contacts the provider", async () => { vi.stubEnv("PARALLEL_API_KEY", " "); const fetcher = reply(); - await expect(searchWeb(query, { fetch: fetcher })).rejects.toThrow("Set PARALLEL_API_KEY on the server"); + await expect(searchWeb(query, { fetch: fetcher })).rejects.toThrow( + "Set PARALLEL_API_KEY on the server", + ); expect(fetcher).not.toHaveBeenCalled(); }); - it.each([401, 403, 429, 500, 502, 503, 504])("sanitizes HTTP %i without retrying", async (status) => { - const fetcher = vi.fn(async () => new Response("private provider diagnostic", { status })); - await expect(searchWeb(query, { fetch: fetcher })).rejects.toThrow(`Parallel returned HTTP ${status}.`); - expect(fetcher).toHaveBeenCalledTimes(1); - }); + it.each([401, 403, 429, 500, 502, 503, 504])( + "sanitizes HTTP %i without retrying", + async (status) => { + const fetcher = vi.fn( + async () => new Response("private provider diagnostic", { status }), + ); + await expect(searchWeb(query, { fetch: fetcher })).rejects.toThrow( + `Parallel returned HTTP ${status}.`, + ); + expect(fetcher).toHaveBeenCalledTimes(1); + }, + ); it("sanitizes transport and malformed response errors", async () => { - await expect(searchWeb(query, { fetch: async () => { throw new Error("private transport diagnostic"); } })).rejects.toThrow(/^Parallel request failed\./); + await expect( + searchWeb(query, { + fetch: async () => { + throw new Error("private transport diagnostic"); + }, + }), + ).rejects.toThrow(/^Parallel request failed\./); for (const value of [{ results: null }, { results: [{ ...source, excerpts: 5 }] }]) - await expect(searchWeb(query, { fetch: reply(value) })).rejects.toThrow(/^Parallel returned an invalid response\.$/); - await expect(searchWeb(query, { fetch: async () => new Response("not json") })).rejects.toThrow(/^Parallel returned an invalid response\.$/); + await expect(searchWeb(query, { fetch: reply(value) })).rejects.toThrow( + /^Parallel returned an invalid response\.$/, + ); + await expect(searchWeb(query, { fetch: async () => new Response("not json") })).rejects.toThrow( + /^Parallel returned an invalid response\.$/, + ); }); it("preserves empty search and complete extraction failure as explicit results", async () => { expect((await searchWeb(query, { fetch: reply({ results: [] }) })).results).toEqual([]); const errors = [{ url: source.url, error_type: "fetch_error", http_status_code: 404 }]; - expect(await extractWeb({ objective: query.objective, urls: [source.url] }, { fetch: reply({ results: [], errors }) })).toMatchObject({ results: [], errors }); - }); - - it.each(["disconnect", "truncated"])("rejects a %s response without returning partial evidence or retrying", async (failure) => { - let stream!: ReadableStreamDefaultController; - const body = new ReadableStream({ start(controller) { stream = controller; controller.enqueue(new TextEncoder().encode('{"results":[')); } }); - const fetcher = vi.fn(async () => new Response(body)); - const pending = expect(searchWeb(query, { fetch: fetcher })).rejects.toThrow(/^Parallel returned an invalid response\.$/); - await vi.waitFor(() => expect(body.locked).toBe(true)); - if (failure === "disconnect") stream.error(new Error("private upstream diagnostic")); else stream.close(); - await pending; - expect(fetcher).toHaveBeenCalledOnce(); - expect(body.locked).toBe(false); - }); - - it.each(["AbortError", "TimeoutError"])("cancels a stalled response reader on %s without leaking the lock", async (name) => { - const controller = new AbortController(), cancel = vi.fn(); - if (name === "TimeoutError") vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); - const body = new ReadableStream({ start(stream) { stream.enqueue(new TextEncoder().encode('{"results":[')); }, cancel }); - const fetcher = vi.fn(async () => new Response(body)); - const outcome = searchWeb(query, { fetch: fetcher, signal: name === "AbortError" ? controller.signal : undefined }).catch(error => error); - await vi.waitFor(() => expect(body.locked).toBe(true)); - controller.abort(new DOMException("Research interrupted", name)); - await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce(), { timeout: 300 }); - expect(await outcome).toMatchObject({ name }); - expect(body.locked).toBe(false); - expect(fetcher).toHaveBeenCalledOnce(); + expect( + await extractWeb( + { objective: query.objective, urls: [source.url] }, + { fetch: reply({ results: [], errors }) }, + ), + ).toMatchObject({ results: [], errors }); }); + it.each(["disconnect", "truncated"])( + "rejects a %s response without returning partial evidence or retrying", + async (failure) => { + let stream!: ReadableStreamDefaultController; + const body = new ReadableStream({ + start(controller) { + stream = controller; + controller.enqueue(new TextEncoder().encode('{"results":[')); + }, + }); + const fetcher = vi.fn(async () => new Response(body)); + const pending = expect(searchWeb(query, { fetch: fetcher })).rejects.toThrow( + /^Parallel returned an invalid response\.$/, + ); + await vi.waitFor(() => expect(body.locked).toBe(true)); + if (failure === "disconnect") stream.error(new Error("private upstream diagnostic")); + else stream.close(); + await pending; + expect(fetcher).toHaveBeenCalledOnce(); + expect(body.locked).toBe(false); + }, + ); + + it.each(["AbortError", "TimeoutError"])( + "cancels a stalled response reader on %s without leaking the lock", + async (name) => { + const controller = new AbortController(), + cancel = vi.fn(); + if (name === "TimeoutError") + vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + const body = new ReadableStream({ + start(stream) { + stream.enqueue(new TextEncoder().encode('{"results":[')); + }, + cancel, + }); + const fetcher = vi.fn(async () => new Response(body)); + const outcome = searchWeb(query, { + fetch: fetcher, + signal: name === "AbortError" ? controller.signal : undefined, + }).catch((error) => error); + await vi.waitFor(() => expect(body.locked).toBe(true)); + controller.abort(new DOMException("Research interrupted", name)); + await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce(), { timeout: 300 }); + expect(await outcome).toMatchObject({ name }); + expect(body.locked).toBe(false); + expect(fetcher).toHaveBeenCalledOnce(); + }, + ); + it("isolates concurrent research results and cancellation between conversations", async () => { - const first = new AbortController(), second = new AbortController(); - const abortedBody = new ReadableStream({ start(stream) { stream.enqueue(new TextEncoder().encode('{"results":[')); } }); + const first = new AbortController(), + second = new AbortController(); + const abortedBody = new ReadableStream({ + start(stream) { + stream.enqueue(new TextEncoder().encode('{"results":[')); + }, + }); const fetcher = vi.fn(async (_url, options) => { const body = JSON.parse(String(options?.body)); - return body.objective === "First conversation" ? new Response(abortedBody) : Response.json({ results: [{ ...source, title: "Second conversation", excerpts: ["Second conversation evidence"] }] }); + return body.objective === "First conversation" + ? new Response(abortedBody) + : Response.json({ + results: [ + { + ...source, + title: "Second conversation", + excerpts: ["Second conversation evidence"], + }, + ], + }); }); - const canceled = searchWeb({ ...query, objective: "First conversation" }, { fetch: fetcher, signal: first.signal }).catch(error => error); - const successful = searchWeb({ ...query, objective: "Second conversation" }, { fetch: fetcher, signal: second.signal }); + const canceled = searchWeb( + { ...query, objective: "First conversation" }, + { fetch: fetcher, signal: first.signal }, + ).catch((error) => error); + const successful = searchWeb( + { ...query, objective: "Second conversation" }, + { fetch: fetcher, signal: second.signal }, + ); await vi.waitFor(() => expect(abortedBody.locked).toBe(true)); first.abort(); expect(await canceled).toMatchObject({ name: "AbortError" }); - expect(await successful).toMatchObject({ results: [{ title: "Second conversation", excerpts: ["Second conversation evidence"] }] }); + expect(await successful).toMatchObject({ + results: [{ title: "Second conversation", excerpts: ["Second conversation evidence"] }], + }); expect(second.signal.aborted).toBe(false); expect(fetcher).toHaveBeenCalledTimes(2); }); it("stops before fetch when cancelled and aborts in-flight requests on the 30-second deadline", async () => { const fetcher = reply(); - await expect(searchWeb(query, { fetch: fetcher, signal: AbortSignal.abort() })).rejects.toMatchObject({ name: "AbortError" }); + await expect( + searchWeb(query, { fetch: fetcher, signal: AbortSignal.abort() }), + ).rejects.toMatchObject({ name: "AbortError" }); expect(fetcher).not.toHaveBeenCalled(); const deadline = new AbortController(); const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal); - const pending = searchWeb(query, { fetch: (_url, options) => new Promise((_, reject) => { - options!.signal!.addEventListener("abort", () => reject(options!.signal!.reason), { once: true }); - }) }); + const pending = searchWeb(query, { + fetch: (_url, options) => + new Promise((_, reject) => { + options!.signal!.addEventListener("abort", () => reject(options!.signal!.reason), { + once: true, + }); + }), + }); const rejected = expect(pending).rejects.toMatchObject({ name: "TimeoutError" }); deadline.abort(new DOMException("Request timed out", "TimeoutError")); await rejected; @@ -247,7 +473,9 @@ describe("Parallel public research", () => { expect(search.toModelOutput!(output)).toEqual({ type: "json", value: output }); controller.abort(); expect(fetcher.mock.calls[0][1]!.signal!.aborted).toBe(true); - await expect(extract.execute({ urls: [source.url], objective: query.objective }, ctx)).rejects.toMatchObject({ name: "AbortError" }); + await expect( + extract.execute({ urls: [source.url], objective: query.objective }, ctx), + ).rejects.toMatchObject({ name: "AbortError" }); expect(fetcher).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/postgres.test.ts b/tests/postgres.test.ts index 1ed3ecf..2eb68fc 100644 --- a/tests/postgres.test.ts +++ b/tests/postgres.test.ts @@ -9,7 +9,10 @@ function client(queries: Array<(sql: string) => unknown> = []) { return { rows: [], rowCount: 0 }; }); const release = vi.fn(); - return { query, release } as unknown as PoolClient & { query: ReturnType; release: ReturnType }; + return { query, release } as unknown as PoolClient & { + query: ReturnType; + release: ReturnType; + }; } function poolFor(connection: PoolClient): Pool { @@ -33,9 +36,13 @@ describe("advisory lock", () => { it("destroys the client when the lock is never acquired", async () => { const connection = client([ () => ({ rows: [] }), - () => { throw new Error("lock failed"); }, + () => { + throw new Error("lock failed"); + }, ]); - await expect(withAdvisoryLock(poolFor(connection), "owner", async () => "done")).rejects.toThrow("lock failed"); + await expect( + withAdvisoryLock(poolFor(connection), "owner", async () => "done"), + ).rejects.toThrow("lock failed"); expect(connection.release).toHaveBeenCalledWith(true); expect(connection.query.mock.calls.map((call) => call[0])).toEqual([ "SET statement_timeout = '30s'", @@ -47,9 +54,13 @@ describe("advisory lock", () => { const connection = client([ () => ({ rows: [] }), () => ({ rows: [] }), - () => { throw new Error("unlock failed"); }, + () => { + throw new Error("unlock failed"); + }, ]); - await expect(withAdvisoryLock(poolFor(connection), "owner", async () => "done")).resolves.toBe("done"); + await expect(withAdvisoryLock(poolFor(connection), "owner", async () => "done")).resolves.toBe( + "done", + ); expect(connection.release).toHaveBeenCalledWith(true); }); }); diff --git a/tests/projection.test.ts b/tests/projection.test.ts index ef50fd5..7a0c135 100644 --- a/tests/projection.test.ts +++ b/tests/projection.test.ts @@ -57,23 +57,39 @@ describe("shared browser output in the Bell panel", () => { }); }); it("clears the previous page's capture and status after a login redirects", () => { - expect(browserPreview([ - event({ url: "https://shop.example/login", httpStatus: 200, screenshot: "data:image/png;base64,aA==" }), - event({ - result: { status: "complete" }, - workbench: { browser: { url: "https://shop.example/account" } }, - }), - ])).toEqual({ + expect( + browserPreview([ + event({ + url: "https://shop.example/login", + httpStatus: 200, + screenshot: "data:image/png;base64,aA==", + }), + event({ + result: { status: "complete" }, + workbench: { browser: { url: "https://shop.example/account" } }, + }), + ]), + ).toEqual({ url: "https://shop.example/account", screenshot: undefined, httpStatus: undefined, }); }); it("keeps a new page's own capture and status when its URL changes", () => { - expect(browserPreview([ - event({ url: "https://shop.example/login", httpStatus: 200, screenshot: "data:image/png;base64,aA==" }), - event({ url: "https://shop.example/account", httpStatus: 403, screenshot: "data:image/png;base64,bA==" }), - ])).toEqual({ + expect( + browserPreview([ + event({ + url: "https://shop.example/login", + httpStatus: 200, + screenshot: "data:image/png;base64,aA==", + }), + event({ + url: "https://shop.example/account", + httpStatus: 403, + screenshot: "data:image/png;base64,bA==", + }), + ]), + ).toEqual({ url: "https://shop.example/account", screenshot: "data:image/png;base64,bA==", httpStatus: 403, diff --git a/tests/workspace-runtime.test.ts b/tests/workspace-runtime.test.ts index 687e650..a5dc582 100644 --- a/tests/workspace-runtime.test.ts +++ b/tests/workspace-runtime.test.ts @@ -21,7 +21,10 @@ import { const sessionId = "workspace-import-test"; const context = (signal = new AbortController().signal) => ({ session: { id: sessionId }, abortSignal: signal }) as never; -afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); async function withEveContext(check: () => Promise) { await contextStorage.run(new ContextContainer(), async () => { @@ -34,79 +37,106 @@ async function withEveContext(check: () => Promise) { } describe("Bell's shared browser runtime", () => { - it("redacts form values and preserves the browser identity through Bell's actual import", () => withEveContext(async () => { - vi.stubEnv("BELL_HANDOFF_SECRET", "synthetic-shared-handoff-secret-at-least-32-bytes"); - const fill = vi.fn(async () => {}); - const page = { locator: () => ({ fill }), url: async () => "https://shop.example/", title: async () => "Fixture" }; - await runBrowserOperation(sessionId, async browser => { - const internal = browser as unknown as { state: ConversationState; gateCaptcha(): Promise }; - internal.state.browser = { provider: "browserbase", status: "active", sessionId: "synthetic-hosted-session" }; - vi.spyOn(browser, "start").mockResolvedValue(); - vi.spyOn(browser, "close").mockResolvedValue(); - vi.spyOn(internal, "gateCaptcha").mockResolvedValue(); - Object.assign(browser, { browserbase: {}, stagehand: { - experimentalBatch: async (callback: (context: { page: typeof page }, args: A) => Promise, args: A) => callback({ page }, args), - } }); - }); - const value = "synthetic-private-form-value"; - const output = await run.execute({ actions: [{ action: "fill", target: "#field", value }] }, context()); - expect(fill).toHaveBeenCalledWith(value); - expect(JSON.stringify(output)).not.toContain(value); - if (!("workbench" in output)) throw new Error("Expected one browser result."); - expect(output.workbench.browser.sessionId).toBe("synthetic-hosted-session"); - expect(output.workbench.browser).not.toHaveProperty("liveViewToken"); - })); + it("redacts form values and preserves the browser identity through Bell's actual import", () => + withEveContext(async () => { + vi.stubEnv("BELL_HANDOFF_SECRET", "synthetic-shared-handoff-secret-at-least-32-bytes"); + const fill = vi.fn(async () => {}); + const page = { + locator: () => ({ fill }), + url: async () => "https://shop.example/", + title: async () => "Fixture", + }; + await runBrowserOperation(sessionId, async (browser) => { + const internal = browser as unknown as { + state: ConversationState; + gateCaptcha(): Promise; + }; + internal.state.browser = { + provider: "browserbase", + status: "active", + sessionId: "synthetic-hosted-session", + }; + vi.spyOn(browser, "start").mockResolvedValue(); + vi.spyOn(browser, "close").mockResolvedValue(); + vi.spyOn(internal, "gateCaptcha").mockResolvedValue(); + Object.assign(browser, { + browserbase: {}, + stagehand: { + experimentalBatch: async ( + callback: (context: { page: typeof page }, args: A) => Promise, + args: A, + ) => callback({ page }, args), + }, + }); + }); + const value = "synthetic-private-form-value"; + const output = await run.execute( + { actions: [{ action: "fill", target: "#field", value }] }, + context(), + ); + expect(fill).toHaveBeenCalledWith(value); + expect(JSON.stringify(output)).not.toContain(value); + if (!("workbench" in output)) throw new Error("Expected one browser result."); + expect(output.workbench.browser.sessionId).toBe("synthetic-hosted-session"); + expect(output.workbench.browser).not.toHaveProperty("liveViewToken"); + })); - it("does not recreate Bell's runtime when canceled before queued work starts", () => withEveContext(async () => { - const controller = new AbortController(); - const pending = run.execute({ actions: [{ action: "goto", url: "https://example.com/" }] }, context(controller.signal)); - controller.abort(); - await expect(pending).rejects.toMatchObject({ name: "AbortError" }); - expect(hasBrowserRuntime(sessionId)).toBe(false); - })); - it("shares one runtime across Bell's tool and lifecycle imports", () => withEveContext(async () => { - const result = { completed: 1, url: "https://example.com/" }; - const runBrowser = vi.fn().mockResolvedValue(result); - const snapshotBrowser = vi.fn().mockResolvedValue({ url: result.url }); - const closeBrowser = vi.fn().mockResolvedValue(undefined); + it("does not recreate Bell's runtime when canceled before queued work starts", () => + withEveContext(async () => { + const controller = new AbortController(); + const pending = run.execute( + { actions: [{ action: "goto", url: "https://example.com/" }] }, + context(controller.signal), + ); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(hasBrowserRuntime(sessionId)).toBe(false); + })); + it("shares one runtime across Bell's tool and lifecycle imports", () => + withEveContext(async () => { + const result = { completed: 1, url: "https://example.com/" }; + const runBrowser = vi.fn().mockResolvedValue(result); + const snapshotBrowser = vi.fn().mockResolvedValue({ url: result.url }); + const closeBrowser = vi.fn().mockResolvedValue(undefined); - await runBrowserOperation(sessionId, async (browser) => { - vi.spyOn(browser, "run").mockImplementation(runBrowser); - vi.spyOn(browser, "snapshot").mockImplementation(snapshotBrowser); - vi.spyOn(browser, "close").mockImplementation(closeBrowser); - }); + await runBrowserOperation(sessionId, async (browser) => { + vi.spyOn(browser, "run").mockImplementation(runBrowser); + vi.spyOn(browser, "snapshot").mockImplementation(snapshotBrowser); + vi.spyOn(browser, "close").mockImplementation(closeBrowser); + }); - const actions = [{ action: "goto" as const, url: result.url }]; - expect(await run.execute({ actions }, context())).toMatchObject({ result }); - expect(runBrowser).toHaveBeenCalledWith(actions); - expect(await snapshot.execute({}, context())).toMatchObject({ result: { url: result.url } }); - expect(snapshotBrowser).toHaveBeenCalledOnce(); + const actions = [{ action: "goto" as const, url: result.url }]; + expect(await run.execute({ actions }, context())).toMatchObject({ result }); + expect(runBrowser).toHaveBeenCalledWith(actions); + expect(await snapshot.execute({}, context())).toMatchObject({ result: { url: result.url } }); + expect(snapshotBrowser).toHaveBeenCalledOnce(); - await lifecycle.events!["turn.completed"]!({} as never, context()); - expect(hasBrowserRuntime(sessionId)).toBe(true); - await browserChannelEvents["session.completed"]({}, undefined, context()); - expect(closeBrowser).toHaveBeenCalledOnce(); - expect(hasBrowserRuntime(sessionId)).toBe(false); - })); + await lifecycle.events!["turn.completed"]!({} as never, context()); + expect(hasBrowserRuntime(sessionId)).toBe(true); + await browserChannelEvents["session.completed"]({}, undefined, context()); + expect(closeBrowser).toHaveBeenCalledOnce(); + expect(hasBrowserRuntime(sessionId)).toBe(false); + })); - it("closes the shared runtime when Bell passes an already canceled signal", () => withEveContext(async () => { - const runBrowser = vi.fn(); - const closeBrowser = vi.fn().mockResolvedValue(undefined); - await runBrowserOperation(sessionId, async (browser) => { - vi.spyOn(browser, "run").mockImplementation(runBrowser); - vi.spyOn(browser, "close").mockImplementation(closeBrowser); - }); - const controller = new AbortController(); - controller.abort(); + it("closes the shared runtime when Bell passes an already canceled signal", () => + withEveContext(async () => { + const runBrowser = vi.fn(); + const closeBrowser = vi.fn().mockResolvedValue(undefined); + await runBrowserOperation(sessionId, async (browser) => { + vi.spyOn(browser, "run").mockImplementation(runBrowser); + vi.spyOn(browser, "close").mockImplementation(closeBrowser); + }); + const controller = new AbortController(); + controller.abort(); - await expect( - run.execute( - { actions: [{ action: "goto", url: "https://example.com/" }] }, - context(controller.signal), - ), - ).rejects.toMatchObject({ name: "AbortError" }); - expect(runBrowser).not.toHaveBeenCalled(); - expect(closeBrowser).toHaveBeenCalledOnce(); - expect(hasBrowserRuntime(sessionId)).toBe(false); - })); + await expect( + run.execute( + { actions: [{ action: "goto", url: "https://example.com/" }] }, + context(controller.signal), + ), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(runBrowser).not.toHaveBeenCalled(); + expect(closeBrowser).toHaveBeenCalledOnce(); + expect(hasBrowserRuntime(sessionId)).toBe(false); + })); }); diff --git a/tsconfig.json b/tsconfig.json index 5518ed7..123618a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "strict": true, "noEmit": true, "skipLibCheck": true, @@ -25,15 +21,6 @@ "allowJs": false, "incremental": true }, - "include": [ - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" - ], - "exclude": [ - "node_modules", - ".eve", - ".output" - ] + "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], + "exclude": ["node_modules", ".eve", ".output"] }