@reclaimprotocol/agent 0.1.22 → 0.1.24

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 (163) hide show
  1. package/README.md +55 -20
  2. package/lib/authoring/backend.d.ts +13 -10
  3. package/lib/authoring/tools.js +106 -9
  4. package/lib/cdp/browser-proxy.d.ts +78 -0
  5. package/lib/cdp/browser-proxy.js +186 -0
  6. package/lib/cdp/capture-engine.js +8 -7
  7. package/lib/cdp/client.d.ts +10 -0
  8. package/lib/cdp/geo.d.ts +10 -0
  9. package/lib/cdp/geo.js +36 -0
  10. package/lib/cdp/index.d.ts +1 -0
  11. package/lib/cdp/index.js +1 -0
  12. package/lib/live/cloudflared.d.ts +15 -0
  13. package/lib/live/cloudflared.js +268 -0
  14. package/lib/live/page.d.ts +20 -0
  15. package/lib/live/page.js +134 -0
  16. package/lib/live/tunnel.d.ts +23 -0
  17. package/lib/live/tunnel.js +120 -0
  18. package/lib/live/viewer.d.ts +28 -0
  19. package/lib/live/viewer.js +232 -0
  20. package/lib/mcp/api/operations.gen.js +34 -26
  21. package/lib/mcp/api/to-zod.d.ts +1 -1
  22. package/lib/mcp/api/to-zod.js +1 -1
  23. package/lib/mcp/api/tools.js +1 -1
  24. package/lib/mcp/instructions.d.ts +15 -2
  25. package/lib/mcp/instructions.js +101 -38
  26. package/lib/mcp/start.js +4 -3
  27. package/lib/mcp/tools/agent/analyze.js +5 -1
  28. package/lib/mcp/tools/agent/attach.d.ts +8 -1
  29. package/lib/mcp/tools/agent/attach.js +166 -22
  30. package/lib/mcp/tools/agent/build.d.ts +13 -8
  31. package/lib/mcp/tools/agent/build.js +22 -11
  32. package/lib/mcp/tools/agent/capture.js +5 -1
  33. package/lib/mcp/tools/agent/eval.js +3 -1
  34. package/lib/mcp/tools/agent/find.js +6 -5
  35. package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
  36. package/lib/mcp/tools/agent/how-it-works.js +57 -17
  37. package/lib/mcp/tools/agent/live-view.d.ts +21 -0
  38. package/lib/mcp/tools/agent/live-view.js +153 -0
  39. package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
  40. package/lib/mcp/tools/agent/pod-proof.js +64 -0
  41. package/lib/mcp/tools/agent/propose.js +29 -3
  42. package/lib/mcp/tools/agent/proxy.d.ts +3 -0
  43. package/lib/mcp/tools/agent/proxy.js +199 -0
  44. package/lib/mcp/tools/agent/publish.js +49 -77
  45. package/lib/mcp/tools/agent/session.d.ts +2 -1
  46. package/lib/mcp/tools/agent/session.js +26 -7
  47. package/lib/mcp/tools/agent/shared.js +17 -8
  48. package/lib/mcp/tools/authenticate/authenticate.d.ts +11 -0
  49. package/lib/mcp/tools/authenticate/authenticate.js +143 -121
  50. package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -3
  51. package/lib/mcp/tools/authenticate/builder-access.js +8 -3
  52. package/lib/mcp/tools/mode/mode.d.ts +1 -1
  53. package/lib/mcp/tools/mode/mode.js +26 -7
  54. package/lib/old/client.d.ts +9 -0
  55. package/lib/old/client.js +19 -1
  56. package/lib/old/instructions.d.ts +9 -1
  57. package/lib/old/instructions.js +4 -6
  58. package/lib/old/login-cdp.d.ts +2 -2
  59. package/lib/old/login-cdp.js +46 -6
  60. package/lib/old/to-register.d.ts +20 -8
  61. package/lib/old/to-register.js +7 -1
  62. package/lib/old/tools/authenticate.js +9 -1
  63. package/lib/old/tools/get-provider-info.js +6 -5
  64. package/lib/old/tools/issue-app-credentials.js +3 -2
  65. package/lib/old/tools/me-providers.js +3 -1
  66. package/lib/old/tools/publish.d.ts +1 -1
  67. package/lib/old/tools/publish.js +132 -129
  68. package/lib/old/tools/update-provider-metadata.d.ts +1 -1
  69. package/lib/old/tools/update-provider-metadata.js +1 -1
  70. package/lib/old/tools/verify-sdk-setup.js +1 -1
  71. package/lib/proof/attestor.d.ts +3 -3
  72. package/lib/proof/debug-response-dump.js +4 -4
  73. package/lib/proof/index.d.ts +1 -0
  74. package/lib/proof/index.js +1 -0
  75. package/lib/proof/page-replay.d.ts +36 -0
  76. package/lib/proof/page-replay.js +76 -0
  77. package/lib/proof/pod-prover.d.ts +47 -0
  78. package/lib/proof/pod-prover.js +155 -0
  79. package/lib/proof/replay.d.ts +28 -0
  80. package/lib/proof/replay.js +39 -18
  81. package/lib/provider/dom-xpath.js +11 -9
  82. package/lib/provider/draft.d.ts +13 -8
  83. package/lib/provider/draft.js +47 -13
  84. package/lib/provider/injection-doc.d.ts +23 -1
  85. package/lib/provider/injection-doc.js +34 -21
  86. package/lib/provider/match-redaction-warning.d.ts +2 -2
  87. package/lib/provider/match-redaction-warning.js +3 -3
  88. package/lib/provider/matcher.js +122 -12
  89. package/lib/provider/secrets.d.ts +3 -3
  90. package/lib/provider/secrets.js +3 -3
  91. package/lib/provider/to-version.d.ts +12 -9
  92. package/lib/provider/to-version.js +16 -4
  93. package/lib/tools/registry.d.ts +5 -5
  94. package/lib/tools/registry.js +5 -5
  95. package/package.json +4 -3
  96. package/samples/demo-server/README.md +7 -6
  97. package/skill/SKILL.builder.md +356 -189
  98. package/skill/SKILL.common.md +314 -79
  99. package/skill/SKILL.old.md +340 -155
  100. package/skill/builder-mode.md +58 -17
  101. package/skill/hash-validation.md +170 -0
  102. package/skill/interception.md +235 -0
  103. package/skill/js-sdk-integration.md +13 -7
  104. package/skill/old-mode.md +53 -12
  105. package/skill/user-script.md +61 -0
  106. package/src/authoring/backend.ts +13 -10
  107. package/src/authoring/tools.ts +128 -9
  108. package/src/cdp/capture-engine.ts +8 -7
  109. package/src/cdp/client.ts +8 -0
  110. package/src/cdp/geo.ts +52 -0
  111. package/src/cdp/index.ts +1 -0
  112. package/src/consts.ts +1 -0
  113. package/src/live/cloudflared.ts +348 -0
  114. package/src/live/page.ts +145 -0
  115. package/src/live/tunnel.ts +157 -0
  116. package/src/live/viewer.ts +293 -0
  117. package/src/mcp/api/operations.gen.ts +34 -26
  118. package/src/mcp/api/to-zod.ts +1 -1
  119. package/src/mcp/api/tools.ts +1 -1
  120. package/src/mcp/instructions.ts +121 -48
  121. package/src/mcp/start.ts +4 -3
  122. package/src/mcp/tools/agent/analyze.ts +5 -1
  123. package/src/mcp/tools/agent/attach.ts +194 -22
  124. package/src/mcp/tools/agent/build.ts +26 -15
  125. package/src/mcp/tools/agent/capture.ts +5 -1
  126. package/src/mcp/tools/agent/eval.ts +4 -1
  127. package/src/mcp/tools/agent/find.ts +6 -5
  128. package/src/mcp/tools/agent/how-it-works.ts +62 -21
  129. package/src/mcp/tools/agent/live-view.ts +198 -0
  130. package/src/mcp/tools/agent/pod-proof.ts +90 -0
  131. package/src/mcp/tools/agent/propose.ts +31 -3
  132. package/src/mcp/tools/agent/publish.ts +54 -78
  133. package/src/mcp/tools/agent/session.ts +26 -5
  134. package/src/mcp/tools/agent/shared.ts +19 -8
  135. package/src/mcp/tools/authenticate/authenticate.ts +167 -165
  136. package/src/mcp/tools/authenticate/builder-access.ts +8 -3
  137. package/src/mcp/tools/mode/mode.ts +28 -7
  138. package/src/old/client.ts +30 -1
  139. package/src/old/instructions.ts +4 -10
  140. package/src/old/login-cdp.ts +50 -6
  141. package/src/old/to-register.ts +28 -8
  142. package/src/old/tools/authenticate.ts +9 -1
  143. package/src/old/tools/get-provider-info.ts +6 -5
  144. package/src/old/tools/issue-app-credentials.ts +3 -2
  145. package/src/old/tools/me-providers.ts +3 -1
  146. package/src/old/tools/publish.ts +140 -130
  147. package/src/old/tools/update-provider-metadata.ts +1 -1
  148. package/src/old/tools/verify-sdk-setup.ts +1 -1
  149. package/src/proof/attestor.ts +3 -3
  150. package/src/proof/debug-response-dump.ts +4 -4
  151. package/src/proof/index.ts +8 -0
  152. package/src/proof/page-replay.ts +131 -0
  153. package/src/proof/pod-prover.ts +231 -0
  154. package/src/proof/replay.ts +60 -19
  155. package/src/provider/dom-xpath.ts +11 -9
  156. package/src/provider/draft.ts +56 -13
  157. package/src/provider/injection-doc.ts +37 -24
  158. package/src/provider/match-redaction-warning.ts +3 -3
  159. package/src/provider/matcher.ts +151 -10
  160. package/src/provider/secrets.ts +3 -3
  161. package/src/provider/to-version.ts +30 -13
  162. package/src/tools/registry.ts +5 -5
  163. package/skill/injection-api.md +0 -335
@@ -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,12 @@ 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
+ ? 'OLD-DEVTOOLS MODE ONLY, and narrow: it authorizes exactly two '
13
+ + 'Builder-exclusive features — allocating a remote browser '
14
+ + '(attach_browser mode="builder") and list_builder_organizations. '
15
+ + 'It does NOT authorize create_provider_version_from_capture or '
16
+ + 'get_me_providers; those need the separate old-devtools login, '
17
+ + 'reclaim_authenticate. Check both with get_devtools_mode. '
16
18
  : '')
17
19
  + 'Authorize the CLI / MCP to make authenticated API calls. Opens '
18
20
  + 'the '
@@ -21,44 +23,120 @@ export function authenticateTool(client, name = 'reclaim_authenticate') {
21
23
  'the user to sign in and approve. On success the access token is ' +
22
24
  'cached in ~/.reclaim/config.json and activated for this session.\n\n' +
23
25
  'Call with no arguments. This bearer token authenticates ' +
24
- 'dashboard/org-management calls (e.g. list_orgs, issuing an org ' +
25
- 'secret). Programmatic verification calls use a separate org secret ' +
26
+ 'dashboard/org-management calls (for example, list_orgs, or ' +
27
+ 'issuing an org secret). Programmatic verification calls use a ' +
28
+ 'separate org secret ' +
26
29
  '(rorg_…, set as RECLAIM_ORG_SECRET).',
27
30
  inputSchema: { type: 'object', properties: {} },
28
31
  }, async () => runOAuthLoopback(client));
29
32
  }
30
- async function runOAuthLoopback(client) {
33
+ const _customFetchForOauth = async (url, options) => {
34
+ const res = await fetch(url, options);
35
+ if (options.method === 'POST' && res.status === 200 &&
36
+ new URL(url).pathname.endsWith('/oauth2/register')) {
37
+ return new Response(await res.text(), {
38
+ status: 201,
39
+ headers: res.headers,
40
+ });
41
+ }
42
+ return res;
43
+ };
44
+ /**
45
+ * Standard OAuth 2.0 Authorization Code + PKCE, native-app style (RFC 8252):
46
+ * dynamically register a public client (RFC 7591 — Better Auth's
47
+ * `oauth-provider` plugin issues no secret for it), send the user to the
48
+ * browser, and catch the redirect on a loopback server. `openid-client`
49
+ * handles PKCE, `state` CSRF protection, and authorization-response
50
+ * validation (including a denied/failed login surfacing as
51
+ * `AuthorizationResponseError`) — this function only wires those calls
52
+ * together and owns the loopback server (no library spins that up for you).
53
+ */
54
+ export async function runOAuthLoopback(client,
55
+ // Overridable only so tests can intercept the authorize URL instead of
56
+ // actually spawning a browser; every real caller uses the default.
57
+ openBrowserFn = openBrowser) {
31
58
  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));
59
+ const codeVerifier = oauth.randomPKCECodeVerifier();
60
+ const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier);
61
+ const state = oauth.randomState();
62
+ // openid-client/oauth4webapi refuse any non-HTTPS issuer by default
63
+ // ("only requests to HTTPS are allowed"). Better Auth's own dev default
64
+ // (`canonicalOrigin()`, packages/app/src/consts.ts) is plain
65
+ // `http://localhost:4001` — the server side has no HTTPS requirement at
66
+ // all — so a local `RECLAIM_API_URL=http://localhost:...` would otherwise
67
+ // always fail here. Loosen it ONLY for loopback hosts, matching what the
68
+ // server already trusts (`authTrustedOrigins()` defaults to
69
+ // localhost:4000/4001 outside production); a non-loopback http:// origin
70
+ // still gets the strict check.
71
+ const isLoopbackAuthBase = ['localhost', '127.0.0.1', '[::1]'].includes(new URL(authBase).hostname);
35
72
  // 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;
73
+ // loopback redirect.
74
+ const callback = await listenForCallback();
75
+ let tokens;
40
76
  try {
41
- const clientId = await registerClient(authBase, callback.redirectUri);
42
- openBrowser(authorizeUrl(authBase, {
43
- clientId,
44
- redirectUri: callback.redirectUri,
45
- challenge,
77
+ const config = await oauth.dynamicClientRegistration(new URL(authBase), {
78
+ client_name: 'Reclaim CLI',
79
+ redirect_uris: [callback.redirectUri],
80
+ token_endpoint_auth_method: 'none',
81
+ grant_types: ['authorization_code', 'refresh_token'],
82
+ response_types: ['code'],
83
+ scope: SCOPE,
84
+ }, undefined, {
85
+ // Better Auth exposes its metadata at the RFC 8414 well-known path
86
+ // (`.well-known/oauth-authorization-server`), not the OIDC one.
87
+ algorithm: 'oauth2',
88
+ // RFC 7591 §3.2.1 requires 201 Created on a successful registration;
89
+ // openid-client enforces that strictly. Better Auth's
90
+ // `oauth-provider` plugin doesn't conform — confirmed live against
91
+ // the real server: `POST .../oauth2/register` returns 200 with an
92
+ // otherwise well-formed body, which openid-client rejects outright
93
+ // ("unexpected HTTP response status code") before ever looking at
94
+ // it. `customFetch` set here persists onto the returned
95
+ // `Configuration` for ALL its future calls too (token exchange
96
+ // included, which correctly returns 200) — so the URL itself, not
97
+ // just method+status, must gate the rewrite, or a genuine 200 from
98
+ // the token endpoint gets corrupted into a 201 and
99
+ // `authorizationCodeGrant` fails instead (confirmed by hitting
100
+ // exactly that when this only checked method === 'POST').
101
+ [oauth.customFetch]: _customFetchForOauth,
102
+ // Applies to the `Configuration` this call returns, so the later
103
+ // `buildAuthorizationUrl`/`authorizationCodeGrant` calls below (which
104
+ // reuse that same `config`) stop enforcing HTTPS too — no separate
105
+ // opt-in needed for them.
106
+ ...(isLoopbackAuthBase
107
+ ? { execute: [oauth.allowInsecureRequests] }
108
+ : {}),
109
+ });
110
+ openBrowserFn(oauth.buildAuthorizationUrl(config, {
111
+ redirect_uri: callback.redirectUri,
112
+ scope: SCOPE,
46
113
  state,
47
- }));
48
- const code = await callback.code;
49
- token = await exchangeCode(authBase, {
50
- code,
51
- verifier,
52
- redirectUri: callback.redirectUri,
53
- clientId,
114
+ code_challenge: codeChallenge,
115
+ code_challenge_method: 'S256',
116
+ }).href);
117
+ const callbackUrl = await callback.url;
118
+ tokens = await oauth.authorizationCodeGrant(config, callbackUrl, {
119
+ pkceCodeVerifier: codeVerifier,
120
+ expectedState: state,
54
121
  });
55
122
  }
123
+ catch (err) {
124
+ // A denied or failed authorization surfaces as this error class with
125
+ // the raw callback params on `.cause` — reword it like the previous
126
+ // hand-rolled check did (RFC 6749 §4.1.2.1's error/error_description).
127
+ if (err instanceof oauth.AuthorizationResponseError) {
128
+ throw new Error(err.cause.get('error_description')
129
+ ?? err.cause.get('error')
130
+ ?? 'Sign-in failed or was cancelled.');
131
+ }
132
+ throw err;
133
+ }
56
134
  finally {
57
135
  callback.close();
58
136
  }
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);
137
+ const expiresAt = new Date(Date.now() + (tokens.expires_in ?? 3600) * 1000).toISOString();
138
+ writeSession({ token: tokens.access_token, expiresAt });
139
+ client.setToken(tokens.access_token);
62
140
  return {
63
141
  status: 'AUTHENTICATED',
64
142
  note: 'Access token cached in ~/.reclaim/config.json and active now.',
@@ -67,42 +145,23 @@ async function runOAuthLoopback(client) {
67
145
  }
68
146
  /**
69
147
  * 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
148
+ * redirect. Resolves as soon as the port is known — the `url` promise settles
71
149
  * later, when the browser hits `/callback` — so the caller can register a
72
150
  * 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.
151
+ * The ephemeral port lets any number of CLIs run concurrently. Hands back the
152
+ * RAW callback URL rather than picking `code`/`state` apart itself —
153
+ * `oauth.authorizationCodeGrant` does that validation. Defined below its
154
+ * caller per repo convention.
75
155
  */
76
- async function listenForCallback(state) {
77
- let resolveCode;
78
- let rejectCode;
79
- const code = new Promise((resolve, reject) => {
80
- resolveCode = resolve;
81
- rejectCode = reject;
156
+ async function listenForCallback() {
157
+ let resolveUrl;
158
+ let rejectUrl;
159
+ const url = new Promise((resolve, reject) => {
160
+ resolveUrl = resolve;
161
+ rejectUrl = reject;
82
162
  });
83
163
  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
- });
164
+ const timer = setTimeout(() => rejectUrl(new Error('Timed out waiting for sign-in (5 min).')), CALLBACK_TIMEOUT_MS);
106
165
  const port = await new Promise((resolve, reject) => {
107
166
  server.once('error', reject);
108
167
  server.listen(0, '127.0.0.1', () => {
@@ -113,75 +172,38 @@ async function listenForCallback(state) {
113
172
  }
114
173
  resolve(addr.port);
115
174
  });
175
+ }).catch((err) => {
176
+ // Bind failed before `close()` (returned below) is ever reachable —
177
+ // clear the timer ourselves, or it fires 5 minutes later and rejects
178
+ // the now-unobserved `url` promise (unhandled rejection) while keeping
179
+ // the process alive in the meantime.
180
+ clearTimeout(timer);
181
+ throw err;
116
182
  });
117
183
  // Past bind, a socket error fails the pending wait rather than throwing.
118
- server.on('error', (err) => rejectCode(err));
184
+ server.on('error', (err) => rejectUrl(err));
185
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
186
+ server.on('request', (req, res) => {
187
+ const reqUrl = new URL(req.url ?? '/', redirectUri);
188
+ if (reqUrl.pathname !== '/callback') {
189
+ res.writeHead(404).end();
190
+ return;
191
+ }
192
+ res.writeHead(200, { 'content-type': 'text/html' });
193
+ res.end('<!doctype html><meta charset=utf-8><body style="font:16px '
194
+ + 'system-ui;padding:3rem;text-align:center">Signed in. You can '
195
+ + 'close this tab and return to your terminal.</body>');
196
+ resolveUrl(reqUrl);
197
+ });
119
198
  return {
120
- redirectUri: `http://127.0.0.1:${port}/callback`,
121
- code,
199
+ redirectUri,
200
+ url,
122
201
  close: () => {
123
202
  clearTimeout(timer);
124
203
  server.close();
125
204
  },
126
205
  };
127
206
  }
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
207
  function openBrowser(url) {
186
208
  const cmd = process.platform === 'darwin'
187
209
  ? '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({
@@ -7,7 +7,7 @@ import { type RegisteredTool } from '../../server.ts';
7
7
  * `~/.reclaim/config.json` and applied the NEXT time the server starts —
8
8
  * the registered tool set, server instructions, and backend client are all
9
9
  * fixed at connect time, so the switch requires a restart/reconnect. Follows
10
- * the `attach_chrome` persist-through-config pattern.
10
+ * the `attach_browser` persist-through-config pattern.
11
11
  *
12
12
  * Registered in BOTH modes (so the user can switch either direction) and
13
13
  * outside `buildAgentTools` (so it survives `RECLAIM_AGENT_DISABLED=1`). An
@@ -1,6 +1,18 @@
1
1
  import { defaultConfigPath } from "../../../config/store.js";
2
2
  import { oldModeSource } from "../../../consts.js";
3
3
  import { defineTool } from "../../server.js";
4
+ /** Whether `config`'s cached builder session token is present and not yet
5
+ * expired. Mirrors `readSession`'s check, but against the CALLER's
6
+ * `ConfigStore` instance (not always the default `~/.reclaim/config.json`
7
+ * path) so this stays test-isolatable and consistent with the rest of this
8
+ * tool's config reads. */
9
+ function hasValidBuilderSession(config) {
10
+ const session = config.read()?.session;
11
+ if (!session?.token || !session.expiresAt) {
12
+ return false;
13
+ }
14
+ return new Date(session.expiresAt) > new Date();
15
+ }
4
16
  /**
5
17
  * The `set_devtools_mode` / `get_devtools_mode` tools: persist which backend
6
18
  * the MCP server targets (`old` devtools vs the `builder`), and
@@ -8,7 +20,7 @@ import { defineTool } from "../../server.js";
8
20
  * `~/.reclaim/config.json` and applied the NEXT time the server starts —
9
21
  * the registered tool set, server instructions, and backend client are all
10
22
  * fixed at connect time, so the switch requires a restart/reconnect. Follows
11
- * the `attach_chrome` persist-through-config pattern.
23
+ * the `attach_browser` persist-through-config pattern.
12
24
  *
13
25
  * Registered in BOTH modes (so the user can switch either direction) and
14
26
  * outside `buildAgentTools` (so it survives `RECLAIM_AGENT_DISABLED=1`). An
@@ -49,20 +61,27 @@ export function buildModeTools(config, currentOldMode) {
49
61
  }),
50
62
  defineTool({
51
63
  name: 'get_devtools_mode',
52
- description: 'Report which backend this MCP server is currently targeting: '
53
- + '"old" (devtools) or "builder" (the builder '
54
- + 'backend). Also reports WHY that mode is in effect (source: '
55
- + 'a USE_OLD_DEVTOOLS env override, the persisted config, or '
56
- + 'the default) and which mode is persisted for the next '
57
- + 'start. Use set_devtools_mode to change it.',
64
+ description: 'Report which backend this server targets ("old" devtools or '
65
+ + '"builder"), WHY (a USE_OLD_DEVTOOLS env override, the persisted '
66
+ + 'config, or the default), and which mode is persisted for the '
67
+ + 'next start. Change it with set_devtools_mode. Also reports auth '
68
+ + 'status for both login systems: builderAuthenticated and '
69
+ + 'oldDevtoolsAuthenticated. Both are PRESENCE checks, not validity '
70
+ + 'checks — a stale token still reports true; a real call surfaces '
71
+ + 'the staleness with an error naming the tool to re-run. Which '
72
+ + 'login authorizes what: how_it_works({ topic: "auth" }).',
58
73
  inputSchema: { type: 'object', properties: {} },
59
74
  }, async () => {
60
75
  const persisted = config.read()?.mode;
76
+ const oldIdentity = config.read()?.old_devtools;
61
77
  return {
62
78
  mode: current,
63
79
  source: oldModeSource(persisted),
64
80
  persistedMode: persisted ?? null,
65
81
  configPath: defaultConfigPath(),
82
+ builderAuthenticated: hasValidBuilderSession(config),
83
+ oldDevtoolsAuthenticated: !!oldIdentity,
84
+ oldDevtoolsIdentityKind: oldIdentity?.kind ?? null,
66
85
  };
67
86
  }),
68
87
  ];
@@ -48,6 +48,15 @@ export interface RegisterProviderBody {
48
48
  * controller's destructure + its `providerConfig` write, both of which
49
49
  * include it alongside `pageTitle`/`userAgent`. */
50
50
  stepsToFollow?: string;
51
+ /** Interception mechanism (`NONE` = standard replay flow, the default;
52
+ * `HAWKEYE`/`MSWJS`/`XHOOK` = JS-context interception; `CDP` = browser-
53
+ * level). Column default is `HAWKEYE` when omitted entirely — unlike
54
+ * `stepsToFollow`, and so on. above, whether the register endpoint actually
55
+ * PERSISTS this field is UNVERIFIED (no source access to confirm); sent
56
+ * here on the chance it does, but the publish tool always follows up
57
+ * with a config (add-version) call regardless, which is confirmed to
58
+ * persist it. */
59
+ injectionType?: 'NONE' | 'MSWJS' | 'XHOOK' | 'CDP' | 'HAWKEYE';
51
60
  }
52
61
  export interface UpdateProviderConfigBody {
53
62
  providerConfig: Record<string, unknown>;
package/lib/old/client.js CHANGED
@@ -202,10 +202,28 @@ _ReclaimOldClient_identity = new WeakMap(), _ReclaimOldClient_fetch = new WeakMa
202
202
  return undefined;
203
203
  }
204
204
  }, _ReclaimOldClient_asBoom = function _ReclaimOldClient_asBoom(res, json) {
205
- const message = (json && typeof json === 'object' &&
205
+ const rawMessage = (json && typeof json === 'object' &&
206
206
  typeof json.message === 'string'
207
207
  ? json.message
208
208
  : undefined) || res.statusText || 'Old devtools request failed';
209
+ // An identity ALREADY SET (as opposed to `#requireIdentity`'s "never
210
+ // authenticated" case, which never reaches the backend) plus either a
211
+ // 401/403 OR a message that itself names token verification means the
212
+ // backend rejected a token we had — that is, it's stale/invalid, not merely
213
+ // absent. The message check matters because this backend does NOT
214
+ // reliably use 401/403 for it — confirmed live: an actually-expired
215
+ // Firebase token surfaced as "Error while verifying token" with a
216
+ // status this narrow check (401/403-only) missed entirely, silently
217
+ // skipping the enrichment. The backend's own message gives no hint of
218
+ // the fix, and nothing else distinguishes this from Builder auth, so
219
+ // append repo-side guidance rather than replacing the raw detail.
220
+ const staleAuth = !!__classPrivateFieldGet(this, _ReclaimOldClient_identity, "f") && (res.status === 401 || res.status === 403 ||
221
+ /verify.{0,20}token|token.{0,20}verif/i.test(rawMessage));
222
+ const message = staleAuth
223
+ ? `${rawMessage} — your old-devtools login looks stale or invalid `
224
+ + '(this is separate from Builder auth). Re-run reclaim_authenticate '
225
+ + 'to refresh it, then retry.'
226
+ : rawMessage;
209
227
  // Shape `data` like a ProblemDetails so remapErrorAsResponse can render
210
228
  // `${message}: ${data.detail}` and expose the raw body as structured.
211
229
  return new Boom(message, {
@@ -1 +1,9 @@
1
- export declare const OLD_MODE_INSTRUCTIONS: string;
1
+ /**
2
+ * MCP server instructions shown to the LLM when running in OLD-devtools mode
3
+ * (`USE_OLD_DEVTOOLS` unset or not `false`). Describes the REDUCED tool
4
+ * surface — there are no org/credential-link/review flows and no generated
5
+ * API tools here. Both modes' instructions load together in
6
+ * `mcp/instructions` (alongside the topic-split authoring guide); re-exported
7
+ * here so old-mode callers keep importing from their own module.
8
+ */
9
+ export { OLD_MODE_INSTRUCTIONS } from '../mcp/instructions.ts';
@@ -2,10 +2,8 @@
2
2
  * MCP server instructions shown to the LLM when running in OLD-devtools mode
3
3
  * (`USE_OLD_DEVTOOLS` unset or not `false`). Describes the REDUCED tool
4
4
  * surface — there are no org/credential-link/review flows and no generated
5
- * API tools here. The prose lives in `skill/old-mode.md` (published via the
6
- * `files` array); the path resolves identically from `src/old` (dev) and
7
- * `lib/old` (published).
5
+ * API tools here. Both modes' instructions load together in
6
+ * `mcp/instructions` (alongside the topic-split authoring guide); re-exported
7
+ * here so old-mode callers keep importing from their own module.
8
8
  */
9
- import { readFileSync } from 'node:fs';
10
- import { fileURLToPath } from 'node:url';
11
- export const OLD_MODE_INSTRUCTIONS = readFileSync(fileURLToPath(new URL('../../skill/old-mode.md', import.meta.url)), 'utf8').trim();
9
+ export { OLD_MODE_INSTRUCTIONS } from "../mcp/instructions.js";
@@ -34,8 +34,8 @@ export interface CdpLoginResult {
34
34
  * finish signing in, read the resulting Firebase ID token from page storage,
35
35
  * then clean up. If we had to launch a dedicated Chrome for this, we close the
36
36
  * whole browser afterwards; if we attached to a Chrome that was already up
37
- * (e.g. the capture flow's), we only close the tab we opened so we don't
38
- * disrupt it.
37
+ * (for example, the capture flow's), we only close the tab we opened so we
38
+ * don't disrupt it.
39
39
  */
40
40
  export declare function captureFirebaseTokenViaCdp(opts: {
41
41
  loginUrl?: string;
@@ -83,12 +83,18 @@ export const EXTRACT_TOKEN_JS = `(async () => {
83
83
  * finish signing in, read the resulting Firebase ID token from page storage,
84
84
  * then clean up. If we had to launch a dedicated Chrome for this, we close the
85
85
  * whole browser afterwards; if we attached to a Chrome that was already up
86
- * (e.g. the capture flow's), we only close the tab we opened so we don't
87
- * disrupt it.
86
+ * (for example, the capture flow's), we only close the tab we opened so we
87
+ * don't disrupt it.
88
88
  */
89
89
  export async function captureFirebaseTokenViaCdp(opts) {
90
90
  const loginUrl = opts.loginUrl || oldLoginUrl() || DEFAULT_LOGIN_URL;
91
- const timeoutMs = opts.timeoutMs ?? 180000;
91
+ // 6 minutes, not 3. On a FRESH Chrome profile the dashboard is a cold load
92
+ // of an ~8.6 MB uncompressed bundle before the login form is even
93
+ // interactive — tens of seconds on a slow link — and only then does the
94
+ // developer start typing credentials and a 2FA code. The old 3-minute
95
+ // budget was routinely spent before they could finish, and the retry that
96
+ // followed looked like a 6-minute hang with no output.
97
+ const timeoutMs = opts.timeoutMs ?? 360000;
92
98
  const pollMs = opts.pollMs ?? 1000;
93
99
  const cfg = new ConfigStore().read()?.browser_agent;
94
100
  const mode = cfg?.attachMode ?? 'dedicated';
@@ -102,7 +108,7 @@ export async function captureFirebaseTokenViaCdp(opts) {
102
108
  if (mode !== 'dedicated') {
103
109
  throw new Error(`attach mode: nothing answering CDP on port ${port}. Launch Chrome ` +
104
110
  `with --remote-debugging-port=${port} AND a non-default ` +
105
- '--user-data-dir, or switch to dedicated mode (attach_chrome).');
111
+ '--user-data-dir, or switch to dedicated mode (attach_browser).');
106
112
  }
107
113
  launchChrome({ port, profileDir: defaultProfileDir() });
108
114
  await waitForCdpReady(port, 10000);
@@ -114,6 +120,10 @@ export async function captureFirebaseTokenViaCdp(opts) {
114
120
  try {
115
121
  const session = await handles.attachToTab(targetId);
116
122
  const deadline = Date.now() + timeoutMs;
123
+ // Tracks how far the page got, so a timeout can say WHICH thing stalled:
124
+ // a bundle that never finished downloading is a different problem from a
125
+ // developer who walked away mid-sign-in.
126
+ let sawInteractivePage = false;
117
127
  while (Date.now() < deadline) {
118
128
  const result = await session.send('Runtime.evaluate', {
119
129
  expression: EXTRACT_TOKEN_JS,
@@ -124,10 +134,21 @@ export async function captureFirebaseTokenViaCdp(opts) {
124
134
  if (typeof value === 'string' && value.length > 0) {
125
135
  return { token: value, closedBrowser: launchedByUs };
126
136
  }
137
+ if (!sawInteractivePage) {
138
+ sawInteractivePage = await pageIsInteractive(session);
139
+ }
127
140
  await new Promise((resolve) => setTimeout(resolve, pollMs));
128
141
  }
129
- throw new Error('Timed out waiting for login. Finish signing in at the opened ' +
130
- 'page, or re-run reclaim_authenticate with a longer timeoutMs.');
142
+ const waited = Math.round(timeoutMs / 1000);
143
+ throw new Error(`Timed out after ${waited}s waiting for login. `
144
+ + (sawInteractivePage
145
+ ? 'The page finished loading, so sign-in was never completed — '
146
+ + 'ask the developer to finish signing in at the opened window, '
147
+ + 'then re-run reclaim_authenticate.'
148
+ : 'The page never became interactive, so the dashboard was still '
149
+ + 'loading the whole time. On a fresh Chrome profile its bundle '
150
+ + 'is a large cold download. Retry (the profile is now warm), or '
151
+ + 'pass a bigger timeoutMs.'));
131
152
  }
132
153
  finally {
133
154
  // Best-effort cleanup — never mask the real result/error.
@@ -145,6 +166,25 @@ export async function captureFirebaseTokenViaCdp(opts) {
145
166
  await handles.close().catch(() => { });
146
167
  }
147
168
  }
169
+ /**
170
+ * Whether the login page has reached a usable state. `readyState` alone is not
171
+ * enough for a single-bundle SPA: the document is 'complete' while the app is
172
+ * still parsing, and nothing is clickable yet. Requiring a rendered root as
173
+ * well is what distinguishes "still loading" from "waiting for the human".
174
+ */
175
+ async function pageIsInteractive(session) {
176
+ try {
177
+ const result = await session.send('Runtime.evaluate', {
178
+ expression: 'document.readyState === "complete" '
179
+ + '&& !!document.body && document.body.innerText.trim().length > 0',
180
+ returnByValue: true,
181
+ });
182
+ return runtimeResultValue(result) === true;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
148
188
  function runtimeResultValue(value) {
149
189
  if (typeof value !== 'object' || !value || !('result' in value)) {
150
190
  return undefined;