From e08e985ff6e4b01df5f678365f73a01fbeff6322 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Fri, 2 Oct 2026 14:20:30 -0400 Subject: [PATCH] Fix Linq wallet authorization delivery and redirects --- README.md | 6 +- agent/channels/linq.ts | 45 ++++++++ .../content/worker-coordination.md | 1 + agent/lib/link-auth.ts | 2 +- app/api/link/route.ts | 67 +++++++++--- .../channels/linq-message-delivery.test.ts | 102 ++++++++++++++++++ .../tools/link-retrieve-spend-request.test.ts | 5 +- tests/integration/link.test.ts | 48 ++++++++- 8 files changed, 256 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 384acc6d..4f122430 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,10 @@ before connecting a different one; reconnecting the same wallet refreshes its grant. Users connect or disconnect their wallet from **Link wallet** in the sidebar. -An agent request that needs a wallet opens the same connection flow and resumes -through Eve's authorization callback. Connection attempts expire after ten +An agent request that needs a wallet sends a native connection link. Opening it +redirects to Link's consent screen after any required OpenInstinct sign-in, then +resumes through Eve's authorization callback. Purchase approval links use Link's +original URLs directly. Connection attempts expire after ten minutes and belong to the signed-in user. Better Auth stores encrypted grants and refreshes tokens; disconnection revokes the Link grant before removing it. Phone sign-in continues to work after disconnecting a wallet. diff --git a/agent/channels/linq.ts b/agent/channels/linq.ts index 37c0e296..dd0dad16 100644 --- a/agent/channels/linq.ts +++ b/agent/channels/linq.ts @@ -67,6 +67,51 @@ const credentials = ( export default linqChannel({ credentials, events: { + async "authorization.required"(event, context, session) { + const { thread } = context; + if (!thread || event.candidateId !== undefined) return; + const displayName = event.authorization?.displayName ?? event.name; + if (!thread.isDM) { + await thread.post({ + raw: `Connect ${displayName} in a direct message with this agent.`, + }); + return; + } + const adapter = context.bot.getAdapter("linq"); + const { chatId, pendingHandle } = adapter.decodeThreadId(thread.id); + if (!chatId || pendingHandle) + throw new Error( + "Authorization delivery requires an existing Linq conversation." + ); + const parts: NonNullable = [ + { + type: "text", + value: [ + event.authorization?.instructions ?? + `Connect ${displayName} to continue.`, + event.authorization?.userCode + ? `Code: ${event.authorization.userCode}` + : undefined, + ] + .filter(Boolean) + .join("\n\n"), + }, + ]; + if (event.authorization?.url) + parts.push({ type: "link", value: event.authorization.url }); + const apiKey = await credentials.apiKey(); + const client = new LinqAPIV3({ apiKey }); + const result = await client.chats.messages.send(chatId, { + message: { + parts, + idempotency_key: `authorization:${session.session.id}:${event.attemptId ?? `${event.turnId}:${event.name}`}`, + }, + }); + context.state.pendingAuthMessageIds = { + ...context.state.pendingAuthMessageIds, + [event.name]: result.message.id, + }; + }, async "action.result"(event, context, session) { const reaction = reactToMessageToolResultSchema.safeParse(event.result); if (event.status === "completed" && reaction.success) { diff --git a/agent/instructions/content/worker-coordination.md b/agent/instructions/content/worker-coordination.md index 53744b2e..24378b28 100644 --- a/agent/instructions/content/worker-coordination.md +++ b/agent/instructions/content/worker-coordination.md @@ -9,6 +9,7 @@ # Link purchases - 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. diff --git a/agent/lib/link-auth.ts b/agent/lib/link-auth.ts index 20ea1945..493c322a 100644 --- a/agent/lib/link-auth.ts +++ b/agent/lib/link-auth.ts @@ -49,7 +49,7 @@ export const linkAuth = defineInteractiveAuthorization<{ attempt: string }>({ ); return { challenge: { - url: `${applicationOrigin()}/link?attempt=${attempt}`, + url: `${applicationOrigin()}/api/link?attempt=${attempt}`, instructions: "Connect your Link wallet to continue.", expiresAt, }, diff --git a/app/api/link/route.ts b/app/api/link/route.ts index a65e0183..c260fa5f 100644 --- a/app/api/link/route.ts +++ b/app/api/link/route.ts @@ -17,6 +17,32 @@ const privateHeaders = { "referrer-policy": "no-referrer", }; +export async function GET(request: Request) { + const attempt = z + .uuid() + .safeParse(new URL(request.url).searchParams.get("attempt")); + if (!attempt.success) + return new Response("Invalid wallet request.", { + status: 400, + headers: privateHeaders, + }); + const session = await getAuthSession(request.headers); + if (!session) + return new Response("Sign in to continue.", { + status: 401, + headers: privateHeaders, + }); + if (!linkConfigured()) + return new Response("Link is not configured.", { + status: 503, + headers: privateHeaders, + }); + + // Only the signed-in owner of a live agent challenge can start this redirect. + // Linking and granting wallet access still require consent on Link. + return connectLink(session.user.id, request.headers, attempt.data); +} + export async function POST(request: Request) { if (request.headers.get("origin") !== applicationOrigin()) { return new Response("Invalid request origin.", { @@ -44,23 +70,35 @@ export async function POST(request: Request) { headers: privateHeaders, }); - try { - if (input.data.operation === "disconnect") { + if (input.data.operation === "disconnect") { + try { await disconnectLink(session.user.id, request.headers); return new Response(null, { status: 303, headers: { ...privateHeaders, location: "/link" }, }); + } catch { + return connectionFailed(input.data.attempt); } - const callbackURL = input.data.attempt - ? await linkAuthorizationCallback(session.user.id, input.data.attempt) + } + return connectLink(session.user.id, request.headers, input.data.attempt); +} + +async function connectLink( + userId: string, + requestHeaders: Headers, + attempt?: string +) { + try { + const callbackURL = attempt + ? await linkAuthorizationCallback(userId, attempt) : new URL("/link", applicationOrigin()).href; const errorCallback = new URL(callbackURL); errorCallback.searchParams.set("error", "authorization_failed"); const auth = await getAuth(); const result = await auth.api.connectLink({ body: { callbackURL, errorCallbackURL: errorCallback.href }, - headers: request.headers, + headers: requestHeaders, returnHeaders: true, }); const headers = new Headers({ @@ -72,13 +110,16 @@ export async function POST(request: Request) { return new Response(null, { status: 303, headers }); } catch { // Provider failures may carry tokens or upstream response bodies. - const destination = new URL("/link", applicationOrigin()); - destination.searchParams.set("error", "connection_failed"); - if (input.data.attempt) - destination.searchParams.set("attempt", input.data.attempt); - return new Response(null, { - status: 303, - headers: { ...privateHeaders, location: destination.href }, - }); + return connectionFailed(attempt); } } + +function connectionFailed(attempt?: string) { + const destination = new URL("/link", applicationOrigin()); + destination.searchParams.set("error", "connection_failed"); + if (attempt) destination.searchParams.set("attempt", attempt); + return new Response(null, { + status: 303, + headers: { ...privateHeaders, location: destination.href }, + }); +} diff --git a/tests/agent/channels/linq-message-delivery.test.ts b/tests/agent/channels/linq-message-delivery.test.ts index 1651b826..46c215da 100644 --- a/tests/agent/channels/linq-message-delivery.test.ts +++ b/tests/agent/channels/linq-message-delivery.test.ts @@ -151,6 +151,11 @@ const handleActionResult = linqChannelCapture.config?.events?.["action.result"]; if (!handleActionResult) { throw new Error("The Linq channel must configure action result delivery."); } +const handleAuthorizationRequired = + linqChannelCapture.config?.events?.["authorization.required"]; +if (!handleAuthorizationRequired) { + throw new Error("The Linq channel must configure authorization delivery."); +} type ActionHandlerParameters = Parameters; @@ -200,6 +205,83 @@ describe("Linq message delivery", () => { expect(post).toHaveBeenCalledExactlyOnceWith({ raw: message }); }); + it("delivers authorization as native text and a connection link without Markdown conversion", async () => { + const { context, post } = handlerContext(); + const event = authorizationEvent(); + await handleAuthorizationRequired(event, context, sessionContext()); + + expect( + linqChannelCapture.sendNativeMessage + ).toHaveBeenCalledExactlyOnceWith("chat-1", { + message: { + idempotency_key: "authorization:session-1:auth-attempt", + parts: [ + { + type: "text", + value: + "Connect your Link wallet to continue.\n\nPurchases require separate approval.", + }, + { + type: "link", + value: + "https://example.com/api/link?attempt=00000000-0000-4000-8000-000000000001", + }, + ], + }, + }); + expect(context.state.pendingAuthMessageIds).toEqual({ + link: "native-message-1", + }); + expect(post).not.toHaveBeenCalled(); + }); + + it("reuses the provider idempotency key when an authorization event is replayed", async () => { + const { context } = handlerContext(); + const event = authorizationEvent(); + await handleAuthorizationRequired(event, context, sessionContext()); + await handleAuthorizationRequired(event, context, sessionContext()); + expect( + linqChannelCapture.sendNativeMessage.mock.calls.map( + ([, body]) => body.message.idempotency_key + ) + ).toEqual([ + "authorization:session-1:auth-attempt", + "authorization:session-1:auth-attempt", + ]); + }); + + it("delivers Link purchase approval URLs unchanged as native links", async () => { + const { context, post } = handlerContext(); + const approvalUrl = + "https://app.link.com/approve/spr_1?approval_token=opaque%2Btoken&source=agent"; + await handleActionResult( + sendMessageResult({ kind: "link", url: approvalUrl }), + context, + sessionContext() + ); + expect( + linqChannelCapture.sendNativeMessage + ).toHaveBeenCalledExactlyOnceWith( + "chat-1", + { + message: { parts: [{ type: "link", value: approvalUrl }] }, + }, + undefined + ); + expect(post).not.toHaveBeenCalled(); + }); + + it("keeps delegated authorization challenges out of the root conversation", async () => { + const { context, post } = handlerContext(); + await handleAuthorizationRequired( + { ...authorizationEvent(), candidateId: "worker" }, + context, + sessionContext() + ); + expect(linqChannelCapture.sendNativeMessage).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + }); + it("sends a native reply to the current inbound message", async () => { const { context, post } = handlerContext(); @@ -921,6 +1003,7 @@ function handlerContext(currentMessageId: string | null = "message-1") { streamingEditIntervalMs: 1000, thread: { id: "linq:dm:chat-1", + isDM: true, post, toJSON: () => ({ _type: "chat:Thread", @@ -941,6 +1024,25 @@ function handlerContext(currentMessageId: string | null = "message-1") { }; } +function authorizationEvent(): Parameters< + NonNullable +>[0] { + return { + name: "link", + description: "Authorization required for Link wallet", + attemptId: "auth-attempt", + authorization: { + displayName: "Link wallet", + instructions: + "Connect your Link wallet to continue.\n\nPurchases require separate approval.", + url: "https://example.com/api/link?attempt=00000000-0000-4000-8000-000000000001", + }, + sequence: 0, + stepIndex: 0, + turnId: "turn-1", + }; +} + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This test adapter deliberately accepts a focused structural fixture. function handlerEventContext(value: unknown): ActionHandlerParameters[1] { // SAFETY: Callers provide every Linq context field exercised by these focused handlers. diff --git a/tests/agent/tools/link-retrieve-spend-request.test.ts b/tests/agent/tools/link-retrieve-spend-request.test.ts index 14d2f097..36d3aa0b 100644 --- a/tests/agent/tools/link-retrieve-spend-request.test.ts +++ b/tests/agent/tools/link-retrieve-spend-request.test.ts @@ -17,7 +17,8 @@ describe("Link status-only override", () => { amount: 2306, currency: "usd", merchant_url: "https://shop.example", - approval_url: "https://link.com/approve", + approval_url: + "https://app.link.com/approve/spr_1?approval_token=opaque%2Btoken&source=agent", card: { id: "card_1", brand: "visa", @@ -37,6 +38,8 @@ describe("Link status-only override", () => { status: "approved", amount: 2306, currency: "usd", + approval_url: + "https://app.link.com/approve/spr_1?approval_token=opaque%2Btoken&source=agent", }); expect(JSON.stringify(output)).not.toMatch( /4242424242424242|098|lpt_secret|spt_secret/u diff --git a/tests/integration/link.test.ts b/tests/integration/link.test.ts index 06be54cc..98dc2810 100644 --- a/tests/integration/link.test.ts +++ b/tests/integration/link.test.ts @@ -43,7 +43,7 @@ describe("Link wallet integration", () => { vi.spyOn(Database, "db", "get").mockReturnValue(database as never); const { getAuth } = await import("@db/services/auth"); const link = await import("@db/services/auth/link"); - const { POST } = await import("@app/api/link/route"); + const { GET, POST } = await import("@app/api/link/route"); const { linkAuth } = await import("@agent/lib/link-auth"); const auth = await getAuth(); const signedIn = await auth.api.verifyPhoneNumber({ @@ -97,7 +97,7 @@ describe("Link wallet integration", () => { throw new Error("Expected a resumable Link authorization."); const pending = authorization.resume; expect(authorization.challenge.url).toBe( - `http://localhost:3000/link?attempt=${pending.attempt}` + `http://localhost:3000/api/link?attempt=${pending.attempt}` ); await expect( link.linkAuthorizationCallback("different-user", pending.attempt) @@ -128,7 +128,37 @@ describe("Link wallet integration", () => { missingOrigin.headers.delete("origin"); expect((await POST(missingOrigin)).status).toBe(403); - const started = await POST(request()); + const authorizationUrl = authorization.challenge.url; + if (!authorizationUrl) throw new Error("Expected a Link connection URL."); + expect( + (await GET(new Request("http://localhost:3000/api/link"))).status + ).toBe(400); + expect( + (await GET(new Request(`${authorizationUrl}invalid`))).status + ).toBe(400); + expect((await GET(new Request(authorizationUrl))).status).toBe(401); + const otherSignedIn = await auth.api.verifyPhoneNumber({ + body: { phoneNumber: "+12025550124", code: "123456" }, + returnHeaders: true, + }); + const otherHeaders = new Headers({ + cookie: otherSignedIn.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]) + .join("; "), + }); + const otherUser = await GET( + new Request(authorizationUrl, { headers: otherHeaders }) + ); + expect(new URL(otherUser.headers.get("location") ?? "").origin).toBe( + "http://localhost:3000" + ); + expect(otherUser.headers.getSetCookie()).toEqual([]); + const navigationHeaders = new Headers(headers); + navigationHeaders.delete("origin"); + const started = await GET( + new Request(authorizationUrl, { headers: navigationHeaders }) + ); expect(started.status).toBe(303); expect(started.headers.get("referrer-policy")).toBe("no-referrer"); const destination = new URL(started.headers.get("location") ?? ""); @@ -252,6 +282,18 @@ describe("Link wallet integration", () => { expect(await link.consumeLinkAuthorization(userId, expired.attempt)).toBe( false ); + const expiredRedirect = await GET( + new Request( + `http://localhost:3000/api/link?attempt=${expired.attempt}`, + { headers } + ) + ); + expect( + new URL(expiredRedirect.headers.get("location") ?? "").searchParams.get( + "error" + ) + ).toBe("connection_failed"); + expect(expiredRedirect.headers.getSetCookie()).toEqual([]); // A different wallet cannot silently replace or coexist with this one. linkSubject = "different-link-user";