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
11 changes: 11 additions & 0 deletions .changeset/oms-wallet-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@polygonlabs/agent-cli": minor
---

Migrate the embedded wallet to `@polygonlabs/oms-wallet`, the renamed and updated successor to `@0xsequence/typescript-sdk`. Internally `OMSClient` becomes `OMSWallet`, with the same constructor shape and the same `.wallet` / `.indexer` sub-clients.

**Node 22+ is now required** (was Node 20+). Update your runtime before upgrading.

The Google leg of `wallet login` is re-architected onto the SDK's OMS relay: our own OAuth-capture endpoints are gone, and the OMS relay now handles the Google callback directly and returns the browser to the agentconnect login page to finish pairing with the CLI. This is an internal re-architecture with no user-facing behavior change beyond the Node floor: `wallet login` still opens the same login page, still lets you choose Google or email, and sessions still last about a week.

Email login, transactions, and balances are unchanged.
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ This is a pnpm workspace monorepo. The primary package is:

- `packages/polygon-agent-cli/` — CLI tool for on-chain agent operations on Polygon

Wallets use the OMS (Open Money Stack) V3 embedded-wallet model (`@0xsequence/typescript-sdk`,
`OMSClient`): the CLI authenticates via browser login with Google or email (`wallet login`) and
Wallets use the OMS (Open Money Stack) V3 embedded-wallet model (`@polygonlabs/oms-wallet`,
`OMSWallet`): the CLI authenticates via browser login with Google or email (`wallet login`) and
holds the credential on disk.

Static assets (ABI JSON in `contracts/`, Claude skills in `skills/`) are
published with the CLI package but are not source code.

## Development

- Dev environment requires Node 24+ (`.nvmrc`). The published CLI supports Node 20+.
- Dev environment requires Node 24+ (`.nvmrc`). The published CLI supports Node 22+.
- `tsx packages/polygon-agent-cli/src/index.ts` runs the CLI directly from source (tsx handles `.js`→`.ts` remapping for workspace packages).
- `pnpm run build` compiles TypeScript to `dist/` (targeting es2023 for Node 20 compat).
- `pnpm run build` compiles TypeScript to `dist/` (targeting es2023 for Node 22 compat).
- The CLI uses yargs with the `CommandModule` builder/handler pattern.

## Key Directories
Expand Down
237 changes: 237 additions & 0 deletions docs/superpowers/plans/2026-07-16-oms-wallet-sdk-migration.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Migrate to @polygonlabs/oms-wallet SDK

Date: 2026-07-16
Status: approved (scoped design agreed in conversation)

## Problem

The CLI depends on `@0xsequence/typescript-sdk@0.1.0-alpha.4` for the embedded wallet. Polygon now ships the same SDK, rebranded and updated, as `@polygonlabs/oms-wallet` (latest 0.2.0). We migrate to it. Most of the surface is a mechanical rename, but the OIDC redirect API was redesigned and intersects the browser-login flow, so that leg is re-architected.

## Package facts

- New package: `@polygonlabs/oms-wallet@0.2.0`, single dependency `viem ^2.48.4`, `engines.node >= 22`.
- Same internal shape as the old SDK; the main class is renamed `OMSClient` → `OMSWallet` with an identical constructor (`{ publishableKey, storage?, redirectAuthStorage?, credentialSigner? }`) and the same `.wallet` (OMSWalletClient) and `.indexer` (OMSWalletIndexerClient) sub-clients.

## Node version bump

The published CLI moves from Node 20+ to **Node 22+**. Update `.nvmrc` (already 24 for dev), the CLI `package.json` `engines.node`, `CLAUDE.md`, and README/skills wording that says "Node 20+".

## Mechanical changes (unchanged behavior)

Swap the dependency and update imports across the six current import sites (`oms-client.ts`, `oms-storage.ts`, `oms-tx.ts`, `indexer.ts`, `operations.ts`, `operations-ui.tsx`):

| Old symbol | New symbol | Notes |
|---|---|---|
| `OMSClient` | `OMSWallet` | same constructor params; `getOmsClient` return type follows |
| `isOmsSdkError` | `isOMSWalletError` | error helper rename (oms-tx.ts) |
| `EthereumPrivateKeyCredentialSigner` | same | |
| `findNetworkById` | same | |
| `StorageManager` (type) | same | our `FileStorageManager` still implements it |
| `FeeOptionWithBalance` (type) | same | |
| `TransactionMode` | same | |
| `TokenBalance` (type) | same | now from the indexer client module, still exported from root |

Unchanged method signatures we rely on, verified against the 0.2.0 types: `wallet.walletAddress`, `wallet.startEmailAuth`, `wallet.completeEmailAuth`, `wallet.signTypedData`, `wallet.sendTransaction`, `wallet.signOut`, `indexer.getBalances`. Email login, transactions, and balances need only the import/rename changes.

## The OIDC redirect re-architecture (Google leg)

The old API `startOidcRedirectAuth({ provider: 'google', redirectUri, relayRedirectUri }) -> { url, state }` is replaced by `startOidcRedirectAuth({ provider: OmsRelayOidcProviders.google, omsRelayReturnUri }) -> { authorizationUrl }`. Gone: our own `redirectUri`, the `state` handle, and `relayRedirectUri`. The OMS relay now owns the Google OAuth callback and returns the browser to a caller-nominated `omsRelayReturnUri`. PKCE/state is held internally in the SDK's `redirectAuthStorage` (our `FileStorageManager`, in the CLI process).

### New flow (CLI holds the SDK; browser is a surface; existing LoginSession pairing reused)

1. CLI calls `startOidcRedirectAuth({ provider: OmsRelayOidcProviders.google, omsRelayReturnUri })` where `omsRelayReturnUri` is the agentconnect `/login` page carrying this pairing session id. Returns `{ authorizationUrl }`; PKCE/state persists in the CLI's redirect store.
2. CLI publishes status `auth-url` (with `authorizationUrl`) to the LoginSession; the page redirects the browser there.
3. Browser completes Google; the OMS relay handles the callback and returns the browser to `omsRelayReturnUri`, appending its callback params.
4. The returned page reads its full URL (`window.location.href`) and posts it back to the CLI as a new pairing action `{ type: 'oidc-callback', callbackUrl }`.
5. CLI receives the action and calls `completeOidcRedirectAuth({ callbackUrl, walletSelection: 'automatic' })` → `{ walletAddress }`, then publishes `done`.

### What this deletes

The OMS relay now performs the OAuth capture our custom relay used to. Remove from `packages/oidc-relay`:
- The `OidcHandoff` Durable Object and the `/api/oidc/register|cb|poll` routes.
- `src/return-to.ts` and its tests (the `returnTo` open-redirect guard existed only for our capture).
- The `OidcHandoff` DO binding in `wrangler.toml`, with a `deleted_classes` migration (all three envs).

Keep the `LoginSession` DO and `/api/login/*` routes. They remain the browser-to-CLI pairing channel, now also carrying the `oidc-callback` action.

Remove from the CLI: `oidcRelayRedirectUri()` / `SEQUENCE_OIDC_RELAY_URI` (no `relayRedirectUri` anymore); `registerRelaySession` / `pollRelayForCallback` in `oidc-relay-client.ts` (state-keyed handoff is obsolete).

### Protocol additions

`LoginAction` (duplicated in relay `login-session.ts`, CLI `browser-login.ts`, UI `machine.ts`) gains `{ type: 'oidc-callback'; callbackUrl: string }`. The relay's `validAction` accepts it with a bounded `callbackUrl` (string, https or http-localhost, ≤ 2048 chars).

### `--local`

The loopback flow can no longer pass a localhost `redirectUri`. Rework it to set `omsRelayReturnUri` to the localhost callback server URL, open `authorizationUrl`, capture the returned URL at the loopback server, and call `completeOidcRedirectAuth({ callbackUrl })`. This still requires the OMS relay to allow a localhost return URI (the same localhost callback was allowlisted previously). If that proves unavailable, `--local` supports email only and prints a clear message; decide during the e2e task.

## Known unknown (resolve with a live e2e, not by guessing)

Whether the OMS relay preserves our session identifier on `omsRelayReturnUri` and how it appends callback params (query vs fragment) is not documented in the package. Default design: carry the session id in the `omsRelayReturnUri` (query param `?s=<session>`, since fragments may be consumed by the relay), have the page read it and post back the full href. The e2e task confirms both the session and the callback params arrive; if the relay rejects dynamic query on the return URI (exact-match allowlist), fall back to encoding the session in the path.

## Testing

- Unit: relay `LoginSession` action validation incl. `oidc-callback`; CLI browser-login loop google path (fakes: action `google` → publish `auth-url` → action `oidc-callback` → `completeOidcRedirectAuth` → `done`); UI machine transitions for the return-from-relay leg; email/tx/indexer suites stay green after the rename.
- Live e2e (staging relay + agentconnect): real Google login (the re-architected leg), email login (regression), and a `--local` attempt; then `balances` and a `deposit` dry-run to confirm indexer + Trails still work through the migrated SDK.

## Deploy / sequencing

- Migration lands on its own branch; the in-flight release PR (#126) is held and will be re-cut on top of this once verified, so production ships on the new SDK.
- Removing the `OidcHandoff` DO needs a `deleted_classes` migration; staging deploy first, confirm the worker deploys and `/api/login` still serves, then production.
- The Sequence/OMS allowlist target shifts from our `/api/oidc/cb` to the `omsRelayReturnUri` (the agentconnect `/login` page). Note this for the production checklist; it changes what the embedded-wallet team allowlists.

## Out of scope

- Adopting the new SDK's `signInWithOidcIdToken`, `callContract`, `revokeAccess`, or custom (BYO) OIDC providers.
- The Google consent-screen rebrand (separate Google Cloud + Sequence config task).
- Rewriting transaction/indexer logic beyond the import/rename swap.
47 changes: 39 additions & 8 deletions packages/agentconnect-ui/src/login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { LoginAction, MachineEvent, MachineState, RelayStatus } from './mac
import { LogoBadge } from '../App.js';
import { oidcRelayUrl } from '../config';
import { initialState, reduce } from './machine.js';
import { getSessionId, isRelayReturn } from './returnUrl.js';

// Poll fast while waiting on the CLI (returning from the provider, checking a
// code) so the transition to the dashboard feels immediate; poll slower while
Expand Down Expand Up @@ -60,12 +61,40 @@ async function fetchStatus(session: string): Promise<RelayStatus | null> {
const TERMINAL: MachineState['kind'][] = ['success', 'expired', 'failed'];

export function LoginPage() {
const session = window.location.hash.slice(1);
const [state, setState] = useState<MachineState>(initialState);
const session = getSessionId(window.location.search, window.location.hash);
const relayReturn = isRelayReturn(window.location.search);
const [state, setState] = useState<MachineState>(() =>
relayReturn ? reduce(initialState, { type: 'relay-return' }) : initialState
);
const dispatch = useCallback((event: MachineEvent) => {
setState((s) => reduce(s, event));
}, []);
const redirected = useRef(false);
const postedRelayCallback = useRef(false);

// On return from the OMS relay, hand the full callback url back to the CLI
// over the pairing channel once; the CLI exchanges it for the wallet and
// publishes `done`, which the poll below picks up like any other status.
// A failed post (a network error, or a 410 for a session that already
// expired) must not leave the user stuck on the "finishing sign in"
// spinner until the ~10-minute poll timeout, so a false result dispatches
// the terminal failed state right away.
useEffect(() => {
if (postedRelayCallback.current || !session || !relayReturn) return;
postedRelayCallback.current = true;
void postAction(session, { type: 'oidc-callback', callbackUrl: window.location.href }).then(
(ok) => {
if (ok) return;
dispatch({
type: 'status',
status: {
status: 'error',
message: 'Could not reach the login relay. Re-run wallet login in your terminal.'
}
});
}
);
}, [session, relayReturn, dispatch]);

// Once the session is established the user should land on their dashboard
// without another click; the success card shows briefly, then we move on.
Expand Down Expand Up @@ -148,12 +177,14 @@ function renderState(state: MachineState, session: string, dispatch: (e: Machine
<div className="text-center">
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-[#c8cfe1] border-t-[#141635]" />
<p className="mt-4 text-sm text-[#64708f]">Finishing sign in</p>
<a
href={state.url}
className="mt-6 inline-block text-sm text-[#64708f] hover:text-[#141635] underline"
>
Not redirected? Continue with Google
</a>
{state.url && (
<a
href={state.url}
className="mt-6 inline-block text-sm text-[#64708f] hover:text-[#141635] underline"
>
Not redirected? Continue with Google
</a>
)}
</div>
);
case 'email-entry':
Expand Down
23 changes: 23 additions & 0 deletions packages/agentconnect-ui/src/login/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,29 @@ describe('login machine', () => {
);
});

it('relay-return on initial load moves straight to auth-pending, no url', () => {
expect(reduce(initialState, { type: 'relay-return' })).toEqual({ kind: 'auth-pending' });
});

it('relay-return is a no-op once past the method chooser', () => {
expect(reduce({ kind: 'google-wait' }, { type: 'relay-return' })).toEqual({
kind: 'google-wait'
});
expect(reduce({ kind: 'email-entry' }, { type: 'relay-return' })).toEqual({
kind: 'email-entry'
});
});

it('the finishing state reached via relay-return only exits via a terminal status', () => {
const finishing: MachineState = reduce(initialState, { type: 'relay-return' });
expect(
reduce(finishing, { type: 'status', status: { status: 'done', walletAddress: '0xW' } })
).toEqual({ kind: 'success', walletAddress: '0xW' });
expect(reduce(finishing, { type: 'status', status: { status: 'otp-sent' } })).toEqual(
finishing
);
});

it('terminal states absorb any later status or event', () => {
const success: MachineState = { kind: 'success', walletAddress: '0xW' };
expect(reduce(success, { type: 'status', status: { status: 'otp-sent' } })).toEqual(success);
Expand Down
16 changes: 13 additions & 3 deletions packages/agentconnect-ui/src/login/machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ export type RelayStatus = LoginStatus | { status: 'expired' };
export type MachineState =
| { kind: 'method' }
| { kind: 'google-wait' }
| { kind: 'auth-pending'; url: string }
// `url` is present when this came from a live `auth-url` status (offers a
// manual fallback link); absent when the page detected a relay return on
// load, since there is nothing left to link to at that point.
| { kind: 'auth-pending'; url?: string }
| { kind: 'email-entry' }
| { kind: 'email-wait'; email: string }
| { kind: 'otp-entry'; email: string; invalid?: boolean; attemptsLeft?: number }
Expand All @@ -30,13 +33,18 @@ export type MachineEvent =
| { type: 'choose-email' }
| { type: 'submit-email'; email: string }
| { type: 'submit-otp'; code: string }
| { type: 'back' };
| { type: 'back' }
// Fired once on initial mount when the page detects it was just returned to
// from the OMS relay (as opposed to a fresh open). Skips the method chooser
// and goes straight to the finishing spinner.
| { type: 'relay-return' };

export type LoginAction =
| { type: 'google' }
| { type: 'email'; email: string }
| { type: 'otp'; code: string }
| { type: 'cancel' };
| { type: 'cancel' }
| { type: 'oidc-callback'; callbackUrl: string };

export const initialState: MachineState = { kind: 'method' };

Expand Down Expand Up @@ -102,5 +110,7 @@ export function reduce(state: MachineState, event: MachineEvent): MachineState {
return state.kind === 'otp-entry' ? { kind: 'otp-wait', email: state.email } : state;
case 'back':
return state.kind === 'email-entry' ? { kind: 'method' } : state;
case 'relay-return':
return state.kind === 'method' ? { kind: 'auth-pending' } : state;
}
}
59 changes: 59 additions & 0 deletions packages/agentconnect-ui/src/login/returnUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';

import { getSessionId, isRelayReturn } from './returnUrl.ts';

describe('getSessionId', () => {
it('reads the session from the query string', () => {
expect(getSessionId('?s=abc', '')).toBe('abc');
});

it('falls back to the fragment when there is no query', () => {
expect(getSessionId('', '#abc')).toBe('abc');
});

it('prefers the query over the fragment when both are present', () => {
expect(getSessionId('?s=abc', '#xyz')).toBe('abc');
});

it('returns an empty string when neither is present', () => {
expect(getSessionId('', '')).toBe('');
});
});

describe('isRelayReturn', () => {
it('is false for a fragment-only announce link (no query at all)', () => {
expect(isRelayReturn('#abc')).toBe(false);
});

it('is false for a bare `s` with no other query keys', () => {
expect(isRelayReturn('?s=abc')).toBe(false);
});

it('is true once a relay callback param joins `s`', () => {
expect(isRelayReturn('?s=abc&code=xyz')).toBe(true);
});

it('is true with multiple relay callback params alongside `s`', () => {
expect(isRelayReturn('?s=abc&state=1&code=2')).toBe(true);
});

it('is true when the relay reports an oauth error', () => {
expect(isRelayReturn('?s=abc&error=access_denied')).toBe(true);
});

it('is false when a link wrapper appends utm params to a pasted link', () => {
expect(isRelayReturn('?s=abc&utm_source=x')).toBe(false);
});

it('is false when a link wrapper appends a gclid param to a pasted link', () => {
expect(isRelayReturn('?s=abc&gclid=x')).toBe(false);
});

it('is false when `s` is missing, even with other query keys', () => {
expect(isRelayReturn('?code=xyz')).toBe(false);
});

it('is false for an empty query string', () => {
expect(isRelayReturn('')).toBe(false);
});
});
42 changes: 42 additions & 0 deletions packages/agentconnect-ui/src/login/returnUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Pure helpers for detecting the login page's session id and whether the
// load is a bounce back from the OMS relay. Kept free of `window` so they can
// be unit tested directly; the component passes in `location.search` and
// `location.hash`.
//
// Two distinct url shapes land on this page:
// - CLI announce: `/login#<session>` (fragment). The CLI opens this in the
// user's browser with nothing to lose by putting the session in the
// fragment, since there is no round trip through a third party.
// - Relay return: `/login?s=<session>&<oms params>` (query). Once Google
// sign-in bounces through the OMS relay and back, the session has to
// survive as a query param, since a fragment is never sent to (or
// preserved by) a server in the middle of that redirect chain, and the
// relay appends its own callback params (code, state, etc.) alongside it.

// Session id lives in the `?s=` query param (used for the OMS relay return
// URI, since fragments may be consumed by the relay); the `#` fragment is
// kept as a fallback for older announce links.
export function getSessionId(search: string, hash: string): string {
const fromQuery = new URLSearchParams(search).get('s');
if (fromQuery) return fromQuery;
return hash.slice(1);
}

// The OAuth callback params the OMS relay appends to the return URL once
// Google sign-in bounces back through it. The SDK consumes these directly
// from the query string, so their presence is what actually distinguishes a
// relay return from a fresh open.
const OAUTH_CALLBACK_PARAMS = ['code', 'state', 'error'];

// True when this load is the browser bouncing back from the OMS relay after
// Google sign-in, not a fresh open. We key specifically on the OAuth callback
// params (`code`, `state`, `error`) the relay appends alongside `s`, rather
// than on "any other query key present": a link wrapper or ad click can
// append tracking params (`utm_*`, `gclid`, etc.) to a pasted `/login?s=...`
// link, and that must not be mistaken for a relay return that posts a bogus
// callback to the relay.
export function isRelayReturn(search: string): boolean {
const params = new URLSearchParams(search);
if (!params.get('s')) return false;
return OAUTH_CALLBACK_PARAMS.some((key) => params.has(key));
}
Loading
Loading