Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions agent/channels/linq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LinqMessageContent["parts"]> = [
{
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) {
Expand Down
1 change: 1 addition & 0 deletions agent/instructions/content/worker-coordination.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion agent/lib/link-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
67 changes: 54 additions & 13 deletions app/api/link/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.", {
Expand Down Expand Up @@ -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({
Expand All @@ -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 },
});
}
102 changes: 102 additions & 0 deletions tests/agent/channels/linq-message-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof handleActionResult>;

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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",
Expand All @@ -941,6 +1024,25 @@ function handlerContext(currentMessageId: string | null = "message-1") {
};
}

function authorizationEvent(): Parameters<
NonNullable<typeof handleAuthorizationRequired>
>[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.
Expand Down
5 changes: 4 additions & 1 deletion tests/agent/tools/link-retrieve-spend-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
Loading
Loading