From 0fa0d105da80957faad5a3220de943589f7c7718 Mon Sep 17 00:00:00 2001 From: James Lawton Date: Thu, 16 Jul 2026 09:29:22 +0100 Subject: [PATCH 1/9] docs: add oms-wallet sdk migration design spec --- ...6-07-16-oms-wallet-sdk-migration-design.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-oms-wallet-sdk-migration-design.md diff --git a/docs/superpowers/specs/2026-07-16-oms-wallet-sdk-migration-design.md b/docs/superpowers/specs/2026-07-16-oms-wallet-sdk-migration-design.md new file mode 100644 index 0000000..6fcd691 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-oms-wallet-sdk-migration-design.md @@ -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=`, 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. From a66ee8ccb77f345576c8239a2c1c608733377aa4 Mon Sep 17 00:00:00 2001 From: James Lawton Date: Thu, 16 Jul 2026 09:31:18 +0100 Subject: [PATCH 2/9] docs: add oms-wallet sdk migration plan --- .../2026-07-16-oms-wallet-sdk-migration.md | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-oms-wallet-sdk-migration.md diff --git a/docs/superpowers/plans/2026-07-16-oms-wallet-sdk-migration.md b/docs/superpowers/plans/2026-07-16-oms-wallet-sdk-migration.md new file mode 100644 index 0000000..dbfb14e --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-oms-wallet-sdk-migration.md @@ -0,0 +1,237 @@ +# OMS Wallet SDK Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Migrate the CLI from `@0xsequence/typescript-sdk@0.1.0-alpha.4` to `@polygonlabs/oms-wallet@0.2.0`, re-architecting the Google OIDC leg onto the SDK's OMS relay and simplifying our relay worker. + +**Architecture:** Mechanical rename swap for email/tx/indexer/errors/networks; a re-architected Google leg where the OMS relay owns the OAuth callback and returns the browser to an `omsRelayReturnUri` (the agentconnect `/login` page), which posts the callback URL back to the CLI over the existing `LoginSession` pairing channel; the CLI calls `completeOidcRedirectAuth({ callbackUrl })`. Our custom `OidcHandoff` DO and `/api/oidc/*` routes are deleted. Spec: `docs/superpowers/specs/2026-07-16-oms-wallet-sdk-migration-design.md`. + +**Tech Stack:** TypeScript, `@polygonlabs/oms-wallet@0.2.0` (viem ^2.48.4, Node 22+), Cloudflare Workers + Durable Objects, React 18 + Vite, vitest. + +## Global Constraints + +- Conventional commit messages, lowercase subject (commitlint `subject-case`: no Sentence/Title/UPPER case in the subject); package scope; never a Co-Authored-By trailer. +- Pre-commit hook runs `pnpm -r run typecheck` across all packages; lint-staged may reformat. +- No em dashes in any copy or comments; sentence case. +- Canonical skill docs live in the repo-root `skills/` tree only. +- `LoginAction`/`LoginStatus` shapes are duplicated verbatim across `packages/oidc-relay/src/login-session.ts`, `packages/polygon-agent-cli/src/lib/browser-login.ts`, and `packages/agentconnect-ui/src/login/machine.ts`; keep them in sync. +- New pinned dep: `@polygonlabs/oms-wallet@0.2.0`. Remove `@0xsequence/typescript-sdk`. Ensure CLI `viem` satisfies the SDK's `^2.48.4` (bump the CLI's `viem ^2.45.3` to `^2.48.4`). +- Do not adopt new SDK features beyond what the current code used (no signInWithOidcIdToken, callContract, BYO providers). + +## Symbol reference (used across tasks) + +New import module `@polygonlabs/oms-wallet`. Renames: `OMSClient`→`OMSWallet`, `isOmsSdkError`→`isOMSWalletError`. Unchanged names: `EthereumPrivateKeyCredentialSigner`, `findNetworkById`, `StorageManager`, `FeeOptionWithBalance`, `TransactionMode`, `TokenBalance`. OIDC: `OmsRelayOidcProviders` (`.google`), `StartOidcRedirectAuthParams` (`{ provider, omsRelayReturnUri? }`), `StartOidcRedirectAuthResult` (`{ authorizationUrl }`), `CompleteOidcRedirectAuthParams` (`{ callbackUrl?, walletSelection? }`). + +New `LoginAction` variant (all three copies): `{ type: 'oidc-callback'; callbackUrl: string }`. + +--- + +### Task 1: Dependency swap and mechanical renames (non-OIDC) + +**Files:** +- Modify: `packages/polygon-agent-cli/package.json` (deps, engines), root `.nvmrc` if needed, root `package.json` engines +- Modify: `packages/polygon-agent-cli/src/lib/oms-storage.ts`, `oms-tx.ts`, `indexer.ts`, `src/commands/operations.ts`, `src/commands/operations-ui.tsx` +- Modify: `packages/polygon-agent-cli/src/lib/oms-client.ts` (OMSClient→OMSWallet only; OIDC helper removal is Task 3) + +**Interfaces:** +- Produces: `getOmsClient()` returns an `OMSWallet`; all non-OIDC imports resolve from `@polygonlabs/oms-wallet`. Later tasks build on this compiling. + +- [ ] **Step 1: Swap the dependency** + +In `packages/polygon-agent-cli/package.json`: remove `"@0xsequence/typescript-sdk": "0.1.0-alpha.4"`, add `"@polygonlabs/oms-wallet": "0.2.0"`, bump `"viem"` to `"^2.48.4"`, and set `"engines": { "node": ">=22" }` (add the field if absent). Then run `pnpm install` and confirm the lockfile updates with no peer errors. + +- [ ] **Step 2: Rename imports in the mechanical sites** + +In each of `oms-storage.ts`, `oms-tx.ts`, `indexer.ts`, `operations.ts`, `operations-ui.tsx`: change the import specifier `@0xsequence/typescript-sdk` → `@polygonlabs/oms-wallet`. In `oms-tx.ts` also rename `isOmsSdkError` → `isOMSWalletError` at its import and call sites. In `oms-client.ts` change the import and `OMSClient` → `OMSWallet` in the import and the `new OMSClient(...)` construction (leave `oidcRelayRedirectUri` for Task 3). + +- [ ] **Step 3: Node version wording** + +`.nvmrc` is already Node 24 (dev); no change needed there. Update root `package.json` `engines.node` to `>=22` if it pins lower. (Doc wording updates land in Task 7.) + +- [ ] **Step 4: Verify and commit** + +Run: `pnpm --filter @polygonlabs/agent-cli typecheck` +Expected: the OIDC code in `wallet.ts`/`browser-login.ts` will still reference old shapes and MAY error; if so, this task's commit is allowed to leave those two files failing ONLY IF they are untouched here (they are). To keep the commit green, run typecheck and if `wallet.ts`/`browser-login.ts` are the only errors and they are about `startOidcRedirectAuth`/`state`, that is expected and fixed in Task 3-4. Prefer: land Task 1 + 3 + 4 before the pre-commit hook runs a full typecheck. Since the hook blocks a red commit, commit Tasks 1, 3, and 4 together if needed (the controller may merge these steps). Otherwise, if typecheck is green, commit: + +```bash +git add packages/polygon-agent-cli/package.json package.json pnpm-lock.yaml packages/polygon-agent-cli/src/lib/oms-storage.ts packages/polygon-agent-cli/src/lib/oms-tx.ts packages/polygon-agent-cli/src/lib/indexer.ts packages/polygon-agent-cli/src/commands/operations.ts packages/polygon-agent-cli/src/commands/operations-ui.tsx packages/polygon-agent-cli/src/lib/oms-client.ts +git commit -m "chore(cli): swap to @polygonlabs/oms-wallet and rename mechanical imports" +``` + +Note to controller: because the pre-commit hook runs a repo-wide typecheck, Tasks 1, 3, and 4 likely must be implemented and committed as one unit (the SDK swap makes the old OIDC calls in wallet.ts/browser-login.ts fail to typecheck until they are reworked). Dispatch them together, or have the implementer stage all three tasks' files and make one commit. Task 2 (relay), Task 5 (UI), Task 6 (docs/tests) are independently committable. + +--- + +### Task 2: Simplify the relay worker (delete OidcHandoff, add oidc-callback action) + +**Files:** +- Modify: `packages/oidc-relay/src/relay.ts` (remove OidcHandoff DO + `/api/oidc/*` routes; keep `/api/login/*`) +- Delete: `packages/oidc-relay/src/return-to.ts`, `packages/oidc-relay/src/return-to.test.ts` +- Modify: `packages/oidc-relay/src/login-session.ts` (`LoginAction` gains `oidc-callback`; `validAction` in relay.ts accepts it) +- Modify: `packages/oidc-relay/wrangler.toml` (drop OidcHandoff binding; add `deleted_classes` migration, all envs) + +**Interfaces:** +- Consumes: nothing new. +- Produces: relay serves only `/api/login/*`; `LoginAction` includes `{ type: 'oidc-callback'; callbackUrl: string }`. Tasks 3 and 5 rely on this action shape. + +- [ ] **Step 1: Extend the action type and validator** + +In `login-session.ts`, add to `LoginAction`: `| { type: 'oidc-callback'; callbackUrl: string }`. In `relay.ts` `validAction`, accept `type === 'oidc-callback'` when `callbackUrl` is a string, 1..2048 chars, starting `https://` or `http://localhost` / `http://127.0.0.1`. + +- [ ] **Step 2: Remove the OidcHandoff DO and /api/oidc routes** + +Delete the `OidcHandoff` class, the `DONE_HTML`/`FAIL_HTML`/`CLOSE_HTML` constants, the `validState`/`validReturnTo` usage tied to it, the `import { validReturnTo }`, and the three `/api/oidc/register|cb|poll` route blocks. Remove `OIDC_RELAY` from the `Env` interface and its `export { OidcHandoff }`. Keep everything under `/api/login/*` and the `LoginSession` export. Delete `return-to.ts` and `return-to.test.ts`. + +- [ ] **Step 3: wrangler.toml migration** + +Remove the `OIDC_RELAY` durable-object binding blocks (top-level, staging, production). Add a new migration tag (e.g. `v3`) with `deleted_classes = ["OidcHandoff"]` to each env's migration list. Keep the `LOGIN_SESSION` binding. + +- [ ] **Step 4: Verify and commit** + +Run: `pnpm --filter @polygonlabs/oidc-relay typecheck && pnpm --filter @polygonlabs/oidc-relay test` +Then a `wrangler dev` smoke on `/api/login/register` + `/api/login/action` with an `oidc-callback` action (expect 204) and confirm `/api/oidc/cb` now 404s. Commit: + +```bash +git add packages/oidc-relay/src/relay.ts packages/oidc-relay/src/login-session.ts packages/oidc-relay/wrangler.toml +git rm packages/oidc-relay/src/return-to.ts packages/oidc-relay/src/return-to.test.ts +git commit -m "refactor(oidc-relay): drop oidchandoff, keep login pairing, add oidc-callback action" +``` + +--- + +### Task 3: CLI OIDC client rework (oms-client + oidc-relay-client) + +**Files:** +- Modify: `packages/polygon-agent-cli/src/lib/oms-client.ts` (remove `oidcRelayRedirectUri`; keep relay/login base URL helpers) +- Modify: `packages/polygon-agent-cli/src/lib/oidc-relay-client.ts` (remove `registerRelaySession`/`pollRelayForCallback`; keep nothing OIDC-specific, or delete the file if now empty) +- Modify: `packages/polygon-agent-cli/src/lib/login-relay-client.ts` (drop `registerOidcHandoff`/`pollOidcCallback` from the relay dep; the callback now arrives as an `oidc-callback` action) + +**Interfaces:** +- Consumes: `LoginAction` with `oidc-callback` (Task 2). +- Produces: `BrowserLoginDeps['relay']` no longer has `registerOidcHandoff`/`pollOidcCallback`; `makeLoginRelay` returns only `registerSession`/`nextAction`/`setStatus`. Task 4 uses the trimmed shape. + +- [ ] **Step 1: oms-client.ts** + +Remove `oidcRelayRedirectUri()` and the `SEQUENCE_OIDC_RELAY_URI` reference (no `relayRedirectUri` in the new SDK). Keep `oidcRelayBaseUrl()` and `loginUiBaseUrl()` (still used for the pairing relay and the login page). `getOmsClient` already returns `OMSWallet` after Task 1. + +- [ ] **Step 2: Trim the relay clients** + +In `oidc-relay-client.ts`, remove `registerRelaySession` and `pollRelayForCallback`. If the file has no remaining exports, delete it and drop its imports. In `login-relay-client.ts`, remove `registerOidcHandoff` and `pollOidcCallback` from the returned object and the `import` of the deleted functions; keep `registerSession`, `nextAction`, `setStatus`. + +- [ ] **Step 3: Verify (compiles with Task 4)** + +Typecheck will fail until Task 4 rewrites the consumers in `browser-login.ts`/`wallet.ts`; implement Task 4 before committing (see Task 1 controller note). No standalone commit. + +--- + +### Task 4: CLI browser-login loop and wallet login rework + +**Files:** +- Modify: `packages/polygon-agent-cli/src/lib/browser-login.ts` (google branch + deps shape) +- Modify: `packages/polygon-agent-cli/src/lib/browser-login.test.ts` +- Modify: `packages/polygon-agent-cli/src/commands/wallet.ts` (handleLogin google + `--local`) + +**Interfaces:** +- Consumes: trimmed relay deps (Task 3); `OmsRelayOidcProviders`, new `startOidcRedirectAuth`/`completeOidcRedirectAuth` signatures. +- Produces: the migrated login flow. + +- [ ] **Step 1: Rework `runBrowserLogin`'s wallet dep and google branch** + +Update `BrowserLoginDeps['wallet']` OIDC methods to the new shapes: `startOidcRedirectAuth(p: { provider: unknown; omsRelayReturnUri: string }): Promise<{ authorizationUrl: string }>` and `completeOidcRedirectAuth(p: { callbackUrl: string; walletSelection: 'automatic' }): Promise<{ walletAddress: string }>`. Add `oidcProviderGoogle` to deps (the `OmsRelayOidcProviders.google` value, injected so tests can fake it). Remove `registerOidcHandoff`/`pollOidcCallback` from `BrowserLoginDeps['relay']`. + +Rewrite the google branch: on the `google` action, call `startOidcRedirectAuth({ provider: deps.oidcProviderGoogle, omsRelayReturnUri: })`, publish `{ status: 'auth-url', url: authorizationUrl }`, then continue the action loop waiting for an `oidc-callback` action; on receiving it, call `completeOidcRedirectAuth({ callbackUrl, walletSelection: 'automatic' })`, publish `done`, return `{ walletAddress, loginMethod: 'google' }`. Keep the single error funnel and timeout. The `omsRelayReturnUri` is `${opts.uiBase}/login?s=${session}` (session in a query param so it survives the relay round-trip; the page reads `s`). + +- [ ] **Step 2: Update tests** + +Rewrite the google-flow tests in `browser-login.test.ts` to the new sequence (action `google` → status `auth-url` → action `{type:'oidc-callback', callbackUrl}` → `completeOidcRedirectAuth` → status `done`), with a fake `oidcProviderGoogle` sentinel and a fake wallet whose `startOidcRedirectAuth` returns `{ authorizationUrl }`. Keep email, cancel, timeout, relay-failure tests (email path unchanged). Add a test that a provider-start failure funnels an error status. + +- [ ] **Step 3: wallet.ts handleLogin** + +In the browser branch, build deps with `oidcProviderGoogle: OmsRelayOidcProviders.google` (import from `@polygonlabs/oms-wallet`) and the trimmed relay. Remove `seqRelay`/`oidcRelayRedirectUri`. For `--local`: set `omsRelayReturnUri` to the loopback server URL, open `authorizationUrl`, capture the returned URL at the loopback server, and call `completeOidcRedirectAuth({ callbackUrl, walletSelection: 'automatic' })`. If the loopback rework is blocked by relay return-URI constraints, make `--local` print that Google-over-local is unavailable and to use the default flow, and keep `--local` for nothing else (decide in Task 8 e2e). Keep the already-logged-in short-circuit and Builder provisioning. + +- [ ] **Step 4: Verify and commit (with Tasks 1 and 3)** + +Run: `pnpm --filter @polygonlabs/agent-cli test && pnpm -r run typecheck` +Commit Tasks 1, 3, 4 together (see Task 1 note): + +```bash +git add packages/polygon-agent-cli +git commit -m "feat(cli): migrate google login to the oms-wallet relay redirect flow" +``` + +--- + +### Task 5: agentconnect-ui login page rework + +**Files:** +- Modify: `packages/agentconnect-ui/src/login/machine.ts`, `machine.test.ts` +- Modify: `packages/agentconnect-ui/src/login/LoginPage.tsx` + +**Interfaces:** +- Consumes: the pairing `/api/login` routes and the `oidc-callback` action. +- Produces: the page that redirects to `authorizationUrl` and, on return from the OMS relay, posts the callback URL back. + +- [ ] **Step 1: machine.ts** + +Add `LoginAction` `oidc-callback` to the UI copy for type parity. The status/state machine is mostly unchanged (`auth-url` still triggers the redirect side effect; `done`/`error`/`expired` terminal). Add a state or flag for "returned from relay, posting callback" if helpful (e.g. reuse `auth-pending`). Update tests for any new transition. + +- [ ] **Step 2: LoginPage.tsx** + +Read the session id from `?s=` (query) first, falling back to the `#` fragment for backward compatibility. On initial load, detect whether the URL carries the OMS relay callback params (the page was returned to from the relay). If so, POST `{ type: 'oidc-callback', callbackUrl: window.location.href }` to `/api/login/action` for this session, and show the "finishing sign in" state. Otherwise render the method chooser as today. Keep the `google-wait` → redirect-to-`authorizationUrl` side effect (the `auth-url` status carries the URL). Keep email/OTP unchanged. Keep the success auto-redirect to the dashboard. + +- [ ] **Step 3: Verify and commit** + +Run: `pnpm --filter @polygonlabs/agentconnect-ui typecheck && pnpm --filter @polygonlabs/agentconnect-ui lint && pnpm --filter @polygonlabs/agentconnect-ui test` +Commit: + +```bash +git add packages/agentconnect-ui/src/login +git commit -m "feat(agentconnect-ui): post oms relay callback back over the login pairing channel" +``` + +--- + +### Task 6: Docs, changeset, and Node-version wording + +**Files:** +- Modify: `CLAUDE.md`, `packages/polygon-agent-cli/README.md`, `skills/SKILL.md`, `skills/polygon-agent-cli/SKILL.md` +- Modify/Create: `.changeset/*.md` + +**Interfaces:** none. + +- [ ] **Step 1: Docs** + +Update any "Node 20+" to "Node 22+". Update the wallet-auth description if it mentions `SEQUENCE_OIDC_RELAY_URI` or our `/api/oidc/cb` capture (now the OMS relay handles the Google callback). Do not rename the storage dir or env vars unrelated to this change. + +- [ ] **Step 2: Changeset** + +Add `.changeset/oms-wallet-sdk.md` (`"@polygonlabs/agent-cli": minor`): migrates the embedded wallet to `@polygonlabs/oms-wallet`, requires Node 22+, and re-architects the Google login onto the OMS relay (no behavior change for users beyond the Node floor). Lowercase-subject-safe body; no em dashes. + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md packages/polygon-agent-cli/README.md skills/SKILL.md skills/polygon-agent-cli/SKILL.md .changeset +git commit -m "docs: note oms-wallet migration and node 22 requirement" +``` + +--- + +### Task 7: Build, unit-verify, and live e2e + +**Files:** none (verification; fixes get their own commits). + +- [ ] **Step 1: Full build + unit** + +`pnpm -r run typecheck`, `pnpm -r --if-present run test`, `pnpm --filter @polygonlabs/agent-cli run build`. All green. + +- [ ] **Step 2: Deploy the relay + UI to staging** + +Deploy `oidc-relay` staging (confirm the `deleted_classes` migration applies cleanly and `/api/login` still serves; `/api/oidc/cb` 404s) and agentconnect-ui staging (with the new LoginPage). Note: the OMS relay must allow the `omsRelayReturnUri` (`agentconnect.staging.polygon.technology/login`); confirm with the embedded-wallet team that the return URI is allowlisted, since the target changed from `/api/oidc/cb`. + +- [ ] **Step 3: Live e2e (human in the loop)** + +From a clean HOME, `agent wallet login`: real Google login through the OMS relay leg, confirm the page returns and posts the callback, the CLI completes and reports `builderProvisioned` and a wallet address; email login (regression); a `--local` attempt (record whether Google-over-local works or is disabled). Then `agent balances` and `agent deposit --asset USDC --amount 0.1` dry-run to confirm indexer + Trails via the migrated SDK. Resolve the session-carry / callback-param known unknown here; if `?s=` does not survive, switch to a path-encoded session and re-verify. + +- [ ] **Step 4: Whole-branch review, then re-cut the release** + +Run the final whole-branch review. Once green, re-cut the held release (PR #126 equivalent) on top of this branch so production ships on the new SDK, and update the production checklist (allowlist target is now the `omsRelayReturnUri`, not `/api/oidc/cb`). From 6c9ebe64584f5ebc982b32f6ed4d2bcf9fef2d3a Mon Sep 17 00:00:00 2001 From: James Lawton Date: Thu, 16 Jul 2026 09:36:50 +0100 Subject: [PATCH 3/9] refactor(oidc-relay): drop oidchandoff, keep login pairing, add oidc-callback action --- packages/oidc-relay/src/login-session.ts | 5 +- packages/oidc-relay/src/relay.ts | 195 ++++------------------ packages/oidc-relay/src/return-to.test.ts | 28 ---- packages/oidc-relay/src/return-to.ts | 23 --- packages/oidc-relay/wrangler.toml | 29 ++-- 5 files changed, 48 insertions(+), 232 deletions(-) delete mode 100644 packages/oidc-relay/src/return-to.test.ts delete mode 100644 packages/oidc-relay/src/return-to.ts diff --git a/packages/oidc-relay/src/login-session.ts b/packages/oidc-relay/src/login-session.ts index 9ed973f..07e7745 100644 --- a/packages/oidc-relay/src/login-session.ts +++ b/packages/oidc-relay/src/login-session.ts @@ -11,7 +11,8 @@ export type LoginAction = | { type: 'google' } | { type: 'email'; email: string } | { type: 'otp'; code: string } - | { type: 'cancel' }; + | { type: 'cancel' } + | { type: 'oidc-callback'; callbackUrl: string }; export type LoginStatus = | { status: 'awaiting-method' } @@ -129,7 +130,7 @@ export class LoginSessionCore { } } -const SESSION_TTL_MS = 10 * 60 * 1000; // matches OidcHandoff +const SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes to complete the login function doStore(storage: DurableObjectStorage): SessionStore { return { diff --git a/packages/oidc-relay/src/relay.ts b/packages/oidc-relay/src/relay.ts index 7a65914..3dce6fe 100644 --- a/packages/oidc-relay/src/relay.ts +++ b/packages/oidc-relay/src/relay.ts @@ -1,36 +1,27 @@ -// OIDC handoff relay (Cloudflare Worker + Durable Object). +// Browser-login pairing relay (Cloudflare Worker + Durable Object). // -// Purpose: let a CLI on ANY machine (including a remote server / openclaw) finish -// a browser OIDC login. Google can only redirect to a fixed public HTTPS URL and -// a localhost callback can't be reached when the browser is on a different -// machine than the CLI. So this relay sits at a public URL: it captures the -// OAuth `code`+`state` when the browser is redirected here, and the CLI POLLS for -// them, so there is no localhost dependency and it works local and remote alike. +// Purpose: let a CLI on ANY machine (including a remote server / openclaw) drive +// a browser-based login. The CLI arms a pairing session, the browser page polls +// for status and submits the chosen login method (Google, email, OTP), and the +// CLI polls for that input. The OMS relay now owns the Google OAuth callback +// itself; this relay only carries pairing state between the CLI and the page, +// including the `oidc-callback` action the page submits once the OMS relay +// returns the browser to it. // -// Security: the relay only ever sees `code`+`state`. The PKCE `code_verifier` and -// the wallet credential never leave the CLI, so a code alone cannot complete the -// login. Entries are short-lived (TTL) and the result is one-time read. -// -// Sessions are keyed by the OIDC `state` the CLI already holds (returned by -// startOidcRedirectAuth). That keeps the redirect target a single stable URL with -// no dynamic query, so the upstream relay only has to allowlist one path. -// -// Endpoints (all under /api/oidc): -// POST /register { state } (CLI: arm a handoff for this state) -// GET /cb?code&state (or ?error&state) (browser: the redirect lands here) -// GET /poll?state -> { status, code?, state?, error? } (CLI: poll for result) +// Endpoints (all under /api/login): +// POST /register { session } (CLI: arm a pairing session) +// POST /action { session, action } (browser: submit user input) +// GET /next-action?session= -> { state, action? } (CLI: poll for input, one-time read) +// POST /status { session, ...LoginStatus } (CLI: publish state) +// GET /status?session= -> LoginStatus (browser: poll state, repeat-readable) import type { LoginAction } from './login-session.ts'; import { LoginSession, parseLoginStatus } from './login-session.ts'; -import { validReturnTo } from './return-to.ts'; export { LoginSession }; -const SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes to complete the login - interface Env { - OIDC_RELAY: DurableObjectNamespace; LOGIN_SESSION: DurableObjectNamespace; } @@ -45,17 +36,24 @@ function json(data: unknown, status = 200): Response { return cors(Response.json(data, { status })); } -// `state` is the SDK's base64url-encoded OIDC state. Bound it so a junk query -// can't be used to address arbitrary Durable Objects, and reject anything that -// isn't the shape we expect before using it as a DO name. -function validState(s: string | null): s is string { - return typeof s === 'string' && s.length > 0 && s.length <= 4096 && /^[A-Za-z0-9_-]+$/.test(s); -} - function validSession(s: string | null): s is string { return typeof s === 'string' && /^[A-Za-z0-9_-]{16,64}$/.test(s); } +// Bound the callback URL the browser hands back after the OMS relay finishes the +// Google OAuth leg. It must be an https URL, or an http localhost/127.0.0.1 URL +// for local dev, so a junk value can't be smuggled through as an action. +function validCallbackUrl(u: unknown): u is string { + return ( + typeof u === 'string' && + u.length > 0 && + u.length <= 2048 && + (u.startsWith('https://') || + u.startsWith('http://localhost') || + u.startsWith('http://127.0.0.1')) + ); +} + // Shape-check browser-submitted actions before they reach the DO. function validAction(a: unknown): a is LoginAction { if (typeof a !== 'object' || a === null) return false; @@ -71,96 +69,10 @@ function validAction(a: unknown): a is LoginAction { const code = (a as { code?: unknown }).code; return typeof code === 'string' && code.length >= 4 && code.length <= 16; } - return false; -} - -const DONE_HTML = `Login complete - -

You're signed in

-

Login complete. You can close this tab and return to your terminal.

`; - -const FAIL_HTML = DONE_HTML.replace("You're signed in", 'Login failed').replace( - 'Login complete. You can close this tab and return to your terminal.', - 'Something went wrong. Return to your terminal for details.' -); - -// Shown when the callback is hit again after the CLI already consumed the -// one-time session (a browser refresh/duplicate) or after it expired. Login is -// driven from the terminal, so this is neutral, not an error. -const CLOSE_HTML = DONE_HTML.replace("You're signed in", 'All set').replace( - 'Login complete. You can close this tab and return to your terminal.', - 'This sign-in link has already been used. Check your terminal — you can close this tab.' -); - -// --- Durable Object: one instance per OIDC `state` --- -export class OidcHandoff { - private state: DurableObjectState; - constructor(state: DurableObjectState) { - this.state = state; - } - - async fetch(request: Request): Promise { - const url = new URL(request.url); - const op = url.pathname; // internal path set by the router below - - if (op === '/register') { - const returnTo = url.searchParams.get('returnTo'); - await this.state.storage.put('status', 'pending'); - if (returnTo) await this.state.storage.put('returnTo', returnTo); - await this.state.storage.setAlarm(Date.now() + SESSION_TTL_MS); - return new Response(null, { status: 204 }); - } - - if (op === '/capture') { - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const error = url.searchParams.get('error'); - const status = await this.state.storage.get('status'); - if (!status) - return new Response(CLOSE_HTML, { status: 200, headers: { 'Content-Type': 'text/html' } }); - const returnTo = await this.state.storage.get('returnTo'); - if (error) { - await this.state.storage.put({ status: 'error', error }); - // With a returnTo the branded page renders the failure (the CLI publishes - // an error status); without one, keep the legacy inline page. - if (returnTo) return Response.redirect(returnTo, 302); - return new Response(FAIL_HTML, { status: 200, headers: { 'Content-Type': 'text/html' } }); - } - if (!code || !state) return new Response('missing code or state', { status: 400 }); - await this.state.storage.put({ status: 'ready', code, state }); - if (returnTo) return Response.redirect(returnTo, 302); - return new Response(DONE_HTML, { status: 200, headers: { 'Content-Type': 'text/html' } }); - } - - if (op === '/poll') { - const status = await this.state.storage.get('status'); - if (!status) return Response.json({ status: 'expired' }); - if (status === 'ready') { - const [code, state] = await Promise.all([ - this.state.storage.get('code'), - this.state.storage.get('state') - ]); - await this.state.storage.deleteAll(); // one-time read - return Response.json({ status: 'ready', code, state }); - } - if (status === 'error') { - const error = await this.state.storage.get('error'); - await this.state.storage.deleteAll(); - return Response.json({ status: 'error', error }); - } - return Response.json({ status: 'pending' }); - } - - return new Response('not found', { status: 404 }); - } - - // TTL: drop the entry if the login never completes. - async alarm(): Promise { - await this.state.storage.deleteAll(); + if (t === 'oidc-callback') { + return validCallbackUrl((a as { callbackUrl?: unknown }).callbackUrl); } + return false; } // --- Worker entry / router --- @@ -177,51 +89,6 @@ export default { return json({ ok: true, service: 'oidc-relay' }); } - // POST /api/oidc/register { state } -> arm the DO for this state. - // The CLI already holds `state` from startOidcRedirectAuth, so there is no - // separate request id: the state IS the handle. - if (request.method === 'POST' && url.pathname === '/api/oidc/register') { - let body: unknown; - try { - body = await request.json(); - } catch { - return json({ error: 'invalid json' }, 400); - } - const state = (body as { state?: unknown } | null)?.state; - if (!validState(typeof state === 'string' ? state : null)) { - return json({ error: 'invalid state' }, 400); - } - const returnTo = (body as { returnTo?: unknown } | null)?.returnTo; - if (returnTo !== undefined && !validReturnTo(returnTo)) { - return json({ error: 'invalid returnTo' }, 400); - } - const stub = env.OIDC_RELAY.get(env.OIDC_RELAY.idFromName(state as string)); - const inner = new URL('https://do/register'); - if (returnTo) inner.searchParams.set('returnTo', returnTo as string); - await stub.fetch(new Request(inner.toString(), { method: 'POST' })); - return cors(new Response(null, { status: 204 })); - } - - // GET /api/oidc/cb?code=...&state=... (the browser's redirect target). - // Serves HTML to the browser, so it is NOT CORS-wrapped. - if (request.method === 'GET' && url.pathname === '/api/oidc/cb') { - const state = url.searchParams.get('state'); - if (!validState(state)) return new Response('missing or invalid state', { status: 400 }); - const stub = env.OIDC_RELAY.get(env.OIDC_RELAY.idFromName(state)); - const inner = new URL('https://do/capture'); - inner.search = url.search; // forward code/state/error - return stub.fetch(new Request(inner.toString(), { redirect: 'manual' })); - } - - // GET /api/oidc/poll?state=... (CLI polls for the result) - if (request.method === 'GET' && url.pathname === '/api/oidc/poll') { - const state = url.searchParams.get('state'); - if (!validState(state)) return json({ status: 'error', error: 'invalid state' }, 400); - const stub = env.OIDC_RELAY.get(env.OIDC_RELAY.idFromName(state)); - const res = await stub.fetch(new Request('https://do/poll')); - return cors(res); - } - // --- Browser-login pairing sessions (/api/login/*) --- const loginStub = (session: string) => env.LOGIN_SESSION.get(env.LOGIN_SESSION.idFromName(session)); diff --git a/packages/oidc-relay/src/return-to.test.ts b/packages/oidc-relay/src/return-to.test.ts deleted file mode 100644 index cc9eb90..0000000 --- a/packages/oidc-relay/src/return-to.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { validReturnTo } from './return-to.ts'; - -describe('validReturnTo', () => { - it('accepts the production and staging login pages over https', () => { - expect(validReturnTo('https://agentconnect.polygon.technology/login#abc')).toBe(true); - expect(validReturnTo('https://agentconnect.staging.polygon.technology/login#abc')).toBe(true); - }); - it('accepts localhost over http for local dev', () => { - expect(validReturnTo('http://localhost:5173/login#abc')).toBe(true); - expect(validReturnTo('http://127.0.0.1:5173/login#abc')).toBe(true); - }); - it('rejects http on non-local hosts', () => { - expect(validReturnTo('http://agentconnect.polygon.technology/login')).toBe(false); - }); - it('rejects other hosts', () => { - expect(validReturnTo('https://evil.example.com/login')).toBe(false); - expect(validReturnTo('https://agentconnect.polygon.technology.evil.com/login')).toBe(false); - }); - it('rejects non-URLs, non-strings, and oversized values', () => { - expect(validReturnTo('not a url')).toBe(false); - expect(validReturnTo(42)).toBe(false); - expect(validReturnTo(`https://agentconnect.polygon.technology/${'a'.repeat(2050)}`)).toBe( - false - ); - }); -}); diff --git a/packages/oidc-relay/src/return-to.ts b/packages/oidc-relay/src/return-to.ts deleted file mode 100644 index d5895a5..0000000 --- a/packages/oidc-relay/src/return-to.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Host allowlist for the post-capture redirect. Prevents the OIDC callback -// from being turned into an open redirect: only our login pages (plus -// localhost for dev) may be a returnTo target. - -const ALLOWED_HOSTS = new Set([ - 'agentconnect.polygon.technology', - 'agentconnect.staging.polygon.technology', - 'localhost', - '127.0.0.1' -]); - -export function validReturnTo(raw: unknown): raw is string { - if (typeof raw !== 'string' || raw.length === 0 || raw.length > 2048) return false; - let url: URL; - try { - url = new URL(raw); - } catch { - return false; - } - const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; - if (url.protocol !== 'https:' && !(isLocal && url.protocol === 'http:')) return false; - return ALLOWED_HOSTS.has(url.hostname); -} diff --git a/packages/oidc-relay/wrangler.toml b/packages/oidc-relay/wrangler.toml index 6a3fc7c..f2dccc0 100644 --- a/packages/oidc-relay/wrangler.toml +++ b/packages/oidc-relay/wrangler.toml @@ -5,10 +5,6 @@ send_metrics = false main = "src/relay.ts" -[[durable_objects.bindings]] -name = "OIDC_RELAY" -class_name = "OidcHandoff" - [[durable_objects.bindings]] name = "LOGIN_SESSION" class_name = "LoginSession" @@ -21,15 +17,15 @@ new_classes = ["OidcHandoff"] tag = "v2" new_classes = ["LoginSession"] +[[migrations]] +tag = "v3" +deleted_classes = ["OidcHandoff"] + # Staging: a workers.dev URL is fine to start; swap to a custom domain for prod. [env.staging] name = "oidc-relay-staging" workers_dev = true -[[env.staging.durable_objects.bindings]] -name = "OIDC_RELAY" -class_name = "OidcHandoff" - [[env.staging.durable_objects.bindings]] name = "LOGIN_SESSION" class_name = "LoginSession" @@ -42,19 +38,18 @@ new_classes = ["OidcHandoff"] tag = "v2" new_classes = ["LoginSession"] +[[env.staging.migrations]] +tag = "v3" +deleted_classes = ["OidcHandoff"] + [env.production] name = "oidc-relay-production" workers_dev = false # Custom domain provisioned by wrangler on deploy (polygon.technology zone must be -# on this Cloudflare account). Sequence must allowlist this /api/oidc/cb as a -# redirect target on their relay (Google only ever sees Sequence's relay, so no -# Google-client change is needed). +# on this Cloudflare account). The Sequence/OMS allowlist target is now the +# agentconnect `omsRelayReturnUri`, not this worker (see design doc). routes = [{ pattern = "oidc-relay.polygon.technology", custom_domain = true }] -[[env.production.durable_objects.bindings]] -name = "OIDC_RELAY" -class_name = "OidcHandoff" - [[env.production.durable_objects.bindings]] name = "LOGIN_SESSION" class_name = "LoginSession" @@ -66,3 +61,7 @@ new_classes = ["OidcHandoff"] [[env.production.migrations]] tag = "v2" new_classes = ["LoginSession"] + +[[env.production.migrations]] +tag = "v3" +deleted_classes = ["OidcHandoff"] From ad147720ffc1e7e882bec654fbaf756371c8039e Mon Sep 17 00:00:00 2001 From: James Lawton Date: Thu, 16 Jul 2026 09:46:49 +0100 Subject: [PATCH 4/9] fix(oidc-relay): url-parse and host-allowlist the oidc-callback url --- packages/oidc-relay/src/relay.test.ts | 58 +++++++++++++++++++++++++++ packages/oidc-relay/src/relay.ts | 35 +++++++++++----- 2 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 packages/oidc-relay/src/relay.test.ts diff --git a/packages/oidc-relay/src/relay.test.ts b/packages/oidc-relay/src/relay.test.ts new file mode 100644 index 0000000..051555f --- /dev/null +++ b/packages/oidc-relay/src/relay.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { validAction, validCallbackUrl } from './relay.ts'; + +describe('validCallbackUrl', () => { + it('accepts the production and staging agentconnect login pages over https', () => { + expect(validCallbackUrl('https://agentconnect.polygon.technology/login#abc')).toBe(true); + expect(validCallbackUrl('https://agentconnect.staging.polygon.technology/login#abc')).toBe( + true + ); + }); + + it('accepts localhost over http for local dev', () => { + expect(validCallbackUrl('http://localhost:5173/login#abc')).toBe(true); + }); + + it('rejects a lookalike host that merely starts with localhost', () => { + expect(validCallbackUrl('http://localhost.evil.example/x')).toBe(false); + }); + + it('rejects a numeric prefix collision with 127.0.0.1', () => { + expect(validCallbackUrl('http://127.0.0.100/x')).toBe(false); + }); + + it('rejects a non-URL string', () => { + expect(validCallbackUrl('not a url')).toBe(false); + }); + + it('rejects an https URL on a non-allowlisted host', () => { + expect(validCallbackUrl('https://evil.example/login')).toBe(false); + }); + + it('rejects a URL longer than 2048 characters', () => { + expect(validCallbackUrl(`https://agentconnect.polygon.technology/${'a'.repeat(2050)}`)).toBe( + false + ); + }); +}); + +describe('validAction', () => { + it('accepts an oidc-callback action with a good callback URL', () => { + expect( + validAction({ + type: 'oidc-callback', + callbackUrl: 'https://agentconnect.polygon.technology/login#abc' + }) + ).toBe(true); + }); + + it('rejects an oidc-callback action with a bad callback URL', () => { + expect( + validAction({ + type: 'oidc-callback', + callbackUrl: 'https://evil.example/login' + }) + ).toBe(false); + }); +}); diff --git a/packages/oidc-relay/src/relay.ts b/packages/oidc-relay/src/relay.ts index 3dce6fe..1808a24 100644 --- a/packages/oidc-relay/src/relay.ts +++ b/packages/oidc-relay/src/relay.ts @@ -40,22 +40,35 @@ function validSession(s: string | null): s is string { return typeof s === 'string' && /^[A-Za-z0-9_-]{16,64}$/.test(s); } +// Host allowlist for the oidc-callback URL. The callbackUrl is always our own +// agentconnect /login page (the omsRelayReturnUri) that the OMS relay returned +// the browser to, so only those hosts (plus localhost for dev) are accepted. +const ALLOWED_RETURN_HOSTS = new Set([ + 'agentconnect.polygon.technology', + 'agentconnect.staging.polygon.technology', + 'localhost', + '127.0.0.1' +]); + // Bound the callback URL the browser hands back after the OMS relay finishes the // Google OAuth leg. It must be an https URL, or an http localhost/127.0.0.1 URL -// for local dev, so a junk value can't be smuggled through as an action. -function validCallbackUrl(u: unknown): u is string { - return ( - typeof u === 'string' && - u.length > 0 && - u.length <= 2048 && - (u.startsWith('https://') || - u.startsWith('http://localhost') || - u.startsWith('http://127.0.0.1')) - ); +// for local dev, on one of the allowlisted hosts, so a junk or spoofed value +// can't be smuggled through as an action. +export function validCallbackUrl(u: unknown): u is string { + if (typeof u !== 'string' || u.length === 0 || u.length > 2048) return false; + let url: URL; + try { + url = new URL(u); + } catch { + return false; + } + const isLocal = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; + if (url.protocol !== 'https:' && !(isLocal && url.protocol === 'http:')) return false; + return ALLOWED_RETURN_HOSTS.has(url.hostname); } // Shape-check browser-submitted actions before they reach the DO. -function validAction(a: unknown): a is LoginAction { +export function validAction(a: unknown): a is LoginAction { if (typeof a !== 'object' || a === null) return false; const t = (a as { type?: unknown }).type; if (t === 'google' || t === 'cancel') return true; From 2c712f6e9e6863a094b339a4cecac2495dfb2863 Mon Sep 17 00:00:00 2001 From: James Lawton Date: Thu, 16 Jul 2026 10:00:40 +0100 Subject: [PATCH 5/9] feat(cli): migrate embedded wallet to @polygonlabs/oms-wallet and oms relay login --- packages/polygon-agent-cli/package.json | 6 +- .../src/commands/operations-ui.tsx | 6 +- .../src/commands/operations.ts | 8 +- .../polygon-agent-cli/src/commands/wallet.ts | 52 ++++--- .../src/lib/browser-login.test.ts | 33 ++-- .../src/lib/browser-login.ts | 36 +++-- packages/polygon-agent-cli/src/lib/indexer.ts | 2 +- .../src/lib/login-relay-client.ts | 15 +- .../src/lib/oidc-relay-client.ts | 71 --------- .../polygon-agent-cli/src/lib/oms-client.ts | 35 ++--- .../polygon-agent-cli/src/lib/oms-storage.ts | 2 +- packages/polygon-agent-cli/src/lib/oms-tx.ts | 6 +- pnpm-lock.yaml | 146 +++--------------- 13 files changed, 128 insertions(+), 290 deletions(-) delete mode 100644 packages/polygon-agent-cli/src/lib/oidc-relay-client.ts diff --git a/packages/polygon-agent-cli/package.json b/packages/polygon-agent-cli/package.json index f886cc1..65577aa 100644 --- a/packages/polygon-agent-cli/package.json +++ b/packages/polygon-agent-cli/package.json @@ -30,8 +30,8 @@ }, "dependencies": { "@0xsequence/network": "^2.3.40", - "@0xsequence/typescript-sdk": "0.1.0-alpha.4", "@0xtrails/api": "^0.10.4", + "@polygonlabs/oms-wallet": "0.2.0", "@polymarket/clob-client-v2": "^1.0.0", "@polymarket/sdk": "^6.0.1", "@x402/core": "^2.3.1", @@ -44,7 +44,7 @@ "ink-spinner": "^5.0.0", "open": "^10.1.0", "react": "^18.3.1", - "viem": "^2.45.3", + "viem": "^2.48.4", "yargs": "^17.7.2" }, "devDependencies": { @@ -54,6 +54,6 @@ "vitest": "^3.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22" } } diff --git a/packages/polygon-agent-cli/src/commands/operations-ui.tsx b/packages/polygon-agent-cli/src/commands/operations-ui.tsx index e2eb690..b710c78 100644 --- a/packages/polygon-agent-cli/src/commands/operations-ui.tsx +++ b/packages/polygon-agent-cli/src/commands/operations-ui.tsx @@ -2,9 +2,9 @@ import { Box, Text, useApp } from 'ink'; import Spinner from 'ink-spinner'; import React, { useState, useEffect } from 'react'; -import type { TokenBalance } from '@0xsequence/typescript-sdk'; +import type { ContractTokenBalance } from '@polygonlabs/oms-wallet'; -import { findNetworkById } from '@0xsequence/typescript-sdk'; +import { findNetworkById } from '@polygonlabs/oms-wallet'; import { getOmsClient } from '../lib/oms-client.ts'; import { loadOmsWalletPointer } from '../lib/storage.ts'; @@ -68,7 +68,7 @@ export function BalancesUI({ walletName, chainOverride }: BalancesUIProps) { } ]; - for (const b of (res.balances || []) as TokenBalance[]) { + for (const b of (res.balances || []) as ContractTokenBalance[]) { const sym = b.contractInfo?.symbol || 'ERC20'; const dec = b.contractInfo?.decimals ?? 18; const tokenAddr = b.contractAddress ? shortAddr(b.contractAddress) : ''; diff --git a/packages/polygon-agent-cli/src/commands/operations.ts b/packages/polygon-agent-cli/src/commands/operations.ts index 8be1bdf..c9913e2 100644 --- a/packages/polygon-agent-cli/src/commands/operations.ts +++ b/packages/polygon-agent-cli/src/commands/operations.ts @@ -2,9 +2,9 @@ import type { CommandModule, Argv } from 'yargs'; import React from 'react'; -import type { TokenBalance } from '@0xsequence/typescript-sdk'; +import type { ContractTokenBalance } from '@polygonlabs/oms-wallet'; -import { findNetworkById } from '@0xsequence/typescript-sdk'; +import { findNetworkById } from '@polygonlabs/oms-wallet'; import { isWalletFunded } from '../lib/indexer.ts'; import { getOmsClient, loginUiBaseUrl } from '../lib/oms-client.ts'; @@ -153,8 +153,8 @@ async function fetchBalancesRowsForChain( ]; const erc20: BalanceRowJson[] = (res.balances || []) - .filter((b: TokenBalance) => !!b.contractAddress) - .map((b: TokenBalance) => ({ + .filter((b: ContractTokenBalance) => !!b.contractAddress) + .map((b: ContractTokenBalance) => ({ type: 'erc20' as const, symbol: b.contractInfo?.symbol || 'ERC20', name: b.contractInfo?.name || undefined, diff --git a/packages/polygon-agent-cli/src/commands/wallet.ts b/packages/polygon-agent-cli/src/commands/wallet.ts index 4036c2c..edee8ec 100644 --- a/packages/polygon-agent-cli/src/commands/wallet.ts +++ b/packages/polygon-agent-cli/src/commands/wallet.ts @@ -4,18 +4,17 @@ import { randomBytes } from 'node:crypto'; import React from 'react'; +import type { OmsRelayOidcProvider } from '@polygonlabs/oms-wallet'; + +import { OmsRelayOidcProviders } from '@polygonlabs/oms-wallet'; + import type { OmsLoginMethod } from '../lib/storage.ts'; import { runBrowserLogin } from '../lib/browser-login.ts'; import { ensureBuilderAccessKey, makeDefaultProvisionDeps } from '../lib/builder-provision.ts'; import { makeLoginRelay } from '../lib/login-relay-client.ts'; import { startOidcCallbackServer } from '../lib/oidc-callback-server.ts'; -import { - getOmsClient, - loginUiBaseUrl, - oidcRelayBaseUrl, - oidcRelayRedirectUri -} from '../lib/oms-client.ts'; +import { getOmsClient, loginUiBaseUrl, oidcRelayBaseUrl } from '../lib/oms-client.ts'; import { listWallets, deleteWallet, @@ -59,25 +58,24 @@ async function announceAuthUrl(url: string): Promise { } } -// Legacy --local flow: a short-lived loopback server; the relay bounces the -// browser to it. Only works when the browser runs on this machine. +// Legacy --local flow: a short-lived loopback server. The OMS relay now owns +// the Google OAuth callback and bounces the browser back to our nominated +// `omsRelayReturnUri`, which we set to this loopback URL. Only works when the +// browser runs on this machine (and the OMS relay allows a localhost return URI). async function obtainLoopbackCallbackUrl( oms: ReturnType, - provider: 'google', argv: LoginArgs ): Promise { - const seqRelay = oidcRelayRedirectUri(); const server = await startOidcCallbackServer({ port: argv.port, timeoutMs: argv.timeout * 1000 }); try { - const { url } = await oms.wallet.startOidcRedirectAuth({ - provider, - redirectUri: server.redirectUri, - ...(seqRelay ? { relayRedirectUri: seqRelay } : {}) + const { authorizationUrl } = await oms.wallet.startOidcRedirectAuth({ + provider: OmsRelayOidcProviders.google, + omsRelayReturnUri: server.redirectUri }); - await announceAuthUrl(url); + await announceAuthUrl(authorizationUrl); return await server.waitForCallbackUrl; } finally { server.close(); @@ -128,11 +126,14 @@ async function handleLogin(argv: LoginArgs): Promise { `Unsupported provider "${argv.provider}". Only "google" works with --local.` ); } - const callbackUrl = await obtainLoopbackCallbackUrl(oms, 'google', argv); + const callbackUrl = await obtainLoopbackCallbackUrl(oms, argv); const result = await oms.wallet.completeOidcRedirectAuth({ callbackUrl, walletSelection: 'automatic' }); + if (!result) { + throw new Error('OIDC login did not complete: no wallet result returned.'); + } walletAddress = result.walletAddress; loginMethod = 'google'; } else { @@ -143,7 +144,23 @@ async function handleLogin(argv: LoginArgs): Promise { const result = await runBrowserLogin( { relay: makeLoginRelay(relayBase), - wallet: oms.wallet, + wallet: { + startOidcRedirectAuth: (p) => + oms.wallet.startOidcRedirectAuth({ + provider: p.provider as OmsRelayOidcProvider, + omsRelayReturnUri: p.omsRelayReturnUri + }), + completeOidcRedirectAuth: async (p) => { + const r = await oms.wallet.completeOidcRedirectAuth(p); + if (!r) { + throw new Error('OIDC login did not complete: no wallet result returned.'); + } + return { walletAddress: r.walletAddress }; + }, + startEmailAuth: (p) => oms.wallet.startEmailAuth(p), + completeEmailAuth: (p) => oms.wallet.completeEmailAuth(p) + }, + oidcProviderGoogle: OmsRelayOidcProviders.google, announce: announceAuthUrl, sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now: () => Date.now(), @@ -152,7 +169,6 @@ async function handleLogin(argv: LoginArgs): Promise { { relayBase, uiBase: loginUiBaseUrl(), - seqRelay: oidcRelayRedirectUri(), timeoutMs: argv.timeout * 1000 } ); diff --git a/packages/polygon-agent-cli/src/lib/browser-login.test.ts b/packages/polygon-agent-cli/src/lib/browser-login.test.ts index 4c342ad..6149beb 100644 --- a/packages/polygon-agent-cli/src/lib/browser-login.test.ts +++ b/packages/polygon-agent-cli/src/lib/browser-login.test.ts @@ -9,6 +9,9 @@ import type { import { runBrowserLogin } from './browser-login.ts'; +// A sentinel standing in for the SDK's opaque OmsRelayOidcProviders.google value. +const GOOGLE_PROVIDER = Symbol('oms-relay-oidc-provider-google'); + // A scripted fake: nextAction() pops the queue (null = pending tick), and every // published status is recorded for assertions. function makeFakes(actionQueue: Array) { @@ -24,17 +27,13 @@ function makeFakes(actionQueue: Array) { nextAction: async () => (actionQueue.length > 0 ? (actionQueue.shift() ?? null) : null), setStatus: async (_s, status) => { statuses.push(status); - }, - registerOidcHandoff: async (state, returnTo) => { - calls.push(`registerOidcHandoff:${state}:${returnTo}`); - }, - pollOidcCallback: async () => ({ code: 'CODE1', state: 'STATE1' }) + } }, wallet: { - startOidcRedirectAuth: async () => ({ - url: 'https://accounts.google.com/auth', - state: 'STATE1' - }), + startOidcRedirectAuth: async (p) => { + calls.push(`startOidc:${String(p.provider === GOOGLE_PROVIDER)}:${p.omsRelayReturnUri}`); + return { authorizationUrl: 'https://accounts.google.com/auth' }; + }, completeOidcRedirectAuth: async (p) => { calls.push(`completeOidc:${p.callbackUrl}`); return { walletAddress: '0xW' }; @@ -48,6 +47,7 @@ function makeFakes(actionQueue: Array) { return { walletAddress: '0xW' }; } }, + oidcProviderGoogle: GOOGLE_PROVIDER, announce: async (url) => { calls.push(`announce:${url}`); }, @@ -68,13 +68,16 @@ const OPTS: BrowserLoginOpts = { describe('runBrowserLogin', () => { it('completes the google flow', async () => { - const { deps, statuses, calls } = makeFakes([{ type: 'google' }]); + const { deps, statuses, calls } = makeFakes([ + { type: 'google' }, + { type: 'oidc-callback', callbackUrl: 'https://ui.test/login?s=sessionid12345678' } + ]); const result = await runBrowserLogin(deps, OPTS); expect(result).toEqual({ walletAddress: '0xW', loginMethod: 'google' }); expect(calls).toContain('announce:https://ui.test/login#sessionid12345678'); - expect(calls).toContain('registerOidcHandoff:STATE1:https://ui.test/login#sessionid12345678'); - expect(calls).toContain('completeOidc:https://relay.test/api/oidc/cb?code=CODE1&state=STATE1'); + expect(calls).toContain('startOidc:true:https://ui.test/login?s=sessionid12345678'); + expect(calls).toContain('completeOidc:https://ui.test/login?s=sessionid12345678'); expect(statuses).toEqual([ { status: 'auth-url', url: 'https://accounts.google.com/auth' }, { status: 'done', walletAddress: '0xW' } @@ -127,6 +130,12 @@ describe('runBrowserLogin', () => { expect(statuses.at(-1)).toMatchObject({ status: 'error' }); }); + it('cancels while waiting for the oidc callback', async () => { + const { deps, statuses } = makeFakes([{ type: 'google' }, { type: 'cancel' }]); + await expect(runBrowserLogin(deps, OPTS)).rejects.toThrow(/cancelled/i); + expect(statuses.at(-1)).toMatchObject({ status: 'error' }); + }); + it('times out when no action ever arrives', async () => { const { deps } = makeFakes([]); await expect(runBrowserLogin(deps, { ...OPTS, timeoutMs: 5000 })).rejects.toThrow(/timed out/i); diff --git a/packages/polygon-agent-cli/src/lib/browser-login.ts b/packages/polygon-agent-cli/src/lib/browser-login.ts index e8920a6..930f818 100644 --- a/packages/polygon-agent-cli/src/lib/browser-login.ts +++ b/packages/polygon-agent-cli/src/lib/browser-login.ts @@ -5,6 +5,7 @@ export type LoginAction = | { type: 'google' } + | { type: 'oidc-callback'; callbackUrl: string } | { type: 'email'; email: string } | { type: 'otp'; code: string } | { type: 'cancel' }; @@ -22,15 +23,12 @@ export interface BrowserLoginDeps { registerSession(session: string): Promise; nextAction(session: string): Promise; setStatus(session: string, status: LoginStatus): Promise; - registerOidcHandoff(state: string, returnTo: string): Promise; - pollOidcCallback(state: string, timeoutMs: number): Promise<{ code: string; state: string }>; }; wallet: { startOidcRedirectAuth(p: { - provider: 'google'; - redirectUri: string; - relayRedirectUri?: string; - }): Promise<{ url: string; state: string }>; + provider: unknown; + omsRelayReturnUri: string; + }): Promise<{ authorizationUrl: string }>; completeOidcRedirectAuth(p: { callbackUrl: string; walletSelection: 'automatic'; @@ -41,6 +39,9 @@ export interface BrowserLoginDeps { walletSelection: 'automatic'; }): Promise<{ walletAddress: string }>; }; + // The SDK's opaque Google provider value (OmsRelayOidcProviders.google), injected + // so this file stays SDK-agnostic and unit tests can fake it with a sentinel. + oidcProviderGoogle: unknown; announce(url: string): Promise; sleep(ms: number): Promise; now(): number; @@ -50,7 +51,6 @@ export interface BrowserLoginDeps { export interface BrowserLoginOpts { relayBase: string; uiBase: string; - seqRelay?: string; timeoutMs: number; pollIntervalMs?: number; } @@ -88,17 +88,21 @@ export async function runBrowserLogin( } if (action.type === 'google') { - const { url, state } = await wallet.startOidcRedirectAuth({ - provider: 'google', - redirectUri: `${opts.relayBase}/api/oidc/cb`, - ...(opts.seqRelay ? { relayRedirectUri: opts.seqRelay } : {}) + // The relay's return page carries this pairing session (`s`) so it + // survives the OAuth round-trip; the OMS relay owns the Google + // callback and bounces the browser back to that page, which posts + // its full URL back to us as an `oidc-callback` action below. + const { authorizationUrl } = await wallet.startOidcRedirectAuth({ + provider: deps.oidcProviderGoogle, + omsRelayReturnUri: `${opts.uiBase}/login?s=${session}` }); - await relay.registerOidcHandoff(state, pageUrl); - await relay.setStatus(session, { status: 'auth-url', url }); - const cb = await relay.pollOidcCallback(state, Math.max(deadline - deps.now(), 1)); - const callbackUrl = `${opts.relayBase}/api/oidc/cb?code=${encodeURIComponent(cb.code)}&state=${encodeURIComponent(cb.state)}`; + await relay.setStatus(session, { status: 'auth-url', url: authorizationUrl }); + continue; + } + + if (action.type === 'oidc-callback') { const result = await wallet.completeOidcRedirectAuth({ - callbackUrl, + callbackUrl: action.callbackUrl, walletSelection: 'automatic' }); await relay.setStatus(session, { status: 'done', walletAddress: result.walletAddress }); diff --git a/packages/polygon-agent-cli/src/lib/indexer.ts b/packages/polygon-agent-cli/src/lib/indexer.ts index 88598c0..eec1e08 100644 --- a/packages/polygon-agent-cli/src/lib/indexer.ts +++ b/packages/polygon-agent-cli/src/lib/indexer.ts @@ -7,7 +7,7 @@ // empty. Any error returns false so callers fall through to the funding page // rather than erroring. -import { findNetworkById } from '@0xsequence/typescript-sdk'; +import { findNetworkById } from '@polygonlabs/oms-wallet'; import { getOmsClient } from './oms-client.ts'; diff --git a/packages/polygon-agent-cli/src/lib/login-relay-client.ts b/packages/polygon-agent-cli/src/lib/login-relay-client.ts index 7b23379..b5ee3d2 100644 --- a/packages/polygon-agent-cli/src/lib/login-relay-client.ts +++ b/packages/polygon-agent-cli/src/lib/login-relay-client.ts @@ -1,11 +1,10 @@ // HTTP client for the relay's /api/login pairing routes (packages/oidc-relay). -// Produces the `relay` dependency for runBrowserLogin; the OIDC handoff pieces -// reuse the existing oidc-relay-client functions. +// Produces the `relay` dependency for runBrowserLogin. The Google OAuth +// callback itself is now handled by the OMS relay, not this client; the +// pairing session only ever carries session/action/status. import type { BrowserLoginDeps, LoginAction, LoginStatus } from './browser-login.ts'; -import { pollRelayForCallback, registerRelaySession } from './oidc-relay-client.ts'; - export function makeLoginRelay(relayBase: string): BrowserLoginDeps['relay'] { return { async registerSession(session: string): Promise { @@ -47,14 +46,6 @@ export function makeLoginRelay(relayBase: string): BrowserLoginDeps['relay'] { } catch { // network error; nothing to do, the CLI result stands on its own. } - }, - - registerOidcHandoff(state: string, returnTo: string): Promise { - return registerRelaySession(relayBase, state, returnTo); - }, - - pollOidcCallback(state: string, timeoutMs: number): Promise<{ code: string; state: string }> { - return pollRelayForCallback(relayBase, state, { timeoutMs }); } }; } diff --git a/packages/polygon-agent-cli/src/lib/oidc-relay-client.ts b/packages/polygon-agent-cli/src/lib/oidc-relay-client.ts deleted file mode 100644 index 42eee57..0000000 --- a/packages/polygon-agent-cli/src/lib/oidc-relay-client.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Client for the public OIDC handoff relay (packages/oidc-relay), used by the -// `wallet login-browser --remote` path. When the CLI runs on a machine whose -// localhost the browser can't reach (a remote server / openclaw), we can't use a -// loopback callback. Instead we register a handoff keyed by the OIDC `state`, the -// browser is redirected to the relay's public callback, and the CLI polls for the -// captured `code`+`state`. -// -// The relay only ever sees `code`+`state`; the PKCE verifier and wallet credential -// never leave this process, so the relay alone cannot complete a login. - -interface RelayCallback { - code: string; - state: string; -} - -const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Arm a handoff for this OIDC `state` so the relay tracks it (and /poll returns - * `pending` until the browser callback lands). Call right after startOidcRedirectAuth. - */ -export async function registerRelaySession( - relayBase: string, - state: string, - returnTo?: string -): Promise { - const res = await fetch(`${relayBase}/api/oidc/register`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(returnTo ? { state, returnTo } : { state }) - }); - if (!res.ok && res.status !== 204) { - throw new Error( - `Relay register failed (${res.status}). Check POLYGON_AGENT_OIDC_RELAY / --relay-url.` - ); - } -} - -/** - * Poll the relay until the browser callback arrives, then return the `code`+`state`. - * Throws on provider error, an expired/unknown session, or timeout. - */ -export async function pollRelayForCallback( - relayBase: string, - state: string, - opts: { timeoutMs: number; intervalMs?: number } -): Promise { - // Poll briskly: this runs after the browser has returned from the provider, - // so the captured code is usually already waiting and a tight interval keeps - // the "finishing sign in" screen short. - const intervalMs = opts.intervalMs ?? 800; - const deadline = Date.now() + opts.timeoutMs; - while (Date.now() < deadline) { - const res = await fetch(`${relayBase}/api/oidc/poll?state=${encodeURIComponent(state)}`); - if (!res.ok) throw new Error(`Relay poll failed (${res.status})`); - const data = (await res.json()) as { - status: string; - code?: string; - state?: string; - error?: string; - }; - if (data.status === 'ready') { - if (!data.code || !data.state) throw new Error('Relay returned "ready" without code/state'); - return { code: data.code, state: data.state }; - } - if (data.status === 'error') throw new Error(data.error || 'Login failed at the provider'); - if (data.status === 'expired') throw new Error('Relay session expired before login completed'); - await sleep(intervalMs); - } - throw new Error('Timed out waiting for browser login. Re-run, or use `wallet login`.'); -} diff --git a/packages/polygon-agent-cli/src/lib/oms-client.ts b/packages/polygon-agent-cli/src/lib/oms-client.ts index 36bd2d7..9ee582a 100644 --- a/packages/polygon-agent-cli/src/lib/oms-client.ts +++ b/packages/polygon-agent-cli/src/lib/oms-client.ts @@ -1,21 +1,21 @@ -// OMSClient factory — builds a per-process, per-wallet OMS V3 client backed +// OMSWallet factory — builds a per-process, per-wallet OMS V3 client backed // by file storage + a persisted credential signer, so sessions survive restarts. -import { EthereumPrivateKeyCredentialSigner, OMSClient } from '@0xsequence/typescript-sdk'; +import { EthereumPrivateKeyCredentialSigner, OMSWallet } from '@polygonlabs/oms-wallet'; import { FileStorageManager, loadOrCreateCredentialKey } from './oms-storage.ts'; import { loadOmsConfig } from './storage.ts'; -const cache = new Map(); +const cache = new Map(); /** - * Get (or build) the OMSClient for a wallet name. Cached per-process so repeated + * Get (or build) the OMSWallet for a wallet name. Cached per-process so repeated * calls within one CLI invocation reuse the same client + storage handle. * * Reads publishableKey + projectId from env or builder.json (loadOmsConfig), * falling back to the baked-in default publishable key. */ -export function getOmsClient(walletName: string): OMSClient { +export function getOmsClient(walletName: string): OMSWallet { const cached = cache.get(walletName); if (cached) return cached; @@ -25,12 +25,12 @@ export function getOmsClient(walletName: string): OMSClient { loadOrCreateCredentialKey(walletName) ); - // SDK 0.1.0-alpha.4: the publishableKey alone identifies the project. - // `redirectAuthStorage` is REQUIRED for the OIDC browser flow: in Node the SDK - // has no sessionStorage to fall back to and throws without it. We back it with - // a separate file store so the transient pending-auth state is isolated from - // the session store (and the email-login/tx paths simply ignore it). - const oms = new OMSClient({ + // The publishableKey alone identifies the project. `redirectAuthStorage` is + // REQUIRED for the OIDC browser flow: in Node the SDK has no sessionStorage + // to fall back to and throws without it. We back it with a separate file + // store so the transient pending-auth state is isolated from the session + // store (and the email-login/tx paths simply ignore it). + const oms = new OMSWallet({ publishableKey: cfg.publishableKey, storage: new FileStorageManager(walletName), redirectAuthStorage: new FileStorageManager(walletName, 'redirect-store'), @@ -41,17 +41,6 @@ export function getOmsClient(walletName: string): OMSClient { return oms; } -/** - * The OIDC relay redirect URI. Google only ever sees this (pre-registered) HTTPS - * callback; the relay bounces the auth code back to our localhost. Overridable - * via SEQUENCE_OIDC_RELAY_URI so production can point at a non-staging relay - * without a code change. When unset, the SDK's built-in default is used (pass - * undefined and the provider default applies). - */ -export function oidcRelayRedirectUri(): string | undefined { - return process.env.SEQUENCE_OIDC_RELAY_URI || undefined; -} - // Baked-in defaults for the browser-login flow so `wallet login` needs no env // vars. Production domains, provisioned by the oidc-relay and agentconnect-ui // deploys on merge to main. Override per environment (or to test against @@ -60,7 +49,7 @@ const DEFAULT_OIDC_RELAY = 'https://oidc-relay.polygon.technology'; const DEFAULT_LOGIN_UI = 'https://agentconnect.polygon.technology'; /** - * Base URL of OUR OIDC handoff + login relay (packages/oidc-relay). Read from + * Base URL of our login pairing relay (packages/oidc-relay). Read from * POLYGON_AGENT_OIDC_RELAY with a production default; `--relay-url` overrides * per-run. Trailing slash trimmed so callers can append `/api/...` cleanly. */ diff --git a/packages/polygon-agent-cli/src/lib/oms-storage.ts b/packages/polygon-agent-cli/src/lib/oms-storage.ts index 72ad13f..df2f255 100644 --- a/packages/polygon-agent-cli/src/lib/oms-storage.ts +++ b/packages/polygon-agent-cli/src/lib/oms-storage.ts @@ -11,7 +11,7 @@ import { randomBytes } from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import type { StorageManager } from '@0xsequence/typescript-sdk'; +import type { StorageManager } from '@polygonlabs/oms-wallet'; import type { CipherData } from './storage.ts'; diff --git a/packages/polygon-agent-cli/src/lib/oms-tx.ts b/packages/polygon-agent-cli/src/lib/oms-tx.ts index 35f5cf5..7c50657 100644 --- a/packages/polygon-agent-cli/src/lib/oms-tx.ts +++ b/packages/polygon-agent-cli/src/lib/oms-tx.ts @@ -5,9 +5,9 @@ // existing command call sites need only swap which implementation they call (via // the runTx dispatch). Internally maps onto oms.wallet.sendTransaction. -import type { FeeOptionWithBalance } from '@0xsequence/typescript-sdk'; +import type { FeeOptionWithBalance } from '@polygonlabs/oms-wallet'; -import { findNetworkById, isOmsSdkError, TransactionMode } from '@0xsequence/typescript-sdk'; +import { findNetworkById, isOMSWalletError, TransactionMode } from '@polygonlabs/oms-wallet'; import { getOmsClient } from './oms-client.ts'; @@ -111,7 +111,7 @@ export async function runOmsTx(params: OmsTxParams): Promise { lastTxHash = res.txnHash ?? lastTxHash; } catch (e) { if ( - isOmsSdkError(e) && + isOMSWalletError(e) && (e.code === 'OMS_SESSION_EXPIRED' || e.code === 'OMS_SESSION_MISSING') ) { throw new Error( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc3d7c9..a8e9493 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,12 +118,12 @@ importers: '@0xsequence/network': specifier: ^2.3.40 version: 2.3.41(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - '@0xsequence/typescript-sdk': - specifier: 0.1.0-alpha.4 - version: 0.1.0-alpha.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) '@0xtrails/api': specifier: ^0.10.4 version: 0.10.4 + '@polygonlabs/oms-wallet': + specifier: 0.2.0 + version: 0.2.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) '@polymarket/clob-client-v2': specifier: ^1.0.0 version: 1.0.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) @@ -161,8 +161,8 @@ importers: specifier: ^18.3.1 version: 18.3.1 viem: - specifier: ^2.45.3 - version: 2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) + specifier: ^2.48.4 + version: 2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) yargs: specifier: ^17.7.2 version: 17.7.2 @@ -331,9 +331,6 @@ packages: peerDependencies: ethers: '>=6' - '@0xsequence/typescript-sdk@0.1.0-alpha.4': - resolution: {integrity: sha512-l1xi8OXgcwQ05HeNqt11TFLddGI6NIZ5ObKe930kgAnM4UcLTZ1DNf+bvKQoRQ16hEZUJDS5nE0BkI85vdYGDQ==} - '@0xsequence/utils@2.3.41': resolution: {integrity: sha512-wikEOiYxfq+Iqsfb9JF6xHnpyXHaUre+2FUQpk3teo7/FXy0xtHdzxtfbhGn5/EcMnlSErk1nt6isrcxlkkHuQ==} peerDependencies: @@ -1548,6 +1545,10 @@ packages: typescript: optional: true + '@polygonlabs/oms-wallet@0.2.0': + resolution: {integrity: sha512-kLsBfrwXK8m7Zd2CzQ6+p/rOQE5ckeEN//Lc1F7Jq9E80ve/EXoVjWujK+DP4weMfhaHwWiK8+VllviuUvZgVA==} + engines: {node: '>=22'} + '@polymarket/clob-client-v2@1.0.2': resolution: {integrity: sha512-lC80Esug6s6y3uV8D5HnkxoXVZUnATjyP6PcK2IXO740iGDuLlp9Dvvkx3+VVygHahN+M3NY7JiYiTQkDfWoeQ==} @@ -5050,14 +5051,6 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} - ox@0.12.4: - resolution: {integrity: sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q==} - peerDependencies: - typescript: '>=5.4.0' - peerDependenciesMeta: - typescript: - optional: true - ox@0.13.1: resolution: {integrity: sha512-S6XU/Chz7HSSlAp7KYZigW8iwSyfS9M+GKTbyDQuZDsqC8S8GUFB2bLBLLB2SfHw8eE2MfymQazVXoLNljLycg==} peerDependencies: @@ -5943,14 +5936,6 @@ packages: typescript: optional: true - viem@2.46.3: - resolution: {integrity: sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg==} - peerDependencies: - typescript: '>=5.0.4' - peerDependenciesMeta: - typescript: - optional: true - viem@2.53.1: resolution: {integrity: sha512-FhfJ/SW73CVosiyVLmIMVgKDRKYV1AGCLzZoHYvmNayyVff63Qi1ocPCk59LqC/cNw244RbBJjHnmxqXkE7NpA==} peerDependencies: @@ -6119,18 +6104,6 @@ packages: utf-8-validate: optional: true - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -6561,15 +6534,6 @@ snapshots: '@0xsequence/core': 2.3.44(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) ethers: 6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - '@0xsequence/typescript-sdk@0.1.0-alpha.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6)': - dependencies: - viem: 2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) - transitivePeerDependencies: - - bufferutil - - typescript - - utf-8-validate - - zod - '@0xsequence/utils@2.3.41(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: ethers: 6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -8085,6 +8049,15 @@ snapshots: - eslint-plugin-import - supports-color + '@polygonlabs/oms-wallet@0.2.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6)': + dependencies: + viem: 2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + '@polymarket/clob-client-v2@1.0.2(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6)': dependencies: '@ethersproject/providers': 5.8.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -8092,7 +8065,7 @@ snapshots: axios: 1.13.6 browser-or-node: 3.0.0 tslib: 2.8.1 - viem: 2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) + viem: 2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6) transitivePeerDependencies: - bufferutil - debug @@ -10678,7 +10651,7 @@ snapshots: dependencies: '@x402/core': 2.5.0 '@x402/extensions': 2.5.0(bufferutil@4.1.0)(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@5.9.3)(utf-8-validate@6.0.6) - viem: 2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) + viem: 2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - bufferutil @@ -10693,7 +10666,7 @@ snapshots: ajv: 8.18.0 siwe: 2.3.2(ethers@6.16.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) tweetnacl: 1.0.3 - viem: 2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) + viem: 2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - bufferutil @@ -10704,7 +10677,7 @@ snapshots: '@x402/fetch@2.5.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)': dependencies: '@x402/core': 2.5.0 - viem: 2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) + viem: 2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76) zod: 3.25.76 transitivePeerDependencies: - bufferutil @@ -12003,10 +11976,6 @@ snapshots: dependencies: ws: 8.18.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) - isows@1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)): - dependencies: - ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) - isows@1.0.7(ws@8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6)): dependencies: ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -12383,36 +12352,6 @@ snapshots: outdent@0.5.0: {} - ox@0.12.4(typescript@5.9.3)(zod@3.25.76): - dependencies: - '@adraffy/ens-normalize': 1.11.1 - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.7 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) - eventemitter3: 5.0.1 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - zod - - ox@0.12.4(typescript@5.9.3)(zod@4.3.6): - dependencies: - '@adraffy/ens-normalize': 1.11.1 - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.7 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) - eventemitter3: 5.0.1 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - zod - ox@0.13.1(typescript@5.9.3)(zod@3.25.76): dependencies: '@adraffy/ens-normalize': 1.11.1 @@ -12848,7 +12787,7 @@ snapshots: buffer: 6.0.3 eventemitter3: 5.0.1 uuid: 14.0.1 - ws: 8.20.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) optionalDependencies: bufferutil: 4.1.0 utf-8-validate: 6.0.6 @@ -13298,40 +13237,6 @@ snapshots: - utf-8-validate - zod - viem@2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.25.76): - dependencies: - '@noble/curves': 1.9.7 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@3.25.76) - isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - ox: 0.12.4(typescript@5.9.3)(zod@3.25.76) - ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - zod - - viem@2.46.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6): - dependencies: - '@noble/curves': 1.9.7 - '@noble/hashes': 1.8.0 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@4.3.6) - isows: 1.0.7(ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - ox: 0.12.4(typescript@5.9.3)(zod@4.3.6) - ws: 8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - zod - viem@2.53.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@3.22.4): dependencies: '@noble/curves': 1.9.7 @@ -13567,11 +13472,6 @@ snapshots: bufferutil: 4.1.0 utf-8-validate: 6.0.6 - ws@8.18.3(bufferutil@4.1.0)(utf-8-validate@6.0.6): - optionalDependencies: - bufferutil: 4.1.0 - utf-8-validate: 6.0.6 - ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): optionalDependencies: bufferutil: 4.1.0 From 3ac4cf9dd2f89c226ae9e6604cad9cc46a7505df Mon Sep 17 00:00:00 2001 From: James Lawton Date: Thu, 16 Jul 2026 10:11:43 +0100 Subject: [PATCH 6/9] feat(agentconnect-ui): post oms relay callback over the login pairing channel --- .../agentconnect-ui/src/login/LoginPage.tsx | 53 ++++++++++++++++--- .../agentconnect-ui/src/login/machine.test.ts | 23 ++++++++ packages/agentconnect-ui/src/login/machine.ts | 16 ++++-- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/packages/agentconnect-ui/src/login/LoginPage.tsx b/packages/agentconnect-ui/src/login/LoginPage.tsx index 7cc4a23..3026a50 100644 --- a/packages/agentconnect-ui/src/login/LoginPage.tsx +++ b/packages/agentconnect-ui/src/login/LoginPage.tsx @@ -59,13 +59,48 @@ async function fetchStatus(session: string): Promise { const TERMINAL: MachineState['kind'][] = ['success', 'expired', 'failed']; +// 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. +function getSessionId(): string { + const fromQuery = new URLSearchParams(window.location.search).get('s'); + if (fromQuery) return fromQuery; + return window.location.hash.slice(1); +} + +// True when this load is the browser bouncing back from the OMS relay after +// Google sign-in, not a fresh open. The CLI's announce URL is a bare +// `/login#` fragment with no query string at all, so any query key +// besides `s` showing up alongside it means the OMS relay appended its own +// callback params on the way back. +function isRelayReturn(): boolean { + const params = new URLSearchParams(window.location.search); + if (!params.get('s')) return false; + for (const key of params.keys()) { + if (key !== 's') return true; + } + return false; +} + export function LoginPage() { - const session = window.location.hash.slice(1); - const [state, setState] = useState(initialState); + const session = getSessionId(); + const [state, setState] = useState(() => + isRelayReturn() ? 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. + useEffect(() => { + if (postedRelayCallback.current || !session || !isRelayReturn()) return; + postedRelayCallback.current = true; + void postAction(session, { type: 'oidc-callback', callbackUrl: window.location.href }); + }, [session]); // Once the session is established the user should land on their dashboard // without another click; the success card shows briefly, then we move on. @@ -148,12 +183,14 @@ function renderState(state: MachineState, session: string, dispatch: (e: Machine
); case 'email-entry': diff --git a/packages/agentconnect-ui/src/login/machine.test.ts b/packages/agentconnect-ui/src/login/machine.test.ts index 81e039a..2daa9d9 100644 --- a/packages/agentconnect-ui/src/login/machine.test.ts +++ b/packages/agentconnect-ui/src/login/machine.test.ts @@ -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); diff --git a/packages/agentconnect-ui/src/login/machine.ts b/packages/agentconnect-ui/src/login/machine.ts index c430cbd..9197cdb 100644 --- a/packages/agentconnect-ui/src/login/machine.ts +++ b/packages/agentconnect-ui/src/login/machine.ts @@ -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 } @@ -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' }; @@ -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; } } From 389d4a6a68f7b52c4f9efad3efa0bca980fc9c81 Mon Sep 17 00:00:00 2001 From: James Lawton Date: Thu, 16 Jul 2026 10:19:15 +0100 Subject: [PATCH 7/9] test(agentconnect-ui): extract and unit-test login return-url detection --- .../agentconnect-ui/src/login/LoginPage.tsx | 32 +++---------- .../src/login/returnUrl.test.ts | 47 +++++++++++++++++++ .../agentconnect-ui/src/login/returnUrl.ts | 37 +++++++++++++++ 3 files changed, 90 insertions(+), 26 deletions(-) create mode 100644 packages/agentconnect-ui/src/login/returnUrl.test.ts create mode 100644 packages/agentconnect-ui/src/login/returnUrl.ts diff --git a/packages/agentconnect-ui/src/login/LoginPage.tsx b/packages/agentconnect-ui/src/login/LoginPage.tsx index 3026a50..567c859 100644 --- a/packages/agentconnect-ui/src/login/LoginPage.tsx +++ b/packages/agentconnect-ui/src/login/LoginPage.tsx @@ -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 @@ -59,33 +60,12 @@ async function fetchStatus(session: string): Promise { const TERMINAL: MachineState['kind'][] = ['success', 'expired', 'failed']; -// 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. -function getSessionId(): string { - const fromQuery = new URLSearchParams(window.location.search).get('s'); - if (fromQuery) return fromQuery; - return window.location.hash.slice(1); -} - -// True when this load is the browser bouncing back from the OMS relay after -// Google sign-in, not a fresh open. The CLI's announce URL is a bare -// `/login#` fragment with no query string at all, so any query key -// besides `s` showing up alongside it means the OMS relay appended its own -// callback params on the way back. -function isRelayReturn(): boolean { - const params = new URLSearchParams(window.location.search); - if (!params.get('s')) return false; - for (const key of params.keys()) { - if (key !== 's') return true; - } - return false; -} - export function LoginPage() { - const session = getSessionId(); + const session = getSessionId(window.location.search, window.location.hash); const [state, setState] = useState(() => - isRelayReturn() ? reduce(initialState, { type: 'relay-return' }) : initialState + isRelayReturn(window.location.search) + ? reduce(initialState, { type: 'relay-return' }) + : initialState ); const dispatch = useCallback((event: MachineEvent) => { setState((s) => reduce(s, event)); @@ -97,7 +77,7 @@ export function LoginPage() { // 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. useEffect(() => { - if (postedRelayCallback.current || !session || !isRelayReturn()) return; + if (postedRelayCallback.current || !session || !isRelayReturn(window.location.search)) return; postedRelayCallback.current = true; void postAction(session, { type: 'oidc-callback', callbackUrl: window.location.href }); }, [session]); diff --git a/packages/agentconnect-ui/src/login/returnUrl.test.ts b/packages/agentconnect-ui/src/login/returnUrl.test.ts new file mode 100644 index 0000000..67be3fc --- /dev/null +++ b/packages/agentconnect-ui/src/login/returnUrl.test.ts @@ -0,0 +1,47 @@ +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 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); + }); +}); diff --git a/packages/agentconnect-ui/src/login/returnUrl.ts b/packages/agentconnect-ui/src/login/returnUrl.ts new file mode 100644 index 0000000..ca175de --- /dev/null +++ b/packages/agentconnect-ui/src/login/returnUrl.ts @@ -0,0 +1,37 @@ +// 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#` (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=&` (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); +} + +// True when this load is the browser bouncing back from the OMS relay after +// Google sign-in, not a fresh open. The CLI's announce URL is a bare +// `/login#` fragment with no query string at all, so any query key +// besides `s` showing up alongside it means the OMS relay appended its own +// callback params on the way back. +export function isRelayReturn(search: string): boolean { + const params = new URLSearchParams(search); + if (!params.get('s')) return false; + for (const key of params.keys()) { + if (key !== 's') return true; + } + return false; +} From dd75624ffa4880bac75fad6f566d08aa6dc6e5f5 Mon Sep 17 00:00:00 2001 From: James Lawton Date: Thu, 16 Jul 2026 10:22:52 +0100 Subject: [PATCH 8/9] docs: migrate wallet docs to oms-wallet and node 22 --- .changeset/oms-wallet-sdk.md | 11 +++++++++++ CLAUDE.md | 8 ++++---- packages/polygon-agent-cli/README.md | 2 +- skills/SKILL.md | 4 ++-- skills/polygon-agent-cli/SKILL.md | 4 ++-- 5 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 .changeset/oms-wallet-sdk.md diff --git a/.changeset/oms-wallet-sdk.md b/.changeset/oms-wallet-sdk.md new file mode 100644 index 0000000..74c306b --- /dev/null +++ b/.changeset/oms-wallet-sdk.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 4037745..942f9ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,8 @@ 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 @@ -21,9 +21,9 @@ 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 diff --git a/packages/polygon-agent-cli/README.md b/packages/polygon-agent-cli/README.md index 6bcdac2..81cd4af 100644 --- a/packages/polygon-agent-cli/README.md +++ b/packages/polygon-agent-cli/README.md @@ -274,7 +274,7 @@ polygon-agent-cli/ └── package.json ``` -**Requirements:** Node.js 20+ +**Requirements:** Node.js 22+ --- diff --git a/skills/SKILL.md b/skills/SKILL.md index 2162e78..9a5bf6e 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -6,7 +6,7 @@ description: "Complete Polygon agent toolkit for on-chain operations on Polygon. # Polygon Agentic CLI ## Prerequisites -- Node.js 20+ +- Node.js 22+ - Install globally: `npm install -g @polygonlabs/agent-cli` (reinstall to update) - Entry point: `agent ` - Storage: `~/.polygon-agent/` (AES-256-GCM encrypted) @@ -25,7 +25,7 @@ npm install -g @polygonlabs/agent-cli@latest # upgrade ## Architecture -The CLI uses the **OMS (Open Money Stack) V3 embedded-wallet** model (`@0xsequence/typescript-sdk`). By default, `wallet login` opens the agentconnect login page (`POLYGON_AGENT_LOGIN_UI`, default `https://agentconnect.polygon.technology`), where the user chooses Google or email (email sends a 6-digit code to the user's inbox, entered on the page). This works whether the browser is on this machine or a different one, so there is no separate headless mode. `--local` falls back to the legacy loopback flow (raw Google sign-in URL plus a localhost callback; browser must be on this same machine; Google only). `--remote` is deprecated and is now a no-op that prints a notice. Once the user signs in, the wallet is created or unlocked and the session credential is stored encrypted on disk. There is no on-chain permission scoping. +The CLI uses the **OMS (Open Money Stack) V3 embedded-wallet** model (`@polygonlabs/oms-wallet`). By default, `wallet login` opens the agentconnect login page (`POLYGON_AGENT_LOGIN_UI`, default `https://agentconnect.polygon.technology`), where the user chooses Google or email (email sends a 6-digit code to the user's inbox, entered on the page). This works whether the browser is on this machine or a different one, so there is no separate headless mode. `--local` falls back to the legacy loopback flow (raw Google sign-in URL plus a localhost callback; browser must be on this same machine; Google only). `--remote` is deprecated and is now a no-op that prints a notice. Once the user signs in, the wallet is created or unlocked and the session credential is stored encrypted on disk. There is no on-chain permission scoping. | Wallet | Created by | Purpose | Fund? | |--------|-----------|---------|-------| diff --git a/skills/polygon-agent-cli/SKILL.md b/skills/polygon-agent-cli/SKILL.md index a6a6fdb..9a50fd1 100644 --- a/skills/polygon-agent-cli/SKILL.md +++ b/skills/polygon-agent-cli/SKILL.md @@ -6,7 +6,7 @@ description: "Complete Polygon agent toolkit for on-chain operations on Polygon. # Polygon Agentic CLI ## Prerequisites -- Node.js 20+ +- Node.js 22+ - Install globally: `npm install -g @polygonlabs/agent-cli` (reinstall to update) - Entry point: `agent ` - Storage: `~/.polygon-agent/` (AES-256-GCM encrypted) @@ -25,7 +25,7 @@ npm install -g @polygonlabs/agent-cli@latest # upgrade ## Architecture -The CLI uses the **OMS (Open Money Stack) V3 embedded-wallet** model (`@0xsequence/typescript-sdk`). By default, `wallet login` opens the agentconnect login page (`POLYGON_AGENT_LOGIN_UI`, default `https://agentconnect.polygon.technology`), where the user chooses Google or email (email sends a 6-digit code to the user's inbox, entered on the page). This works whether the browser is on this machine or a different one, so there is no separate headless mode. `--local` falls back to the legacy loopback flow (raw Google sign-in URL plus a localhost callback; browser must be on this same machine; Google only). `--remote` is deprecated and is now a no-op that prints a notice. Once the user signs in, the wallet is created or unlocked and the session credential is stored encrypted on disk. There is no on-chain permission scoping. +The CLI uses the **OMS (Open Money Stack) V3 embedded-wallet** model (`@polygonlabs/oms-wallet`). By default, `wallet login` opens the agentconnect login page (`POLYGON_AGENT_LOGIN_UI`, default `https://agentconnect.polygon.technology`), where the user chooses Google or email (email sends a 6-digit code to the user's inbox, entered on the page). This works whether the browser is on this machine or a different one, so there is no separate headless mode. `--local` falls back to the legacy loopback flow (raw Google sign-in URL plus a localhost callback; browser must be on this same machine; Google only). `--remote` is deprecated and is now a no-op that prints a notice. Once the user signs in, the wallet is created or unlocked and the session credential is stored encrypted on disk. There is no on-chain permission scoping. | Wallet | Created by | Purpose | Fund? | |--------|-----------|---------|-------| From 315498df54cdf7049e1f5f67aa2980932df2a0cb Mon Sep 17 00:00:00 2001 From: James Lawton Date: Thu, 16 Jul 2026 10:35:40 +0100 Subject: [PATCH 9/9] fix(agentconnect-ui): key relay-return on oauth params and fail fast on post error isRelayReturn now requires an oauth callback param (code/state/error) rather than any non-s query key, so a wrapped/tracked link (utm_*, gclid) pasted into a browser no longer misfires a bogus relay callback post. The oidc-callback post in LoginPage now awaits the result and dispatches a terminal failed state on a false response, instead of leaving the user on the finishing-sign-in spinner until the poll timeout. Also hoists the duplicated isRelayReturn(window.location.search) call into one const. --- .../agentconnect-ui/src/login/LoginPage.tsx | 26 ++++++++++++++----- .../src/login/returnUrl.test.ts | 12 +++++++++ .../agentconnect-ui/src/login/returnUrl.ts | 21 +++++++++------ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/agentconnect-ui/src/login/LoginPage.tsx b/packages/agentconnect-ui/src/login/LoginPage.tsx index 567c859..2c6890c 100644 --- a/packages/agentconnect-ui/src/login/LoginPage.tsx +++ b/packages/agentconnect-ui/src/login/LoginPage.tsx @@ -62,10 +62,9 @@ const TERMINAL: MachineState['kind'][] = ['success', 'expired', 'failed']; export function LoginPage() { const session = getSessionId(window.location.search, window.location.hash); + const relayReturn = isRelayReturn(window.location.search); const [state, setState] = useState(() => - isRelayReturn(window.location.search) - ? reduce(initialState, { type: 'relay-return' }) - : initialState + relayReturn ? reduce(initialState, { type: 'relay-return' }) : initialState ); const dispatch = useCallback((event: MachineEvent) => { setState((s) => reduce(s, event)); @@ -76,11 +75,26 @@ export function LoginPage() { // 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 || !isRelayReturn(window.location.search)) return; + if (postedRelayCallback.current || !session || !relayReturn) return; postedRelayCallback.current = true; - void postAction(session, { type: 'oidc-callback', callbackUrl: window.location.href }); - }, [session]); + 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. diff --git a/packages/agentconnect-ui/src/login/returnUrl.test.ts b/packages/agentconnect-ui/src/login/returnUrl.test.ts index 67be3fc..942c5bb 100644 --- a/packages/agentconnect-ui/src/login/returnUrl.test.ts +++ b/packages/agentconnect-ui/src/login/returnUrl.test.ts @@ -37,6 +37,18 @@ describe('isRelayReturn', () => { 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); }); diff --git a/packages/agentconnect-ui/src/login/returnUrl.ts b/packages/agentconnect-ui/src/login/returnUrl.ts index ca175de..4369747 100644 --- a/packages/agentconnect-ui/src/login/returnUrl.ts +++ b/packages/agentconnect-ui/src/login/returnUrl.ts @@ -22,16 +22,21 @@ export function getSessionId(search: string, hash: string): string { 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. The CLI's announce URL is a bare -// `/login#` fragment with no query string at all, so any query key -// besides `s` showing up alongside it means the OMS relay appended its own -// callback params on the way back. +// 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; - for (const key of params.keys()) { - if (key !== 's') return true; - } - return false; + return OAUTH_CALLBACK_PARAMS.some((key) => params.has(key)); }