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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ GOOGLE_CONNECTOR_UID=
# optional click-to-message shortcut in the workspace.
LINQ_CONNECTOR=
LINQ_PHONE_NUMBER=
# Optional per-user Link wallet connection. Register the exact callback URL
# https://<BETTER_AUTH_URL host>/api/auth/callback/link with Stripe.
LINK_CLIENT_ID=
LINK_CLIENT_SECRET=
STRIPE_PUBLISHABLE_KEY=
# Development benchmarks only (pnpm bench:browser).
BROWSER_BENCH_LABEL=self-hosted
BROWSER_BENCH_REPETITIONS=1
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,52 @@ Gotchas:
- Sending email and creating confirmed calendar events always require approval.
Calendar events with attendees send Google invitations.

## Link wallet

The root agent mounts `@stripe/link-integrations-eve` in
`agent/extensions/link.ts`. It uses Stripe's bundled wallet tools, skills, and
approval policies, with per-user authorization through the existing Better Auth
account. It does not use a shared wallet token.

To enable it, register a Link OAuth client and configure `LINK_CLIENT_ID`,
`LINK_CLIENT_SECRET`, and `STRIPE_PUBLISHABLE_KEY` on the server. Register the
exact redirect URI `https://<your-app-host>/api/auth/callback/link` with Stripe,
including a separate localhost URI when developing locally. Set
`BETTER_AUTH_URL` to the canonical application origin. These values are optional
for installations that do not use Link.

Apply the database migration with `pnpm db:migrate` before enabling Link. It
enforces one Link wallet per OpenInstinct account. Disconnect the current wallet
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
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.

Wallet access is available in interactive conversations, not scheduled workers
or scheduled result delivery. The extension's default Eve approval for creating
spend requests remains enabled, separately from approval in Link. Its tools can
return payment credentials into stored Eve tool results; the bundled skills
instruct the agent not to repeat them in chat. Financial-data tools also require
the corresponding Link grant scopes; the default grant requests
`payment_methods.agentic` and `userinfo:read`.

## Eve compatibility

This branch pins Eve `0.66.3` and the published Link extension `0.2.4`.
The extension's tool contract is supported directly, so it needs no compatibility
rebuild. Browser work uses the background workflow and `agentId` continuation
APIs supported by this Eve version.

Do not move an active session from Eve `0.69` onto this deployment. Keep existing
sessions on their owning deployment until they finish, and start a new
conversation on this branch's deployment. This rollback does not reset or
cancel production sessions.

## Local development

The **Deploy with Vercel** flow above is the simplest way to run OpenInstinct. It
Expand Down
4 changes: 4 additions & 0 deletions agent/extensions/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import link from "@stripe/link-integrations-eve";
import { linkAuth } from "@agent/lib/link-auth";

export default link({ auth: linkAuth });
8 changes: 8 additions & 0 deletions agent/instructions/content/worker-coordination.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,11 @@
- The `run_browser` tool supplies the required structured completion schema on every initial or resumed call, including when passing an existing `agentId`. Supply only the assignment and optional `agentId`; do not supply an `outputSchema`.
- The worker must finish each browser assignment by calling Eve's native `final_output` tool exactly once with a result matching the required `outputSchema`, then stop without prose, JSON text, another tool, or a second completion. Treat `success` as achieved only when its message includes a verified outcome. Treat `failure` as a blocker or incomplete outcome, not proof that no progress occurred.
- Worker images are private, user-scoped artifacts. Ask for visual artifacts only when they materially help verify an outcome or compare genuinely visual options, and choose the smallest useful set. Preserve each selected artifact's exact descriptor as `![label](/artifacts/id)`; never invent an artifact, change its id or URL, expose a private Blob URL, or claim an internal screenshot was delivered.

# 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.
- 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.
- 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.
81 changes: 81 additions & 0 deletions agent/lib/link-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
ConnectionAuthorizationFailedError,
ConnectionAuthorizationRequiredError,
defineInteractiveAuthorization,
type ConnectionPrincipal,
} from "eve/connections";
import {
consumeLinkAuthorization,
getLinkToken,
LinkConnectionRequiredError,
startLinkAuthorization,
} from "@db/services/auth/link";
import { applicationOrigin } from "@shared/environment/origin";
import { scopeFromPrincipal } from "@agent/lib/principal-scope";

function accountUserId(principal: ConnectionPrincipal) {
// Scheduled workers and delivery-only reports cannot acquire wallet access.
if (
principal.type !== "user" ||
!principal.id.startsWith("better-auth:") ||
principal.attributes?.scheduleId
) {
throw new ConnectionAuthorizationFailedError("link", {
reason: "principal_required",
retryable: false,
});
}
scopeFromPrincipal(principal);
return principal.id.slice("better-auth:".length);
}

async function getToken(principal: ConnectionPrincipal) {
try {
return await getLinkToken(accountUserId(principal));
} catch (error) {
if (error instanceof LinkConnectionRequiredError)
throw new ConnectionAuthorizationRequiredError("link");
throw error;
}
}

export const linkAuth = defineInteractiveAuthorization<{ attempt: string }>({
displayName: "Link wallet",
getToken: ({ principal }) => getToken(principal),
async startAuthorization({ principal, callbackUrl }) {
const { attempt, expiresAt } = await startLinkAuthorization(
accountUserId(principal),
callbackUrl
);
return {
challenge: {
url: `${applicationOrigin()}/link?attempt=${attempt}`,
instructions: "Connect your Link wallet to continue.",
expiresAt,
},
resume: { attempt },
};
},
async completeAuthorization({ principal, callback, resume }) {
if (
!resume ||
callback.params.attempt !== resume.attempt ||
!(await consumeLinkAuthorization(
accountUserId(principal),
resume.attempt
))
) {
throw new ConnectionAuthorizationFailedError("link", {
reason: "invalid_state",
retryable: false,
});
}
if (callback.params.error) {
throw new ConnectionAuthorizationFailedError("link", {
reason: "access_denied",
retryable: false,
});
}
return getToken(principal);
},
});
9 changes: 9 additions & 0 deletions agent/subagents/browser-agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ You are `browser-agent`, the root coordinator's dedicated browser executor. Comp

# Execution

## 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.
- 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.

## Browser operation

- Use `playwright_execute` as the primary browser execution surface. Prefer one bounded program per page state that inspects, performs related safe actions, verifies the meaningful outcome, and returns a compact result. When Playwright is unreliable or semantic interaction is more suitable, inspect with `browser_snapshot`, `browser_text`, or `browser_find`, then use `browser_act` for a short relaxed action plan. `browser_act` dispatches actions and returns the successor state without waiting for model-authored postconditions; do not repeat an action merely because strict causal verification is absent. Use `browser_wait_for` only when the next operation truly depends on a delayed user-visible state. Use current refs only, and snapshot again after navigation, a stale-ref error, or an unavailable successor.
- Use `computer_action` only when the page requires visual reasoning or coordinate input that the semantic browser tools cannot express. Never use fixed multi-second sleeps; use `browser_wait_for` with a specific semantic state, URL, title, value, or element condition.
- Create one browser and reuse it. Pass a known target as `start_url`. Start read-only; immediately before a saved login is needed, replace it at the same URL with `save_changes: true`, and delete that writer as soon as authentication succeeds so the profile is saved. Only one writable workspace browser may exist.
Expand Down
3 changes: 3 additions & 0 deletions agent/subagents/browser-agent/lib/autofill/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ export async function fillWithKernelNativeAutofill({
control.sessionId
);
} catch (error) {
// A payment fill may have written values before its response failed.
// Reconcile the checkout instead of retrying another control.
if (kind === "payment") throw error;
lastError = error;
continue;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { fillWithKernelNativeAutofill } 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<typeof commandSchema>[] = [];
let pageOrigin = "https://shop.example";
let frameOrigin = "https://shop.example";
let failFill = false;

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 = {};
switch (command.method) {
case "Target.getTargets":
result = {
targetInfos: [
{ targetId: "page-1", type: "page", url: `${pageOrigin}/checkout` },
],
};
break;
case "Target.attachToTarget":
result = { sessionId: "cdp-1" };
break;
case "Page.getFrameTree":
result = {
frameTree: {
frame: { id: "frame-1", url: `${frameOrigin}/checkout` },
},
};
break;
case "Page.createIsolatedWorld":
result = { executionContextId: 1 };
break;
case "Runtime.evaluate": {
const expression = z.string().parse(command.params?.expression);
if (expression === frameOriginExpression)
result = { result: { value: frameOrigin } };
else if (expression.includes("flatMap"))
result = {
result: {
value: [
{ autocomplete: "cc-number", focused: true, index: 0 },
{ autocomplete: "cc-csc", focused: false, index: 1 },
],
},
};
else if (expression.includes("vaultSecret"))
result = { result: { value: 2 } };
else result = { result: { objectId: "input-1" } };
break;
}
case "DOM.describeNode":
result = { node: { backendNodeId: 1 } };
break;
}
queueMicrotask(() =>
this.dispatchEvent(
new MessageEvent("message", {
data: JSON.stringify({
id: command.id,
result,
error:
command.method === "Autofill.trigger" && failFill
? { message: "unknown fill outcome" }
: undefined,
}),
})
)
);
}
}

const input = {
browserSessionId: "browser-1",
expectedOrigin: "https://shop.example",
kind: "payment" as const,
claims: Object.entries({
"cc-name": "Test Buyer",
"cc-number": "4242424242424242",
"cc-csc": "098",
"cc-exp-month": "12",
"cc-exp-year": "2035",
}).map(([token, value]) => ({ id: token, token, value })),
};
beforeEach(() => {
commands.length = 0;
pageOrigin = "https://shop.example";
frameOrigin = pageOrigin;
failFill = false;
vi.stubGlobal("WebSocket", BrowserSocket);
});
afterEach(() => vi.unstubAllGlobals());

describe("native payment injection", () => {
it("marks controls before sending card fields through CDP and returns only a receipt", async () => {
const result = await fillWithKernelNativeAutofill(input);
const fillIndex = commands.findIndex(
({ method }) => method === "Autofill.trigger"
);
const maskIndex = commands.findIndex(({ params }) =>
z.string().safeParse(params?.expression).data?.includes("vaultSecret")
);
expect(maskIndex).toBeGreaterThan(-1);
expect(maskIndex).toBeLessThan(fillIndex);
expect(commands[fillIndex]?.params?.card).toEqual({
name: "Test Buyer",
number: "4242424242424242",
cvc: "098",
expiryMonth: "12",
expiryYear: "2035",
});
expect(result).toEqual({ filledClaims: 5, origin: "https://shop.example" });
});
it("rechecks the top-level merchant origin before injection", async () => {
pageOrigin = "https://other.example";
await expect(fillWithKernelNativeAutofill(input)).rejects.toThrow(
"no longer matches"
);
expect(commands.some(({ method }) => method === "Autofill.trigger")).toBe(
false
);
});
it("does not disclose cards to a cross-origin frame", async () => {
frameOrigin = "https://processor.example";
await expect(fillWithKernelNativeAutofill(input)).rejects.toThrow(
"No visible form control"
);
expect(commands.some(({ method }) => method === "Autofill.trigger")).toBe(
false
);
});
it("does not try another control after an uncertain payment fill", async () => {
failFill = true;
await expect(fillWithKernelNativeAutofill(input)).rejects.toThrow(
"unknown fill outcome"
);
expect(
commands.filter(({ method }) => method === "Autofill.trigger")
).toHaveLength(1);
});
});
Loading
Loading