diff --git a/agent/instructions/content/worker-coordination.md b/agent/instructions/content/worker-coordination.md index 24378b28..8eaad761 100644 --- a/agent/instructions/content/worker-coordination.md +++ b/agent/instructions/content/worker-coordination.md @@ -11,6 +11,6 @@ - Use the official Link extension for wallet access, spend requests, and approvals. Load `link__create-payment-credential` for a purchase or `link__financial-insights` for balances and transactions. Let Eve handle wallet connection; never ask for tokens, card numbers, or security codes, or install the Link CLI. - Send Link's exact purchase `approval_url` through `send_message` with `kind: "link"` for a native Link preview. Preserve the complete URL and query parameters; do not proxy, wrap, or rewrite approval links. - For a browser purchase, first have the worker establish the exact merchant URL, items, quantities, options, and final total including tax and shipping. Create a one-time `card` spend request with those details through `link__create_spend_request`, using a stable idempotency key for that purchase. Follow Link's approval URL or required user action, then check the same request with `link__retrieve_spend_request`. Creating a request or receiving a user's chat reply does not establish Link approval. -- Our browser flow retrieves card details only inside `fill_from_link`. Do not request credential expansion, retrieve raw credentials through another tool, or put card details in a worker assignment. After Link reports `approved`, resume the same browser worker with the spend request ID, merchant, items and choices, approved amount in minor currency units, currency, and the user's exact purchase authorization. Tell it to recheck checkout and call `fill_from_link`. -- Shared Payment Tokens, Link Pay Tokens, recurring purchases, and cross-origin payment frames are not supported by this browser bridge. Return the specific limitation instead of inventing a merchant integration or switching payment methods. If Link is unconfigured, direct the user to `/link`; do not claim the wallet is connected. +- Our browser flow retrieves card details only inside `fill_from_link`. Do not request credential expansion, retrieve raw credentials through another tool, or put card details in a worker assignment. After Link reports `approved`, resume the same browser worker with the spend request ID, merchant, items and choices, approved amount in minor currency units, currency, and the user's exact purchase authorization. Tell it to recheck checkout and call `fill_from_link`; hosted card forms use the exact top-level checkout URL and visible field selectors, with exact frame URLs when needed to disambiguate. The tool supports Braintree, Shopify, PayPal card fields, and Stripe frames without exposing card values to either agent. +- Shared Payment Tokens, Link Pay Tokens, recurring purchases, and unrecognized cross-origin payment frames are not supported by this browser bridge. Return the specific limitation instead of inventing a merchant integration or switching payment methods. If Link is unconfigured, direct the user to `/link`; do not claim the wallet is connected. - A fill is not a completed purchase. Have the worker submit only the authorized checkout and verify a merchant order confirmation. If the result is uncertain, inspect the existing order and spend request; do not create another request or retry the purchase blindly. A changed total or material term requires a corrected request and approval before proceeding. diff --git a/agent/subagents/browser-agent/instructions.md b/agent/subagents/browser-agent/instructions.md index 85a1d19e..4b727b5b 100644 --- a/agent/subagents/browser-agent/instructions.md +++ b/agent/subagents/browser-agent/instructions.md @@ -26,7 +26,7 @@ You are `browser-agent`, the root coordinator's dedicated browser executor. Comp ## Link checkout - When the coordinator specifies Link, use `fill_from_link` for payment. Do not substitute a saved vault card or ask for payment vault setup. If no approved spend request ID is supplied, preserve the browser and return the exact merchant URL, items, quantities, options, total including tax and shipping, and currency so the coordinator can arrange Link approval. -- With an approved request ID, recheck the purchase and current total, focus a visible card field, and call `fill_from_link` with the browser session ID, spend request ID, observed amount in minor currency units, and lowercase currency. The tool checks the current user's wallet and approved merchant origin. It supports one-time card forms on that origin; stop and report cross-origin payment-frame or unsupported-credential blockers. +- With an approved request ID, recheck the purchase and current total, then call `fill_from_link` with the browser session ID, spend request ID, observed amount in minor currency units, and lowercase currency. For hosted card fields, inspect only their selectors, types, labels, and frame URLs, then supply the exact current top-level `pageUrl` and `fields` bindings. Each binding names `number`, `cvc`, optional `name`, and either `expiration` with `format: "MM/YY"` or `"MM/YYYY"`, or separate `exp_month` and `exp_year`. Each CSS selector must identify one visible input or select; include its exact `frameUrl` to disambiguate repeated selectors. Braintree, Shopify, PayPal card fields, and Stripe frames are supported. Other cross-origin frames remain unsupported. Without bindings, focus a same-origin card field for native autofill. The tool checks the current user's wallet and approved merchant origin; never switch merchants or payment credentials after a failed or uncertain fill. - Never inspect, copy, screenshot, or return filled payment values. Check only form validation and non-secret checkout details. The vault retry-once instruction does not apply to Link: after a failed or uncertain fill, report the state without blindly filling again. Return Link connection or approval blockers to the coordinator. - Filling does not authorize submission. Submit only when the coordinator supplied the user's exact purchase authorization and the checkout still matches. Submit once and verify a merchant order confirmation before reporting success. On a timeout or ambiguous result, preserve the browser and report uncertainty; do not retry the purchase or request another card. diff --git a/agent/subagents/browser-agent/lib/autofill/native.ts b/agent/subagents/browser-agent/lib/autofill/native.ts index 59ab04de..6cba01ae 100644 --- a/agent/subagents/browser-agent/lib/autofill/native.ts +++ b/agent/subagents/browser-agent/lib/autofill/native.ts @@ -1,6 +1,5 @@ -import Kernel from "@onkernel/sdk"; +import { kernel } from "@agent/subagents/browser-agent/lib/kernel"; import { z } from "zod"; -import { env } from "@shared/environment"; import type { AutofillClaim } from "./protocol"; import { classifyNativeLoginControl, @@ -16,6 +15,8 @@ const targetListSchema = z.object({ targetInfos: z.array( z.object({ targetId: z.string(), + parentId: z.string().optional(), + parentFrameId: z.string().optional(), type: z.string(), url: z.string(), }) @@ -33,15 +34,21 @@ type CdpCommandValue = | readonly CdpCommandValue[] | { readonly [key: string]: CdpCommandValue }; +const frameSchema = z.object({ + id: z.string(), + url: z.string(), + urlFragment: z.string().optional(), + parentId: z.string().optional(), +}); const frameTreeSchema = z.object({ frameTree: z.lazy(() => frameTreeNodeSchema), }); const frameTreeNodeSchema: z.ZodType<{ childFrames?: z.infer[]; - frame: { id: string; url: string }; + frame: z.infer; }> = z.object({ childFrames: z.array(z.lazy(() => frameTreeNodeSchema)).optional(), - frame: z.object({ id: z.string(), url: z.string() }), + frame: frameSchema, }); const isolatedWorldSchema = z.object({ executionContextId: z.number() }); @@ -123,11 +130,18 @@ type NativeAutofillKind = "address" | "contact" | "login" | "payment"; export async function currentKernelPageOrigin({ browserSessionId, signal, + pageUrl, }: { readonly browserSessionId: string; readonly signal?: AbortSignal; + readonly pageUrl?: string; }) { - return withKernelPage(browserSessionId, signal, async ({ origin }) => origin); + return withKernelPage( + browserSessionId, + signal, + async ({ origin }) => origin, + pageUrl + ); } export async function fillWithKernelNativeAutofill({ @@ -136,12 +150,14 @@ export async function fillWithKernelNativeAutofill({ expectedOrigin, kind, signal, + pageUrl, }: { readonly browserSessionId: string; readonly claims: readonly AutofillClaim[]; readonly expectedOrigin: string; readonly kind: NativeAutofillKind; readonly signal?: AbortSignal; + readonly pageUrl?: string; }) { const payload = kind === "login" ? undefined : buildNativeAutofillPayload(kind, claims); @@ -205,10 +221,428 @@ export async function fillWithKernelNativeAutofill({ "Chromium could not autofill any visible control. Focus a field in the intended card or address form and retry.", { cause: lastError } ); - } + }, + pageUrl + ); +} + +const paymentFrameOrigins = new Set([ + "https://assets.braintreegateway.com", + "https://checkout.shopifycs.com", + "https://checkout.shopify.com", + "https://www.paypal.com", + "https://www.sandbox.paypal.com", + "https://js.stripe.com", + "https://hooks.stripe.com", +]); + +const paymentBindingMatchesSchema = z.array( + z.object({ + bindingIndex: z.number().int().nonnegative(), + inputIndex: z.number().int().nonnegative(), + }) +); + +// Link's approved one-time card can be delivered to recognized payment frames. +// Saved vault credentials retain their existing same-origin policy above. +export async function fillKernelPaymentFields({ + browserSessionId, + expectedOrigin, + pageUrl, + fields, + signal, +}: { + readonly browserSessionId: string; + readonly expectedOrigin: string; + readonly pageUrl: string; + readonly fields: readonly { + readonly selector: string; + readonly frameUrl?: string; + readonly value: string; + readonly token?: AutofillClaim["token"]; + }[]; + readonly signal?: AbortSignal; +}) { + const page = new URL(pageUrl); + if ( + page.protocol !== "https:" || + page.origin !== expectedOrigin || + page.username || + page.password + ) { + throw new Error( + "Payment field bindings require the approved HTTPS checkout." + ); + } + return withKernelPage( + browserSessionId, + signal, + async ({ + connection, + origin, + sessionId, + frameId, + frameParents, + frameSessions, + }) => { + if (origin !== expectedOrigin) + throw new Error( + "The checkout no longer matches the approved merchant." + ); + const topSessionId = sessionId[0]; + if (!topSessionId) throw new Error("The checkout page is unavailable."); + const topWorld = isolatedWorldSchema.parse( + await connection.send( + "Page.createIsolatedWorld", + { + frameId, + worldName: "open-instinct-link-checkout", + }, + topSessionId + ) + ); + const frames = ( + await Promise.all( + sessionId.map(async (attachedSessionId) => { + const { frameTree } = frameTreeSchema.parse( + await connection.send( + "Page.getFrameTree", + undefined, + attachedSessionId + ) + ); + return flattenFrames(frameTree).map((frame) => ({ + id: frame.id, + url: frame.url, + sessionId: attachedSessionId, + })); + }) + ) + ).flat(); + const matches = ( + await Promise.all( + frames.map(async (frame) => { + if ( + !(await isPaymentFrameVisible( + connection, + frame.id, + frameId, + frameParents, + frameSessions + )) + ) + return []; + const world = await connection + .send( + "Page.createIsolatedWorld", + { + frameId: frame.id, + worldName: "open-instinct-link-fields", + }, + frame.sessionId + ) + .catch(() => undefined); + const parsedWorld = isolatedWorldSchema.safeParse(world); + if (!parsedWorld.success) return []; + const executionContextId = parsedWorld.data.executionContextId; + const frameOrigin = evaluatedStringSchema.parse( + await connection.send( + "Runtime.evaluate", + { + contextId: executionContextId, + expression: frameOriginExpression, + returnByValue: true, + }, + frame.sessionId + ) + ).result.value; + if ( + frameOrigin !== expectedOrigin && + !paymentFrameOrigins.has(frameOrigin) + ) + return []; + const response = evaluatedValueSchema.parse( + await connection.send( + "Runtime.evaluate", + { + contextId: executionContextId, + expression: `(() => { + const bindings = ${JSON.stringify(fields.map(({ selector, frameUrl }) => ({ selector, frameUrl })))}; + const inputs = Array.from(document.querySelectorAll("input, select")); + return bindings.flatMap((binding, bindingIndex) => { + if (binding.frameUrl && binding.frameUrl !== location.href) return []; + return Array.from(document.querySelectorAll(binding.selector)).flatMap((element) => { + if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement)) return []; + if (element.disabled || element.readOnly || element.getClientRects().length === 0) return []; + if (element instanceof HTMLInputElement && !["text", "tel", "number", "password"].includes(element.type)) return []; + const style = getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden") return []; + return [{ bindingIndex, inputIndex: inputs.indexOf(element) }]; + }); + }); + })()`, + returnByValue: true, + }, + frame.sessionId + ) + ); + return Promise.all( + paymentBindingMatchesSchema + .parse(response.result.value) + .map(async (match) => { + const evaluated = evaluatedObjectSchema.parse( + await connection.send( + "Runtime.evaluate", + { + contextId: executionContextId, + expression: `document.querySelectorAll("input, select").item(${String(match.inputIndex)})`, + }, + frame.sessionId + ) + ); + if (!evaluated.result.objectId) + throw new Error("A payment input disappeared."); + const { node } = describedNodeSchema.parse( + await connection.send( + "DOM.describeNode", + { objectId: evaluated.result.objectId }, + frame.sessionId + ) + ); + return { + bindingIndex: match.bindingIndex, + inputIndex: match.inputIndex, + frameId: frame.id, + frameOrigin, + frameUrl: frame.url, + executionContextId, + sessionId: frame.sessionId, + objectId: evaluated.result.objectId, + backendNodeId: node.backendNodeId, + }; + }) + ); + }) + ) + ).flat(); + try { + const controls = fields.map((_field, bindingIndex) => { + const candidates = [ + ...new Map( + matches + .filter((match) => match.bindingIndex === bindingIndex) + .map((match) => [ + `${match.frameId}:${String(match.backendNodeId)}`, + match, + ]) + ).values(), + ]; + if (candidates.length !== 1) + throw new Error( + "Each binding must identify one visible payment input in the approved checkout." + ); + const candidate = candidates[0]; + if (!candidate) throw new Error("A payment input is unavailable."); + return candidate; + }); + if ( + new Set( + controls.map( + ({ frameId: id, backendNodeId }) => + `${id}:${String(backendNodeId)}` + ) + ).size !== controls.length + ) { + throw new Error("Payment bindings must target distinct inputs."); + } + /* oxlint-disable eslint/no-await-in-loop -- Payment fields are written once in order; any uncertain field stops the operation. */ + for (const [index, control] of controls.entries()) { + const topUrl = evaluatedStringSchema.parse( + await connection.send( + "Runtime.evaluate", + { + contextId: topWorld.executionContextId, + expression: "location.href", + returnByValue: true, + }, + topSessionId + ) + ).result.value; + if (topUrl !== pageUrl) + throw new Error("The checkout changed before payment filling."); + const field = fields[index]; + if (!field) throw new Error("A payment binding is unavailable."); + if ( + !(await isPaymentFrameVisible( + connection, + control.frameId, + frameId, + frameParents, + frameSessions + )) + ) { + throw new Error("A payment frame became hidden before filling."); + } + const result = evaluatedBooleanSchema.parse( + await connection.send( + "Runtime.callFunctionOn", + { + objectId: control.objectId, + arguments: [ + { value: field.value }, + { value: control.frameOrigin }, + { value: control.frameUrl }, + { value: field.selector }, + { value: field.token ?? null }, + ], + functionDeclaration: paymentFieldFunction, + returnByValue: true, + }, + control.sessionId + ) + ); + if (!result.result.value) + throw new Error( + "A payment field rejected filling; inspect the existing checkout before continuing." + ); + } + /* oxlint-enable eslint/no-await-in-loop */ + return { filledClaims: controls.length, origin }; + } finally { + await Promise.all( + matches.map(({ objectId, sessionId: attachedSessionId }) => + connection + .send("Runtime.releaseObject", { objectId }, attachedSessionId) + .catch(() => undefined) + ) + ); + } + }, + pageUrl ); } +const frameOwnerSchema = z.object({ + backendNodeId: z.number().int().positive(), +}); +const resolvedNodeSchema = z.object({ + object: z.object({ objectId: z.string() }), +}); + +async function isPaymentFrameVisible( + connection: CdpConnection, + frameId: string, + rootFrameId: string, + parents: ReadonlyMap, + sessions: ReadonlyMap +) { + const visited = new Set(); + let current = frameId; + /* oxlint-disable eslint/no-await-in-loop -- Each frame owner must be inspected in its parent document before moving up the ancestry chain. */ + while (current !== rootFrameId) { + if (visited.has(current)) return false; + visited.add(current); + const parent = parents.get(current); + const parentSession = parent ? sessions.get(parent) : undefined; + if (!parent || !parentSession) return false; + const owner = frameOwnerSchema.safeParse( + await connection + .send("DOM.getFrameOwner", { frameId: current }, parentSession) + .catch(() => undefined) + ); + if (!owner.success) return false; + const world = isolatedWorldSchema.safeParse( + await connection + .send( + "Page.createIsolatedWorld", + { + frameId: parent, + worldName: "open-instinct-link-frame-visibility", + }, + parentSession + ) + .catch(() => undefined) + ); + if (!world.success) return false; + const resolved = resolvedNodeSchema.safeParse( + await connection + .send( + "DOM.resolveNode", + { + backendNodeId: owner.data.backendNodeId, + executionContextId: world.data.executionContextId, + }, + parentSession + ) + .catch(() => undefined) + ); + if (!resolved.success) return false; + const objectId = resolved.data.object.objectId; + try { + const visible = evaluatedBooleanSchema.parse( + await connection.send( + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration: `function() { + const style = getComputedStyle(this); + return this.isConnected && this.getClientRects().length > 0 && style.display !== "none" && style.visibility === "visible"; + }`, + returnByValue: true, + }, + parentSession + ) + ); + if (!visible.result.value) return false; + } finally { + await connection + .send("Runtime.releaseObject", { objectId }, parentSession) + .catch(() => undefined); + } + current = parent; + } + /* oxlint-enable eslint/no-await-in-loop */ + return true; +} + +const paymentFieldFunction = `function(value, expectedOrigin, expectedUrl, selector, token) { + const eligible = (element) => { + if (!(element instanceof HTMLInputElement || element instanceof HTMLSelectElement) || element.disabled || element.readOnly || element.getClientRects().length === 0) return false; + if (element instanceof HTMLInputElement && !["text", "tel", "number", "password"].includes(element.type)) return false; + const style = getComputedStyle(element); + return style.display !== "none" && style.visibility !== "hidden"; + }; + const valid = () => { + if (self.origin !== expectedOrigin || location.href !== expectedUrl || !this.isConnected || !eligible(this)) return false; + const candidates = Array.from(document.querySelectorAll(selector)).filter(eligible); + return candidates.length === 1 && candidates[0] === this; + }; + if (!valid()) return false; + let fillValue = value; + if (this instanceof HTMLSelectElement) { + const options = Array.from(this.options).filter((option) => !option.disabled); + if (!options.some((option) => option.value === value)) { + const equivalent = token === "cc-exp-month" ? options.filter((option) => /^[0-9]{1,2}$/.test(option.value) && Number(option.value) === Number(value)) : []; + if (equivalent.length !== 1) return false; + fillValue = equivalent[0].value; + } + } + this.dataset.vaultSecret = "true"; + this.style.setProperty("-webkit-text-security", "disc", "important"); + this.style.setProperty("color", "transparent", "important"); + this.style.setProperty("text-shadow", "0 0 8px black", "important"); + this.focus(); + if (!valid()) return false; + const prototype = this instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(prototype, "value").set.call(this, fillValue); + this.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertReplacementText", data: fillValue })); + this.dispatchEvent(new Event("change", { bubbles: true })); + this.blur(); + if (!this.isConnected) return false; + if (this.value === fillValue) return true; + return /^[0-9 /-]+$/.test(fillValue) && /^[0-9 /-]+$/.test(this.value) && this.value.replace(/[ /-]/g, "") === fillValue.replace(/[ /-]/g, ""); +}`; + async function fillNativeLoginControls( connection: CdpConnection, sessionIds: readonly string[], @@ -612,20 +1046,35 @@ async function withKernelPage( readonly connection: CdpConnection; readonly origin: string; readonly sessionId: readonly string[]; - }) => Promise + readonly frameId: string; + readonly frameParents: ReadonlyMap; + readonly frameSessions: ReadonlyMap; + }) => Promise, + pageUrl?: string ) { - const browser = await new Kernel({ - apiKey: env.KERNEL_API_KEY, - }).browsers.retrieve(browserSessionId, {}, { signal }); + const browser = await kernel.browsers.retrieve( + browserSessionId, + {}, + { signal } + ); const connection = await CdpConnection.connect(browser.cdp_ws_url, signal); try { const { targetInfos } = targetListSchema.parse( await connection.send("Target.getTargets") ); - const target = targetInfos.findLast( - ({ type, url }) => type === "page" && isWebUrl(url) + const matchingPages = targetInfos.filter( + ({ type, url }) => + type === "page" && + isWebUrl(url) && + (pageUrl === undefined || url === pageUrl) ); + if (pageUrl !== undefined && matchingPages.length !== 1) { + throw new Error( + "The exact checkout page is no longer uniquely available." + ); + } + const target = matchingPages.at(-1); if (!target) throw new Error("No active browser tab was found."); const { sessionId: pageSessionId } = attachedTargetSchema.parse( @@ -640,21 +1089,55 @@ async function withKernelPage( const { frameTree } = frameTreeSchema.parse( await connection.send("Page.getFrameTree", undefined, pageSessionId) ); - const frameIds = new Set(flattenFrames(frameTree).map(({ id }) => id)); - const iframeTargets = targetInfos.filter( - ({ targetId, type }) => type === "iframe" && frameIds.has(targetId) - ); - /* oxlint-disable eslint/no-await-in-loop -- CDP target attachment mutates one connection and session IDs are collected in target order. */ - for (const iframeTarget of iframeTargets) { - const attached = attachedTargetSchema.safeParse( - await connection - .send("Target.attachToTarget", { - flatten: true, - targetId: iframeTarget.targetId, - }) - .catch(() => undefined) - ); - if (attached.success) sessionIds.push(attached.data.sessionId); + const attachedTargets = new Set([target.targetId]); + const frameParents = new Map(); + const frameSessions = new Map(); + /* oxlint-disable eslint/no-await-in-loop -- Discover descendant iframe targets through each attached frame tree before operating on the page. */ + for (let index = 0; index < sessionIds.length; index += 1) { + const currentSessionId = sessionIds[index]; + if (!currentSessionId) continue; + const tree = + index === 0 + ? frameTree + : frameTreeSchema.parse( + await connection.send( + "Page.getFrameTree", + undefined, + currentSessionId + ) + ).frameTree; + const frameEntries = flattenFrames(tree); + const rootParent = targetInfos.find( + ({ targetId }) => targetId === tree.frame.id + )?.parentFrameId; + for (const entry of frameEntries) { + frameSessions.set(entry.id, currentSessionId); + const parent = + entry.parentId ?? + (entry.id === tree.frame.id ? rootParent : undefined); + if (parent) frameParents.set(entry.id, parent); + } + const frameIds = new Set(frameEntries.map(({ id }) => id)); + for (const iframeTarget of targetInfos.filter( + ({ targetId, parentId, type }) => + type === "iframe" && + (frameIds.has(targetId) || + (parentId !== undefined && attachedTargets.has(parentId))) && + !attachedTargets.has(targetId) + )) { + const attached = attachedTargetSchema.safeParse( + await connection + .send("Target.attachToTarget", { + flatten: true, + targetId: iframeTarget.targetId, + }) + .catch(() => undefined) + ); + if (attached.success) { + attachedTargets.add(iframeTarget.targetId); + sessionIds.push(attached.data.sessionId); + } + } } /* oxlint-enable eslint/no-await-in-loop */ @@ -662,6 +1145,9 @@ async function withKernelPage( connection, origin: new URL(target.url).origin, sessionId: sessionIds, + frameId: frameTree.frame.id, + frameParents, + frameSessions, }); } finally { await Promise.all( @@ -807,11 +1293,18 @@ const cdpResponseSchema = z.object({ }); function flattenFrames( - node: z.infer -): { readonly id: string; readonly url: string }[] { + node: z.infer, + parentId?: string +): { readonly id: string; readonly url: string; readonly parentId?: string }[] { return [ - node.frame, - ...(node.childFrames ?? []).flatMap((child) => flattenFrames(child)), + { + id: node.frame.id, + url: `${node.frame.url}${node.frame.urlFragment ?? ""}`, + parentId: node.frame.parentId ?? parentId, + }, + ...(node.childFrames ?? []).flatMap((child) => + flattenFrames(child, node.frame.id) + ), ]; } diff --git a/agent/subagents/browser-agent/lib/autofill/tests/hosted-payment.test.ts b/agent/subagents/browser-agent/lib/autofill/tests/hosted-payment.test.ts new file mode 100644 index 00000000..e83bcd61 --- /dev/null +++ b/agent/subagents/browser-agent/lib/autofill/tests/hosted-payment.test.ts @@ -0,0 +1,332 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { fillKernelPaymentFields } from "../native"; +import { frameOriginExpression } from "../login"; + +vi.mock("@onkernel/sdk", () => ({ + default: class { + browsers = { retrieve: async () => ({ cdp_ws_url: "wss://kernel.test" }) }; + }, +})); + +const commandSchema = z.object({ + id: z.number(), + method: z.string(), + params: z.record(z.string(), z.json()).optional(), + sessionId: z.string().optional(), +}); +const commands: z.infer[] = []; +let processorOrigin = "https://assets.braintreegateway.com"; +let processorFragment = ""; +let changedPage = false; +let omittedChildFrames = false; +let hiddenFrame = false; +let hideAfterWrite = false; +let ambiguous = false; +let duplicate = false; +let failFill = false; +let fills = 0; + +class BrowserSocket extends EventTarget { + constructor() { + super(); + queueMicrotask(() => this.dispatchEvent(new Event("open"))); + } + close() { + this.dispatchEvent(new Event("close")); + } + send(data: string) { + const command = commandSchema.parse(JSON.parse(data)); + commands.push(command); + let result: NonNullable["params"]> = {}; + let error: { message: string } | undefined; + const processorFrame = { + id: "processor-frame", + url: `${processorOrigin}/fields`, + urlFragment: processorFragment, + }; + switch (command.method) { + case "Target.getTargets": + result = { + targetInfos: [ + { + targetId: "page-1", + type: "page", + url: "https://shop.example/checkout", + }, + { + targetId: "processor-frame", + parentId: "page-1", + parentFrameId: "main-frame", + type: "iframe", + url: processorFrame.url, + }, + ], + }; + break; + case "Target.attachToTarget": + result = { + sessionId: + command.params?.targetId === "page-1" + ? "page-session" + : "processor-session", + }; + break; + case "Page.getFrameTree": + result = { + frameTree: + command.sessionId === "page-session" + ? { + frame: { + id: "main-frame", + url: "https://shop.example/checkout", + }, + childFrames: omittedChildFrames + ? [] + : [{ frame: processorFrame }], + } + : { frame: processorFrame }, + }; + break; + case "Page.createIsolatedWorld": + if ( + command.sessionId === "page-session" && + command.params?.frameId === "processor-frame" + ) + error = { message: "Frame belongs to another target" }; + else + result = { + executionContextId: + command.sessionId === "processor-session" ? 2 : 1, + }; + break; + case "Runtime.evaluate": { + const expression = z.string().parse(command.params?.expression); + if (expression === frameOriginExpression) + result = { + result: { + value: + command.params?.contextId === 2 + ? processorOrigin + : "https://shop.example", + }, + }; + else if (expression === "location.href") + result = { + result: { + value: + changedPage && fills > 0 + ? "https://other.example/checkout" + : "https://shop.example/checkout", + }, + }; + else if (expression.includes("const bindings")) + result = { + result: { + value: + command.params?.contextId === 2 + ? [ + { bindingIndex: 0, inputIndex: 0 }, + { bindingIndex: 1, inputIndex: 1 }, + { bindingIndex: 2, inputIndex: duplicate ? 0 : 2 }, + ...(ambiguous + ? [{ bindingIndex: 0, inputIndex: 3 }] + : []), + ] + : [], + }, + }; + else + result = { + result: { + objectId: `input-${/item\((\d+)\)/u.exec(expression)?.[1] ?? "0"}`, + }, + }; + break; + } + case "DOM.describeNode": + result = { + node: { + backendNodeId: + Number( + z.string().parse(command.params?.objectId).split("-").at(-1) + ) + 1, + }, + }; + break; + case "DOM.getFrameOwner": + result = { backendNodeId: 100 }; + break; + case "DOM.resolveNode": + result = { object: { objectId: "frame-owner" } }; + break; + case "Runtime.callFunctionOn": + if (command.params?.objectId === "frame-owner") + result = { + result: { value: !hiddenFrame && !(hideAfterWrite && fills > 0) }, + }; + else { + fills += 1; + result = { result: { value: !failFill } }; + } + break; + } + queueMicrotask(() => + this.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ id: command.id, result, error }), + }) + ) + ); + } +} + +const input = { + browserSessionId: "browser-1", + pageUrl: "https://shop.example/checkout", + expectedOrigin: "https://shop.example", + fields: [ + { selector: "#number", value: "4242424242424242" }, + { selector: "#expiry", value: "12/35" }, + { selector: "#cvc", value: "098" }, + ], +}; +beforeEach(() => { + commands.length = 0; + processorOrigin = "https://assets.braintreegateway.com"; + processorFragment = ""; + changedPage = false; + omittedChildFrames = false; + hiddenFrame = false; + hideAfterWrite = false; + ambiguous = false; + duplicate = false; + failFill = false; + fills = 0; + vi.stubGlobal("WebSocket", BrowserSocket); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("hosted payment field injection", () => { + it.each([ + "https://assets.braintreegateway.com", + "https://checkout.shopifycs.com", + "https://www.paypal.com", + "https://js.stripe.com", + ])("fills one approved checkout across %s frames", async (provider) => { + processorOrigin = provider; + const result = await fillKernelPaymentFields(input); + expect(result).toEqual({ filledClaims: 3, origin: "https://shop.example" }); + const writes = commands.filter( + ({ method, params }) => + method === "Runtime.callFunctionOn" && + params?.objectId !== "frame-owner" + ); + expect(writes).toHaveLength(3); + expect( + writes.every(({ sessionId }) => sessionId === "processor-session") + ).toBe(true); + expect(writes[0]?.params?.functionDeclaration).toEqual( + expect.stringContaining("vaultSecret") + ); + expect(writes[0]?.params?.arguments).toEqual([ + { value: "4242424242424242" }, + { value: provider }, + { value: `${provider}/fields` }, + { value: "#number" }, + { value: null }, + ]); + expect(JSON.stringify(result)).not.toMatch(/4242424242424242|098/u); + expect(commands.some(({ method }) => method === "Autofill.trigger")).toBe( + false + ); + }); + it("discovers out-of-process descendants omitted from the main frame tree", async () => { + omittedChildFrames = true; + await expect(fillKernelPaymentFields(input)).resolves.toEqual({ + filledClaims: 3, + origin: "https://shop.example", + }); + expect(fills).toBe(3); + }); + it("retains hosted frame URL fragments when pinning the field document", async () => { + processorFragment = "#hosted-config"; + await fillKernelPaymentFields(input); + const write = commands.find( + ({ method, params }) => + method === "Runtime.callFunctionOn" && + params?.objectId !== "frame-owner" + ); + expect(write?.params?.arguments).toEqual( + expect.arrayContaining([ + { value: "https://assets.braintreegateway.com/fields#hosted-config" }, + ]) + ); + }); + it("excludes a hidden containing iframe before passing card values", async () => { + hiddenFrame = true; + await expect(fillKernelPaymentFields(input)).rejects.toThrow( + "one visible payment input" + ); + expect(fills).toBe(0); + expect(JSON.stringify(commands)).not.toContain("4242424242424242"); + }); + it("rechecks ancestor visibility before each write", async () => { + hideAfterWrite = true; + await expect(fillKernelPaymentFields(input)).rejects.toThrow( + "frame became hidden" + ); + expect(fills).toBe(1); + }); + it("rejects unrelated frame origins before sending card values", async () => { + processorOrigin = "https://assets.braintreegateway.com.attacker.example"; + await expect(fillKernelPaymentFields(input)).rejects.toThrow( + "one visible payment input" + ); + expect(fills).toBe(0); + expect(JSON.stringify(commands)).not.toContain("4242424242424242"); + }); + it.each(["ambiguous", "duplicate"])( + "rejects %s bindings before filling", + async (kind) => { + ambiguous = kind === "ambiguous"; + duplicate = kind === "duplicate"; + await expect(fillKernelPaymentFields(input)).rejects.toThrow( + /one visible|distinct inputs/u + ); + expect(fills).toBe(0); + } + ); + it("stops on a changed checkout without writing another field", async () => { + changedPage = true; + await expect(fillKernelPaymentFields(input)).rejects.toThrow( + "checkout changed" + ); + expect(fills).toBe(1); + }); + it("stops after the first uncertain field and never retries", async () => { + failFill = true; + await expect(fillKernelPaymentFields(input)).rejects.toThrow( + "rejected filling" + ); + expect(fills).toBe(1); + expect( + commands.filter(({ method }) => method === "Target.attachToTarget") + ).toHaveLength(2); + }); + it("requires the exact approved HTTPS checkout page", async () => { + await expect( + fillKernelPaymentFields({ + ...input, + pageUrl: "https://shop.example/another-checkout", + }) + ).rejects.toThrow("uniquely available"); + await expect( + fillKernelPaymentFields({ + ...input, + pageUrl: "http://shop.example/checkout", + }) + ).rejects.toThrow("approved HTTPS"); + expect(fills).toBe(0); + }); +}); diff --git a/agent/subagents/browser-agent/tools/fill_from_link.ts b/agent/subagents/browser-agent/tools/fill_from_link.ts index 95c3b19f..34039104 100644 --- a/agent/subagents/browser-agent/tools/fill_from_link.ts +++ b/agent/subagents/browser-agent/tools/fill_from_link.ts @@ -14,18 +14,71 @@ import { requireOwnedBrowserSession } from "../lib/owned-browser"; import { currentKernelPageOrigin, fillWithKernelNativeAutofill, + fillKernelPaymentFields, } from "../lib/autofill/native"; -const inputSchema = z.strictObject({ - browserSessionId: z.string().trim().min(1).max(500), - spendRequestId: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/u), - amount: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), - currency: z.string().regex(/^[a-z]{3}$/u), +const paymentFieldSchema = z.strictObject({ + field: z.enum([ + "name", + "number", + "exp_month", + "exp_year", + "expiration", + "cvc", + ]), + selector: z.string().trim().min(1).max(1000), + frameUrl: z.url().max(4000).optional(), + format: z.enum(["MM/YY", "MM/YYYY"]).optional(), }); +const inputSchema = z + .strictObject({ + browserSessionId: z.string().trim().min(1).max(500), + spendRequestId: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/u), + amount: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + currency: z.string().regex(/^[a-z]{3}$/u), + pageUrl: z.url().max(4000).optional(), + fields: z.array(paymentFieldSchema).min(3).max(6).optional(), + }) + .superRefine((input, ctx) => { + if (!input.fields) return; + if (!input.pageUrl) + ctx.addIssue({ + code: "custom", + path: ["pageUrl"], + message: "Field bindings require the exact current checkout URL.", + }); + const roles = new Set(input.fields.map(({ field }) => field)); + const hasExpiry = roles.has("expiration") + ? !roles.has("exp_month") && !roles.has("exp_year") + : roles.has("exp_month") && roles.has("exp_year"); + if ( + roles.size !== input.fields.length || + !roles.has("number") || + !roles.has("cvc") || + !hasExpiry + ) { + ctx.addIssue({ + code: "custom", + path: ["fields"], + message: + "Bind each card field once, including number, CVC, and either combined expiration or both month and year.", + }); + } + for (const [index, binding] of input.fields.entries()) { + if ((binding.field === "expiration") !== (binding.format !== undefined)) { + ctx.addIssue({ + code: "custom", + path: ["fields", index, "format"], + message: "Only combined expiration requires a format.", + }); + } + } + }); + export default defineTool({ description: - "Fill a standard card checkout with an approved Link spend request. Supply only its ID, an owned browser session, and the total amount in minor units and lowercase currency just observed at checkout. The tool retrieves credentials for the signed-in user's wallet, verifies approval and merchant origin, and fills them server-side. Never provide or read card details. Focus a card field first. This does not submit a purchase; verify the merchant and total again before submitting. Shared Payment Tokens, Link Pay Tokens, and recurring requests are unsupported here.", + "Fill a standard card checkout with an approved Link spend request. Supply only its ID, an owned browser session, and the total amount in minor units and lowercase currency just observed at checkout. The tool retrieves credentials for the signed-in user's wallet, verifies approval and merchant origin, and fills them server-side. Never provide or read card details. For hosted payment fields, supply the exact current pageUrl and CSS field bindings observed without reading values; optional frameUrl disambiguates inputs across frames. Combined expiration requires MM/YY or MM/YYYY. Supported processor frames include Braintree, Shopify, PayPal card fields, and Stripe. Without bindings, focus a same-origin card field for native autofill. This does not submit a purchase; verify the merchant and total again before submitting. Shared Payment Tokens, Link Pay Tokens, and recurring requests are unsupported here.", inputSchema, outputSchema: z.object({ success: z.literal(true), @@ -110,6 +163,7 @@ export default defineTool({ const origin = await currentKernelPageOrigin({ browserSessionId: input.browserSessionId, signal: context.abortSignal, + pageUrl: input.pageUrl, }); if (origin !== merchant.origin) { throw new Error( @@ -163,13 +217,38 @@ export default defineTool({ try { // The existing injector rechecks the origin and masks filled card fields. - const result = await fillWithKernelNativeAutofill({ - browserSessionId: input.browserSessionId, - claims, - expectedOrigin: origin, - kind: "payment", - signal: context.abortSignal, - }); + const result = input.fields + ? await fillKernelPaymentFields({ + browserSessionId: input.browserSessionId, + pageUrl: z.url().parse(input.pageUrl), + expectedOrigin: origin, + fields: input.fields.map((binding) => { + const values = { + name: card.billing_address.name, + number: card.number, + exp_month: String(card.exp_month).padStart(2, "0"), + exp_year: String(card.exp_year), + expiration: `${String(card.exp_month).padStart(2, "0")}/${binding.format === "MM/YY" ? String(card.exp_year).slice(-2) : String(card.exp_year)}`, + cvc: card.cvc, + }; + return { + selector: binding.selector, + frameUrl: binding.frameUrl, + value: values[binding.field], + token: + binding.field === "exp_month" ? "cc-exp-month" : undefined, + }; + }), + signal: context.abortSignal, + }) + : await fillWithKernelNativeAutofill({ + browserSessionId: input.browserSessionId, + claims, + expectedOrigin: origin, + kind: "payment", + signal: context.abortSignal, + pageUrl: input.pageUrl, + }); return { success: true as const, spendRequestId: input.spendRequestId, @@ -180,7 +259,7 @@ export default defineTool({ }; } catch { throw new Error( - "Link card autofill could not be confirmed. Check the current checkout without reading payment fields; do not submit or retry blindly." + "Link card filling could not be confirmed. Check field bindings and the current checkout without reading payment values; do not submit or retry blindly." ); } }, diff --git a/agent/tools/link__retrieve_spend_request.ts b/agent/tools/link__retrieve_spend_request.ts index d0dde912..a84b9f1c 100644 --- a/agent/tools/link__retrieve_spend_request.ts +++ b/agent/tools/link__retrieve_spend_request.ts @@ -5,7 +5,7 @@ import { defineTool } from "eve/tools"; // Eve's qualified-name override retains the mounted extension's authorization. export default defineTool({ description: - "Check a Link spend request's approval status and purchase details without retrieving payment credentials. Pass the approved request ID to browser-agent's fill_from_link for a standard card checkout. Credential expansion is intentionally unavailable to the coordinator.", + "Check a Link spend request's approval status and purchase details without retrieving payment credentials. Pass the approved request ID to browser-agent's fill_from_link for a standard or supported hosted card checkout. Credential expansion is intentionally unavailable to the coordinator.", inputSchema: linkToolSchemas.retrieveSpendRequest.omit({ include: true }), async execute({ id }, context) { const request = await retrieve_spend_request.execute({ id }, context); diff --git a/tests/agent/subagents/browser-agent/tools/fill-from-link.test.ts b/tests/agent/subagents/browser-agent/tools/fill-from-link.test.ts index b7824e26..a6037378 100644 --- a/tests/agent/subagents/browser-agent/tools/fill-from-link.test.ts +++ b/tests/agent/subagents/browser-agent/tools/fill-from-link.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; import type { SpendRequest } from "@stripe/link-sdk"; import * as WorkerAccess from "@agent/subagents/browser-agent/lib/access"; import * as OwnedBrowser from "@agent/subagents/browser-agent/lib/owned-browser"; @@ -20,6 +21,7 @@ const access = vi.spyOn(WorkerAccess, "requireWorkerScope"); const owned = vi.spyOn(OwnedBrowser, "requireOwnedBrowserSession"); const origin = vi.spyOn(Autofill, "currentKernelPageOrigin"); const fill = vi.spyOn(Autofill, "fillWithKernelNativeAutofill"); +const fillFields = vi.spyOn(Autofill, "fillKernelPaymentFields"); const token = vi.spyOn(context, "getToken"); const requireAuth = vi.spyOn(context, "requireAuth"); @@ -63,6 +65,10 @@ beforeEach(() => { token.mockResolvedValue({ token: "wallet-token-user-1" }); origin.mockResolvedValue("https://shop.example"); fill.mockResolvedValue({ filledClaims: 5, origin: "https://shop.example" }); + fillFields.mockResolvedValue({ + filledClaims: 3, + origin: "https://shop.example", + }); fetchMock.mockImplementation(async () => Response.json(approvedRequest())); }); afterEach(() => vi.unstubAllGlobals()); @@ -113,6 +119,151 @@ describe("Link browser bridge", () => { ); }); + it.each([ + { + fields: [ + { field: "number", selector: "#number" }, + { field: "expiration", selector: "#expiry", format: "MM/YY" }, + { field: "cvc", selector: "#cvc" }, + ], + }, + { + pageUrl: "https://shop.example/checkout", + fields: [ + { field: "number", selector: "#number" }, + { field: "cvc", selector: "#cvc" }, + { field: "name", selector: "#name" }, + ], + }, + { + pageUrl: "https://shop.example/checkout", + fields: [ + { field: "number", selector: "#number" }, + { field: "expiration", selector: "#expiry" }, + { field: "cvc", selector: "#cvc" }, + ], + }, + { + pageUrl: "https://shop.example/checkout", + fields: [ + { field: "number", selector: "#number" }, + { field: "expiration", selector: "#expiry", format: "MM/YY" }, + { field: "cvc", selector: "#cvc", format: "MM/YY" }, + ], + }, + ])( + "rejects incomplete or contradictory hosted field contracts: %j", + (change) => { + const schema = fillFromLink.inputSchema; + if (!(schema instanceof z.ZodType)) + throw new Error("Expected the authored field schema."); + expect(schema.safeParse({ ...input, ...change }).success).toBe(false); + } + ); + + it.each(["MM/YY", "MM/YYYY"] as const)( + "fills hosted fields through the approved wallet using %s expiration", + async (format) => { + const bindings = [ + { + field: "number" as const, + selector: "#number", + frameUrl: "https://assets.braintreegateway.com/number", + }, + { field: "expiration" as const, selector: "#expiry", format }, + { field: "cvc" as const, selector: "#cvc" }, + ]; + const output = await fillFromLink.execute( + { + ...input, + pageUrl: "https://shop.example/checkout", + fields: bindings, + }, + context + ); + expect(fill).not.toHaveBeenCalled(); + expect(fillFields).toHaveBeenCalledWith( + expect.objectContaining({ + pageUrl: "https://shop.example/checkout", + expectedOrigin: "https://shop.example", + fields: [ + { + selector: "#number", + frameUrl: bindings[0]?.frameUrl, + value: "4242424242424242", + }, + { + selector: "#expiry", + frameUrl: undefined, + value: format === "MM/YY" ? "12/35" : "12/2035", + }, + { selector: "#cvc", frameUrl: undefined, value: "098" }, + ], + }) + ); + expect(origin).toHaveBeenCalledWith( + expect.objectContaining({ pageUrl: "https://shop.example/checkout" }) + ); + expect(token).toHaveBeenCalledWith(linkAuth); + expect(output).toEqual(expect.objectContaining({ filledClaims: 3 })); + expect(JSON.stringify(output)).not.toMatch( + /4242424242424242|098|wallet-token/u + ); + } + ); + + it("preserves the month claim when binding separate expiration selects", async () => { + const request = approvedRequest(); + fetchMock.mockResolvedValue( + Response.json({ ...request, card: { ...request.card, exp_month: 1 } }) + ); + await fillFromLink.execute( + { + ...input, + pageUrl: "https://shop.example/checkout", + fields: [ + { field: "number", selector: "#number" }, + { field: "exp_month", selector: "#month" }, + { field: "exp_year", selector: "#year" }, + { field: "cvc", selector: "#cvc" }, + ], + }, + context + ); + const call = fillFields.mock.calls[0]?.[0]; + if (!call) throw new Error("Missing bound-field injection."); + expect(call.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + selector: "#month", + value: "01", + token: "cc-exp-month", + }), + expect.objectContaining({ selector: "#year", value: "2035" }), + ]) + ); + }); + + it("redacts and does not retry an uncertain hosted-field fill", async () => { + fillFields.mockRejectedValueOnce(new Error("4242424242424242")); + const result = fillFromLink.execute( + { + ...input, + pageUrl: "https://shop.example/checkout", + fields: [ + { field: "number", selector: "#number" }, + { field: "expiration", selector: "#expiry", format: "MM/YY" }, + { field: "cvc", selector: "#cvc" }, + ], + }, + context + ); + await expect(result).rejects.toThrow("could not be confirmed"); + await expect(result).rejects.not.toHaveProperty("cause"); + expect(fillFields).toHaveBeenCalledOnce(); + expect(fill).not.toHaveBeenCalled(); + }); + it.each([ "pending_approval", "requires_action",