@reclaimprotocol/agent 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/lib/authoring/backend.d.ts +9 -7
  2. package/lib/authoring/tools.js +98 -1
  3. package/lib/mcp/tools/agent/propose.js +27 -3
  4. package/lib/mcp/tools/agent/publish.js +7 -5
  5. package/lib/mcp/tools/agent/shared.js +17 -8
  6. package/lib/mcp/tools/authenticate/authenticate.d.ts +11 -0
  7. package/lib/mcp/tools/authenticate/authenticate.js +128 -119
  8. package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -3
  9. package/lib/mcp/tools/authenticate/builder-access.js +8 -3
  10. package/lib/mcp/tools/mode/mode.js +32 -1
  11. package/lib/old/client.d.ts +9 -0
  12. package/lib/old/client.js +19 -1
  13. package/lib/old/to-register.d.ts +17 -6
  14. package/lib/old/to-register.js +6 -0
  15. package/lib/old/tools/me-providers.js +3 -1
  16. package/lib/old/tools/publish.js +82 -38
  17. package/lib/proof/page-replay.d.ts +36 -0
  18. package/lib/proof/page-replay.js +76 -0
  19. package/lib/proof/replay.d.ts +28 -0
  20. package/lib/proof/replay.js +39 -18
  21. package/lib/provider/draft.d.ts +3 -1
  22. package/lib/provider/draft.js +39 -7
  23. package/lib/provider/matcher.js +116 -7
  24. package/lib/provider/to-version.d.ts +8 -6
  25. package/lib/provider/to-version.js +15 -3
  26. package/package.json +3 -2
  27. package/skill/SKILL.builder.md +1 -1
  28. package/skill/SKILL.common.md +9 -0
  29. package/skill/SKILL.old.md +1 -1
  30. package/skill/injection-api.md +44 -15
  31. package/skill/old-mode.md +3 -1
  32. package/src/authoring/backend.ts +9 -7
  33. package/src/authoring/tools.ts +119 -1
  34. package/src/mcp/tools/agent/propose.ts +29 -3
  35. package/src/mcp/tools/agent/publish.ts +7 -6
  36. package/src/mcp/tools/agent/shared.ts +19 -8
  37. package/src/mcp/tools/authenticate/authenticate.ts +150 -163
  38. package/src/mcp/tools/authenticate/builder-access.ts +8 -3
  39. package/src/mcp/tools/mode/mode.ts +34 -1
  40. package/src/old/client.ts +30 -1
  41. package/src/old/to-register.ts +25 -6
  42. package/src/old/tools/me-providers.ts +3 -1
  43. package/src/old/tools/publish.ts +86 -40
  44. package/src/proof/page-replay.ts +131 -0
  45. package/src/proof/replay.ts +60 -19
  46. package/src/provider/draft.ts +46 -6
  47. package/src/provider/matcher.ts +145 -5
  48. package/src/provider/to-version.ts +24 -9
@@ -61,13 +61,15 @@ export interface AuthoringBackend {
61
61
  * the cloud publish step) — that side effect is the backend's, not ours. */
62
62
  prove(draft: Draft, opts?: ProveOptions): Promise<unknown>;
63
63
  /**
64
- * Validate/replay a drafted recipe before proving. With `withoutSecrets` it
65
- * is the AUTH-BOUND check: re-issue the request stripped of the captured
66
- * secrets (local: a Node re-fetch without them; remote: an anonymous `fetch`
67
- * in the Popcorn page, `credentials: 'omit'`) and report whether the values
68
- * are STILL extractable — i.e. the endpoint is public. Without it, validate
69
- * extraction for the authenticated request. The shape is environment-specific
70
- * and passed straight through as the tool's result.
64
+ * Validate/replay a drafted recipe before proving. Both local and remote
65
+ * backends re-issue the request FROM INSIDE the live attached page (a
66
+ * `fetch` run over CDP), never a detached out-of-band call, so replay
67
+ * always reflects the real browser session. With `withoutSecrets` it is
68
+ * the AUTH-BOUND check: re-issue anonymously (`credentials: 'omit'`, no
69
+ * secret headers) and report whether the values are STILL extractable —
70
+ * i.e. the endpoint is public. Without it, validate extraction for the
71
+ * authenticated request. The shape is environment-specific and passed
72
+ * straight through as the tool's result.
71
73
  */
72
74
  replay(draft: Draft, opts?: {
73
75
  withoutSecrets?: boolean;
@@ -120,6 +120,97 @@ export function authoringTools(resolveBackend) {
120
120
  }
121
121
  return { ready: false };
122
122
  }),
123
+ defineTool({
124
+ name: 'test_user_script',
125
+ description: 'Dry-run a USER SCRIPT — the same thing as the `jsUserScripts` / '
126
+ + '`customInjection` field on a published provider: plain browser '
127
+ + 'JS that runs before every page load, used to drive navigation '
128
+ + 'for data gated behind login/routing — in the LIVE attached tab, '
129
+ + 'BEFORE publishing. Installs `script` via CDP so it runs on the '
130
+ + 'next navigation exactly like the real field would, then reports '
131
+ + 'whether it ran and any errors it threw. Catches bugs (e.g. a '
132
+ + 'listener registered with setTimeout that never fires because the '
133
+ + "page was already past 'load' by the time it ran) before they "
134
+ + 'surface in a real end-user session instead of after. If '
135
+ + '`navigateUrl` is given, navigates there after installing, then '
136
+ + 'removes the script again once the result is read (self-cleaning '
137
+ + '— safe to call repeatedly). Without `navigateUrl` the script is '
138
+ + 'left installed for you to navigate yourself (any way you like) '
139
+ + 'then re-call this tool, or read '
140
+ + '`window.__reclaimUserScriptResult` via eval_in_page — in that '
141
+ + 'manual mode it is NOT auto-removed and keeps running on every '
142
+ + 'later navigation until a follow-up call with `navigateUrl` '
143
+ + 'removes it, so avoid stacking several manual-mode calls in a '
144
+ + 'row.',
145
+ inputSchema: objectSchema({
146
+ script: { type: 'string' },
147
+ navigateUrl: { type: 'string' },
148
+ timeoutMs: { type: 'number' },
149
+ }, ['script']),
150
+ }, async (args) => {
151
+ const backend = resolveBackend(args);
152
+ const script = String(args.script);
153
+ // The user's script is spliced in as a STRING and compiled at
154
+ // runtime via `new Function`, not as raw top-level source: a
155
+ // syntax error in it would otherwise fail to parse the entire
156
+ // wrapper (result init, error listener, and try/catch included),
157
+ // so the tool would report ran:false / errors:[] instead of the
158
+ // actual SyntaxError.
159
+ const wrapped = `
160
+ window.__reclaimUserScriptResult = { ran: false, errors: [] };
161
+ window.addEventListener('error', function(e) {
162
+ window.__reclaimUserScriptResult.errors.push(String(e.message));
163
+ });
164
+ try {
165
+ (new Function(${JSON.stringify(script)}))();
166
+ window.__reclaimUserScriptResult.ran = true;
167
+ } catch(e) {
168
+ window.__reclaimUserScriptResult.errors.push(
169
+ String((e && e.message) || e),
170
+ );
171
+ }
172
+ `;
173
+ const installed = (await backend.sendCdp('Page.addScriptToEvaluateOnNewDocument', { source: wrapped }));
174
+ if (typeof args.navigateUrl !== 'string') {
175
+ return {
176
+ installed: true,
177
+ note: 'Installed for the NEXT navigation only — it did not run on '
178
+ + 'the current page, and stays installed (runs on every later '
179
+ + 'navigation too) until a follow-up call with navigateUrl '
180
+ + 'removes it. Navigate now (e.g. the navigate tool), then read '
181
+ + 'window.__reclaimUserScriptResult via eval_in_page, or call '
182
+ + 'test_user_script again with navigateUrl.',
183
+ };
184
+ }
185
+ try {
186
+ const timeoutMs = Number(args.timeoutMs) || 30000;
187
+ await backend.sendCdp('Page.enable');
188
+ const load = backend.waitForEvent('Page.loadEventFired', timeoutMs);
189
+ const nav = (await backend.sendCdp('Page.navigate', { url: String(args.navigateUrl) }));
190
+ if (nav.errorText) {
191
+ return { installed: true, navigated: false, error: nav.errorText };
192
+ }
193
+ await load;
194
+ const { value, error } = await evalInPage(backend, 'window.__reclaimUserScriptResult');
195
+ if (error) {
196
+ return { installed: true, navigated: true, error };
197
+ }
198
+ const result = value;
199
+ return {
200
+ installed: true,
201
+ navigated: true,
202
+ ran: result?.ran ?? false,
203
+ errors: result?.errors ?? [],
204
+ };
205
+ }
206
+ finally {
207
+ // One-shot test: remove it so it doesn't keep running (and
208
+ // potentially redirecting the tab) on every later navigation.
209
+ if (installed.identifier) {
210
+ await backend.sendCdp('Page.removeScriptToEvaluateOnNewDocument', { identifier: installed.identifier });
211
+ }
212
+ }
213
+ }),
123
214
  defineTool({
124
215
  name: 'get_cookies',
125
216
  description: 'List the NAMES of cookies currently set for the page (values are '
@@ -236,7 +327,13 @@ export function authoringTools(resolveBackend) {
236
327
  defineTool({
237
328
  name: 'run_proof',
238
329
  description: 'Prove the drafted recipe (zkTLS). Only call after replay_request '
239
- + 'looks right. Returns the proof result.',
330
+ + 'looks right. Returns the proof result. NOTE: unlike '
331
+ + 'replay_request (which re-issues from inside the live attached '
332
+ + 'browser tab), the ATTESTOR calls the target from its OWN '
333
+ + 'network/IP, separate from the browser\'s. A request that '
334
+ + 'replayed fine can still fail here for geo/IP-bound content '
335
+ + '(country-locked pages, IP-allowlisted APIs) — if that happens, '
336
+ + 'it\'s a property of the site, not a bug in the draft.',
240
337
  inputSchema: objectSchema({ draftId: { type: 'string' } }, ['draftId']),
241
338
  },
242
339
  // The backend shapes its own result: the cloud returns a lean,
@@ -19,17 +19,38 @@ export function proposeTool(resolveBackend) {
19
19
  + 'one match + redaction per target. Returns a draftId + recipe '
20
20
  + '(never secrets). Prefer a SINGLE request holding ALL values. '
21
21
  + 'Single-value shorthand: `target` + `name`. Per-target `hash` '
22
- + 'OPRF-hashes that value.',
22
+ + 'OPRF-hashes that value. BY DEFAULT each `name` is shortened to '
23
+ + 'its last alphanumeric token, lowercased, to form the param key '
24
+ + '(e.g. "employer_name" → "name") — pick names that stay distinct '
25
+ + 'after shortening ("firstName"/"surname", not "first_name"/'
26
+ + '"last_name"). Pass `shortenNames: false` to use each `name` '
27
+ + 'VERBATIM as the param key instead — it becomes both a regex '
28
+ + 'capture-group name and a paramValues key, so it must be a plain '
29
+ + 'identifier (letters/digits/underscore, not starting with a '
30
+ + 'digit); we recommend lower_snake_case (e.g. "employer_name"), '
31
+ + 'but you choose the exact name.',
23
32
  inputSchema: objectSchema({
24
33
  requestId: { type: 'string' },
25
34
  name: { type: 'string' },
35
+ shortenNames: {
36
+ type: 'boolean',
37
+ description: 'Default true: shorten each target `name` to its last '
38
+ + 'alphanumeric token, lowercased, to form the param key. '
39
+ + 'Set false to use `name` verbatim instead (must be a plain '
40
+ + 'identifier — lower_snake_case recommended).',
41
+ },
26
42
  targets: {
27
43
  type: 'array',
28
44
  items: {
29
45
  type: 'object',
30
46
  properties: {
31
47
  value: { type: 'string' },
32
- name: { type: 'string' },
48
+ name: {
49
+ type: 'string',
50
+ description: 'The param key, or the source of one — see '
51
+ + '`shortenNames`. Must stay distinct from other targets\' '
52
+ + 'names after whichever treatment applies.',
53
+ },
33
54
  hash: {
34
55
  type: 'string',
35
56
  enum: ['oprf-raw', 'oprf', 'oprf-mpc'],
@@ -46,6 +67,9 @@ export function proposeTool(resolveBackend) {
46
67
  const requestId = String(args.requestId);
47
68
  const providerName = String(args.name);
48
69
  const defaultHash = args.hash;
70
+ const shortenNames = typeof args.shortenNames === 'boolean'
71
+ ? args.shortenNames
72
+ : true;
49
73
  // Explicit multi-value `targets`, else the single `target` shorthand.
50
74
  const raw = Array.isArray(args.targets) ? args.targets : [];
51
75
  const targets = raw.length
@@ -85,7 +109,7 @@ export function proposeTool(resolveBackend) {
85
109
  if (!captured.responseBody) {
86
110
  throw new Error(`request body unavailable for ${requestId}`);
87
111
  }
88
- const { provider, secretRefs, extractions } = draftProvider(captured, targets, providerName, defaultHash);
112
+ const { provider, secretRefs, extractions } = draftProvider(captured, targets, providerName, defaultHash, { shortenNames });
89
113
  // Materialize the real secret header values for later replay/prove;
90
114
  // they stay in the server-side draft, never in this result.
91
115
  const secrets = {};
@@ -212,17 +212,19 @@ export function createVersionFromCaptureTool(client, attachRef) {
212
212
  interceptorOptions: {
213
213
  type: 'object',
214
214
  description: 'In-app interception settings, stored as ' +
215
- 'webSettings.clientOptions.inapp.interceptorOptions. ' +
216
- '`isDocumentRequestReplayEnabled` is the INVERSE of ' +
217
- 'old-devtools\' `disableRequestReplay`: true replays the ' +
218
- 'main document request via the attestor (default false).',
215
+ 'webSettings.clientOptions.inapp.interceptorOptions — only ' +
216
+ 'needed for a genuine live-interception provider (a real ' +
217
+ 'HAWKEYE/MSWJS script, not the standard capture→replay flow). ' +
218
+ 'Document replay (webSettings.clientOptions.inapp' +
219
+ '.interceptorOptions.isDocumentRequestReplayEnabled) is ' +
220
+ 'ALWAYS published disabled regardless of this field — there ' +
221
+ 'is no way to enable it here.',
219
222
  properties: {
220
223
  interceptorType: {
221
224
  type: 'string',
222
225
  enum: ['HAWKEYE', 'MSWJS', 'CDP'],
223
226
  description: 'How traffic interception is implemented.',
224
227
  },
225
- isDocumentRequestReplayEnabled: { type: 'boolean' },
226
228
  interceptorSettings: {
227
229
  type: 'string',
228
230
  description: 'Free-form JSON config for the chosen interceptor.',
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { authoringTools, extendTool, } from "../../../authoring/index.js";
11
11
  import { runAttestorProof } from "../../../proof/attestor.js";
12
- import { replayProvider } from "../../../proof/replay.js";
12
+ import { replayProviderInPage } from "../../../proof/page-replay.js";
13
13
  import { shapeRichProofResult } from "./proof.js";
14
14
  /**
15
15
  * OPRF mode this LOCAL (non-TEE) attestor can actually run. Recipes carry
@@ -39,13 +39,17 @@ function withLocalOprfMode(provider) {
39
39
  };
40
40
  }
41
41
  /** The local backend for one capture: drive its tab over CDP, read its
42
- * captured requests, prove via the in-process attestor, replay by re-fetch. */
42
+ * captured requests, prove via the in-process attestor, replay by re-fetching
43
+ * FROM INSIDE the tab (real cookies/session — see proof/page-replay.ts). */
43
44
  function localBackend(entry) {
44
45
  const { tabClient, session, drafts } = entry;
46
+ const sendCdp = (method, params) => {
47
+ return tabClient.send(method, params ?? {});
48
+ };
45
49
  return {
46
50
  capture: session,
47
51
  drafts,
48
- sendCdp: (method, params) => tabClient.send(method, params ?? {}),
52
+ sendCdp,
49
53
  waitForEvent: (method, timeoutMs) => new Promise((resolve) => {
50
54
  let stopListening = () => { };
51
55
  const onEvent = () => {
@@ -63,10 +67,9 @@ function localBackend(entry) {
63
67
  stopListening = tabClient.subscribeOnce(method, onEvent);
64
68
  }),
65
69
  replay: async (draft, opts) => {
66
- // Auth-bound check: re-fetch with the secrets dropped. replayProvider
67
- // already does a live Node fetch, so empty secrets == anonymous.
68
- const secrets = opts?.withoutSecrets ? {} : draft.secrets;
69
- const result = await replayProvider(draft.provider, secrets);
70
+ // Replayed FROM INSIDE the attached tab (real cookies/session), not
71
+ // a detached Node fetch — see proof/page-replay.ts.
72
+ const result = await replayProviderInPage({ sendCdp }, draft.provider, draft.secrets, opts);
70
73
  return {
71
74
  status: result.status,
72
75
  matchesOriginal: result.matchesOriginal,
@@ -105,7 +108,13 @@ const RUN_PROOF_EXTRAS = {
105
108
  + 'specific alternative — do NOT invent backup URLs. `ownerAddress` '
106
109
  + '(the credential to sign with) is REQUIRED — call resolve_owner_key '
107
110
  + 'first. errorKind="missing-owner-key" means that address has no known '
108
- + 'private key; re-run resolve_owner_key or issue/import a credential.',
111
+ + 'private key; re-run resolve_owner_key or issue/import a credential. '
112
+ + 'NOTE: unlike replay_request (which re-issues from inside the live '
113
+ + 'attached browser tab), the ATTESTOR calls the target from its OWN '
114
+ + 'network/IP, separate from the browser\'s. A request that replayed '
115
+ + 'fine can still fail here for geo/IP-bound content (country-locked '
116
+ + "pages, IP-allowlisted APIs) — if that happens, it's a property of "
117
+ + 'the site, not a bug in the draft.',
109
118
  properties: {
110
119
  ownerAddress: {
111
120
  type: 'string',
@@ -1,3 +1,14 @@
1
1
  import type { ReclaimClient } from '@reclaimprotocol/client';
2
2
  import { type RegisteredTool } from '../../server.ts';
3
3
  export declare function authenticateTool(client: ReclaimClient, name?: 'reclaim_authenticate' | 'authenticate_builder'): RegisteredTool;
4
+ /**
5
+ * Standard OAuth 2.0 Authorization Code + PKCE, native-app style (RFC 8252):
6
+ * dynamically register a public client (RFC 7591 — Better Auth's
7
+ * `oauth-provider` plugin issues no secret for it), send the user to the
8
+ * browser, and catch the redirect on a loopback server. `openid-client`
9
+ * handles PKCE, `state` CSRF protection, and authorization-response
10
+ * validation (including a denied/failed login surfacing as
11
+ * `AuthorizationResponseError`) — this function only wires those calls
12
+ * together and owns the loopback server (no library spins that up for you).
13
+ */
14
+ export declare function runOAuthLoopback(client: ReclaimClient, openBrowserFn?: (url: string) => void): Promise<unknown>;
@@ -1,7 +1,6 @@
1
- import assert from 'node:assert';
2
1
  import { spawn } from 'node:child_process';
3
- import { createHash, randomBytes } from 'node:crypto';
4
2
  import { createServer } from 'node:http';
3
+ import * as oauth from 'openid-client';
5
4
  import { defineTool } from "../../server.js";
6
5
  import { writeSession } from "./session-cache.js";
7
6
  const SCOPE = 'openid profile email';
@@ -10,9 +9,17 @@ export function authenticateTool(client, name = 'reclaim_authenticate') {
10
9
  return defineTool({
11
10
  name,
12
11
  description: (name === 'authenticate_builder'
13
- ? 'Authenticate specifically with Builder. Available regardless '
14
- + 'of '
15
- + 'the selected publishing backend. '
12
+ ? 'This tool only exists in OLD-DEVTOOLS mode — Builder mode has '
13
+ + 'no separate version of it; its own reclaim_authenticate '
14
+ + 'already covers everything below. In old-devtools mode, this '
15
+ + 'authenticates with Builder for exactly two Builder-exclusive '
16
+ + 'features: allocating a Popcorn remote browser (attach_browser '
17
+ + 'mode="builder") and listing Builder organizations '
18
+ + '(list_builder_organizations — old-devtools has no generated '
19
+ + 'org tool of its own). It does NOT authorize '
20
+ + 'create_provider_version_from_capture or get_me_providers, '
21
+ + 'which need their OWN, separate login via the old-devtools '
22
+ + 'reclaim_authenticate tool. Check both with get_devtools_mode. '
16
23
  : '')
17
24
  + 'Authorize the CLI / MCP to make authenticated API calls. Opens '
18
25
  + 'the '
@@ -27,38 +34,96 @@ export function authenticateTool(client, name = 'reclaim_authenticate') {
27
34
  inputSchema: { type: 'object', properties: {} },
28
35
  }, async () => runOAuthLoopback(client));
29
36
  }
30
- async function runOAuthLoopback(client) {
37
+ const _customFetchForOauth = async (url, options) => {
38
+ const res = await fetch(url, options);
39
+ if (options.method === 'POST' && res.status === 200 &&
40
+ new URL(url).pathname.endsWith('/oauth2/register')) {
41
+ return new Response(await res.text(), {
42
+ status: 201,
43
+ headers: res.headers,
44
+ });
45
+ }
46
+ return res;
47
+ };
48
+ /**
49
+ * Standard OAuth 2.0 Authorization Code + PKCE, native-app style (RFC 8252):
50
+ * dynamically register a public client (RFC 7591 — Better Auth's
51
+ * `oauth-provider` plugin issues no secret for it), send the user to the
52
+ * browser, and catch the redirect on a loopback server. `openid-client`
53
+ * handles PKCE, `state` CSRF protection, and authorization-response
54
+ * validation (including a denied/failed login surfacing as
55
+ * `AuthorizationResponseError`) — this function only wires those calls
56
+ * together and owns the loopback server (no library spins that up for you).
57
+ */
58
+ export async function runOAuthLoopback(client,
59
+ // Overridable only so tests can intercept the authorize URL instead of
60
+ // actually spawning a browser; every real caller uses the default.
61
+ openBrowserFn = openBrowser) {
31
62
  const authBase = `${client.baseUrl}/auth`;
32
- const verifier = base64url(randomBytes(32));
33
- const challenge = base64url(createHash('sha256').update(verifier).digest());
34
- const state = base64url(randomBytes(16));
63
+ const codeVerifier = oauth.randomPKCECodeVerifier();
64
+ const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
65
+ const state = oauth.randomState();
35
66
  // Bind the callback port first: the client is registered for this exact
36
- // loopback redirect, and the same client_id has to be presented again at the
37
- // token endpoint (public clients authenticate by client_id alone).
38
- const callback = await listenForCallback(state);
39
- let token;
67
+ // loopback redirect.
68
+ const callback = await listenForCallback();
69
+ let tokens;
40
70
  try {
41
- const clientId = await registerClient(authBase, callback.redirectUri);
42
- openBrowser(authorizeUrl(authBase, {
43
- clientId,
44
- redirectUri: callback.redirectUri,
45
- challenge,
71
+ const config = await oauth.dynamicClientRegistration(new URL(authBase), {
72
+ client_name: 'Reclaim CLI',
73
+ redirect_uris: [callback.redirectUri],
74
+ token_endpoint_auth_method: 'none',
75
+ grant_types: ['authorization_code', 'refresh_token'],
76
+ response_types: ['code'],
77
+ scope: SCOPE,
78
+ }, undefined, {
79
+ // Better Auth exposes its metadata at the RFC 8414 well-known path
80
+ // (`.well-known/oauth-authorization-server`), not the OIDC one.
81
+ algorithm: 'oauth2',
82
+ // RFC 7591 §3.2.1 requires 201 Created on a successful registration;
83
+ // openid-client enforces that strictly. Better Auth's
84
+ // `oauth-provider` plugin doesn't conform — confirmed live against
85
+ // the real server: `POST .../oauth2/register` returns 200 with an
86
+ // otherwise well-formed body, which openid-client rejects outright
87
+ // ("unexpected HTTP response status code") before ever looking at
88
+ // it. `customFetch` set here persists onto the returned
89
+ // `Configuration` for ALL its future calls too (token exchange
90
+ // included, which correctly returns 200) — so the URL itself, not
91
+ // just method+status, must gate the rewrite, or a genuine 200 from
92
+ // the token endpoint gets corrupted into a 201 and
93
+ // `authorizationCodeGrant` fails instead (confirmed by hitting
94
+ // exactly that when this only checked method === 'POST').
95
+ [oauth.customFetch]: _customFetchForOauth,
96
+ });
97
+ openBrowserFn(oauth.buildAuthorizationUrl(config, {
98
+ redirect_uri: callback.redirectUri,
99
+ scope: SCOPE,
46
100
  state,
47
- }));
48
- const code = await callback.code;
49
- token = await exchangeCode(authBase, {
50
- code,
51
- verifier,
52
- redirectUri: callback.redirectUri,
53
- clientId,
101
+ code_challenge: codeChallenge,
102
+ code_challenge_method: 'S256',
103
+ }).href);
104
+ const callbackUrl = await callback.url;
105
+ tokens = await oauth.authorizationCodeGrant(config, callbackUrl, {
106
+ pkceCodeVerifier: codeVerifier,
107
+ expectedState: state,
54
108
  });
55
109
  }
110
+ catch (err) {
111
+ // A denied or failed authorization surfaces as this error class with
112
+ // the raw callback params on `.cause` — reword it like the previous
113
+ // hand-rolled check did (RFC 6749 §4.1.2.1's error/error_description).
114
+ if (err instanceof oauth.AuthorizationResponseError) {
115
+ throw new Error(err.cause.get('error_description')
116
+ ?? err.cause.get('error')
117
+ ?? 'Sign-in failed or was cancelled.');
118
+ }
119
+ throw err;
120
+ }
56
121
  finally {
57
122
  callback.close();
58
123
  }
59
- const expiresAt = new Date(Date.now() + token.expires_in * 1000).toISOString();
60
- writeSession({ token: token.access_token, expiresAt });
61
- client.setToken(token.access_token);
124
+ const expiresAt = new Date(Date.now() + (tokens.expires_in ?? 3600) * 1000).toISOString();
125
+ writeSession({ token: tokens.access_token, expiresAt });
126
+ client.setToken(tokens.access_token);
62
127
  return {
63
128
  status: 'AUTHENTICATED',
64
129
  note: 'Access token cached in ~/.reclaim/config.json and active now.',
@@ -67,42 +132,23 @@ async function runOAuthLoopback(client) {
67
132
  }
68
133
  /**
69
134
  * Bind an ephemeral loopback port and start listening for the provider's
70
- * redirect. Resolves as soon as the port is known — the `code` promise settles
135
+ * redirect. Resolves as soon as the port is known — the `url` promise settles
71
136
  * later, when the browser hits `/callback` — so the caller can register a
72
137
  * client for this exact redirect URI before sending the user off to authorize.
73
- * The ephemeral port lets any number of CLIs run concurrently. Defined below
74
- * its caller per repo convention.
138
+ * The ephemeral port lets any number of CLIs run concurrently. Hands back the
139
+ * RAW callback URL rather than picking `code`/`state` apart itself —
140
+ * `oauth.authorizationCodeGrant` does that validation. Defined below its
141
+ * caller per repo convention.
75
142
  */
76
- async function listenForCallback(state) {
77
- let resolveCode;
78
- let rejectCode;
79
- const code = new Promise((resolve, reject) => {
80
- resolveCode = resolve;
81
- rejectCode = reject;
143
+ async function listenForCallback() {
144
+ let resolveUrl;
145
+ let rejectUrl;
146
+ const url = new Promise((resolve, reject) => {
147
+ resolveUrl = resolve;
148
+ rejectUrl = reject;
82
149
  });
83
150
  const server = createServer();
84
- const timer = setTimeout(() => rejectCode(new Error('Timed out waiting for sign-in (5 min).')), CALLBACK_TIMEOUT_MS);
85
- server.on('request', (req, res) => {
86
- const url = new URL(req.url ?? '/', 'http://127.0.0.1');
87
- if (url.pathname !== '/callback') {
88
- res.writeHead(404).end();
89
- return;
90
- }
91
- res.writeHead(200, { 'content-type': 'text/html' });
92
- res.end('<!doctype html><meta charset=utf-8><body style="font:16px '
93
- + 'system-ui;padding:3rem;text-align:center">Signed in. You can '
94
- + 'close this tab and return to your terminal.</body>');
95
- const authCode = url.searchParams.get('code');
96
- if (!authCode || url.searchParams.get('state') !== state) {
97
- // A denied or failed authorization comes back as `error`/
98
- // `error_description` (RFC 6749 §4.1.2.1) — surface it verbatim.
99
- const err = url.searchParams.get('error_description')
100
- ?? url.searchParams.get('error');
101
- rejectCode(new Error(err ?? 'Sign-in failed or was cancelled.'));
102
- return;
103
- }
104
- resolveCode(authCode);
105
- });
151
+ const timer = setTimeout(() => rejectUrl(new Error('Timed out waiting for sign-in (5 min).')), CALLBACK_TIMEOUT_MS);
106
152
  const port = await new Promise((resolve, reject) => {
107
153
  server.once('error', reject);
108
154
  server.listen(0, '127.0.0.1', () => {
@@ -113,75 +159,38 @@ async function listenForCallback(state) {
113
159
  }
114
160
  resolve(addr.port);
115
161
  });
162
+ }).catch((err) => {
163
+ // Bind failed before `close()` (returned below) is ever reachable —
164
+ // clear the timer ourselves, or it fires 5 minutes later and rejects
165
+ // the now-unobserved `url` promise (unhandled rejection) while keeping
166
+ // the process alive in the meantime.
167
+ clearTimeout(timer);
168
+ throw err;
116
169
  });
117
170
  // Past bind, a socket error fails the pending wait rather than throwing.
118
- server.on('error', (err) => rejectCode(err));
171
+ server.on('error', (err) => rejectUrl(err));
172
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
173
+ server.on('request', (req, res) => {
174
+ const reqUrl = new URL(req.url ?? '/', redirectUri);
175
+ if (reqUrl.pathname !== '/callback') {
176
+ res.writeHead(404).end();
177
+ return;
178
+ }
179
+ res.writeHead(200, { 'content-type': 'text/html' });
180
+ res.end('<!doctype html><meta charset=utf-8><body style="font:16px '
181
+ + 'system-ui;padding:3rem;text-align:center">Signed in. You can '
182
+ + 'close this tab and return to your terminal.</body>');
183
+ resolveUrl(reqUrl);
184
+ });
119
185
  return {
120
- redirectUri: `http://127.0.0.1:${port}/callback`,
121
- code,
186
+ redirectUri,
187
+ url,
122
188
  close: () => {
123
189
  clearTimeout(timer);
124
190
  server.close();
125
191
  },
126
192
  };
127
193
  }
128
- function authorizeUrl(authBase, opts) {
129
- return `${authBase}/oauth2/authorize?`
130
- + new URLSearchParams({
131
- response_type: 'code',
132
- client_id: opts.clientId,
133
- redirect_uri: opts.redirectUri,
134
- scope: SCOPE,
135
- state: opts.state,
136
- code_challenge: opts.challenge,
137
- code_challenge_method: 'S256',
138
- }).toString();
139
- }
140
- /** Dynamic client registration (RFC 7591) for a public PKCE client. */
141
- async function registerClient(authBase, redirectUri) {
142
- const res = await fetch(`${authBase}/oauth2/register`, {
143
- method: 'POST',
144
- headers: { 'content-type': 'application/json' },
145
- body: JSON.stringify({
146
- client_name: 'Reclaim CLI',
147
- redirect_uris: [redirectUri],
148
- token_endpoint_auth_method: 'none',
149
- grant_types: ['authorization_code', 'refresh_token'],
150
- response_types: ['code'],
151
- scope: SCOPE,
152
- }),
153
- });
154
- assert(res.ok, new Error(`Client registration failed (${res.status}).`));
155
- const json = (await res.json());
156
- assert(json.client_id, new Error('Client registration returned no client_id.'));
157
- return json.client_id;
158
- }
159
- async function exchangeCode(authBase, opts) {
160
- const res = await fetch(`${authBase}/oauth2/token`, {
161
- method: 'POST',
162
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
163
- body: new URLSearchParams({
164
- grant_type: 'authorization_code',
165
- code: opts.code,
166
- redirect_uri: opts.redirectUri,
167
- code_verifier: opts.verifier,
168
- // Required even though this is a public client with no secret: it's
169
- // how the token endpoint identifies the client (RFC 6749 §4.1.3).
170
- client_id: opts.clientId,
171
- }).toString(),
172
- });
173
- const body = await res.text();
174
- assert(res.ok, new Error(`Token exchange failed (${res.status}): ${body}`));
175
- const json = JSON.parse(body);
176
- assert(json.access_token, new Error('Token exchange returned no access_token.'));
177
- return {
178
- access_token: json.access_token,
179
- expires_in: json.expires_in ?? 3600,
180
- };
181
- }
182
- function base64url(buf) {
183
- return buf.toString('base64url');
184
- }
185
194
  function openBrowser(url) {
186
195
  const cmd = process.platform === 'darwin'
187
196
  ? 'open'
@@ -1,8 +1,13 @@
1
1
  import type { ReclaimClient } from '@reclaimprotocol/client';
2
2
  import { type RegisteredTool } from '../../server.ts';
3
- /** Legacy-devtools mode needs a distinct Builder login solely for allocating
4
- * quota-accounted Popcorn sessions. Builder mode uses its native auth tool. */
3
+ /** Legacy-devtools mode needs a distinct Builder login for its two
4
+ * Builder-exclusive features: allocating quota-accounted Popcorn sessions
5
+ * (see `buildBuilderOrganizationTools` below) and listing Builder
6
+ * organizations. Builder mode needs neither — its native auth tool alone
7
+ * covers both, org listing included (via its generated `list_orgs`). */
5
8
  export declare function buildBuilderAuthenticationTools(client: ReclaimClient): RegisteredTool[];
6
9
  /** Legacy-devtools mode lacks generated Builder API tools, so it needs this
7
- * narrow organization-discovery bridge for remote-browser allocation. */
10
+ * narrow organization-discovery bridge — an org id is required to allocate a
11
+ * remote-browser (Popcorn) session. Builder mode's own `list_orgs` already
12
+ * covers this under its native auth. */
8
13
  export declare function buildBuilderOrganizationTools(client: ReclaimClient): RegisteredTool[];
@@ -1,12 +1,17 @@
1
1
  import { defineTool } from "../../server.js";
2
2
  import { authenticateTool } from "./authenticate.js";
3
- /** Legacy-devtools mode needs a distinct Builder login solely for allocating
4
- * quota-accounted Popcorn sessions. Builder mode uses its native auth tool. */
3
+ /** Legacy-devtools mode needs a distinct Builder login for its two
4
+ * Builder-exclusive features: allocating quota-accounted Popcorn sessions
5
+ * (see `buildBuilderOrganizationTools` below) and listing Builder
6
+ * organizations. Builder mode needs neither — its native auth tool alone
7
+ * covers both, org listing included (via its generated `list_orgs`). */
5
8
  export function buildBuilderAuthenticationTools(client) {
6
9
  return [authenticateTool(client, 'authenticate_builder')];
7
10
  }
8
11
  /** Legacy-devtools mode lacks generated Builder API tools, so it needs this
9
- * narrow organization-discovery bridge for remote-browser allocation. */
12
+ * narrow organization-discovery bridge — an org id is required to allocate a
13
+ * remote-browser (Popcorn) session. Builder mode's own `list_orgs` already
14
+ * covers this under its native auth. */
10
15
  export function buildBuilderOrganizationTools(client) {
11
16
  return [
12
17
  defineTool({