@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
@@ -23,18 +23,41 @@ export function proposeTool(resolveBackend: ResolveBackend): RegisteredTool {
23
23
  + 'one match + redaction per target. Returns a draftId + recipe '
24
24
  + '(never secrets). Prefer a SINGLE request holding ALL values. '
25
25
  + 'Single-value shorthand: `target` + `name`. Per-target `hash` '
26
- + 'OPRF-hashes that value.',
26
+ + 'OPRF-hashes that value. BY DEFAULT each `name` is shortened to '
27
+ + 'its last alphanumeric token, lowercased, to form the param key '
28
+ + '(e.g. "employer_name" → "name") — pick names that stay distinct '
29
+ + 'after shortening ("firstName"/"surname", not "first_name"/'
30
+ + '"last_name"). Pass `shortenNames: false` to use each `name` '
31
+ + 'VERBATIM as the param key instead — it becomes both a regex '
32
+ + 'capture-group name and a paramValues key, so it must be a plain '
33
+ + 'identifier (letters/digits/underscore, not starting with a '
34
+ + 'digit); we recommend lower_snake_case (e.g. "employer_name"), '
35
+ + 'but you choose the exact name.',
27
36
  inputSchema: objectSchema(
28
37
  {
29
38
  requestId: { type: 'string' },
30
39
  name: { type: 'string' },
40
+ shortenNames: {
41
+ type: 'boolean',
42
+ description:
43
+ 'Default true: shorten each target `name` to its last '
44
+ + 'alphanumeric token, lowercased, to form the param key. '
45
+ + 'Set false to use `name` verbatim instead (must be a plain '
46
+ + 'identifier — lower_snake_case recommended).',
47
+ },
31
48
  targets: {
32
49
  type: 'array',
33
50
  items: {
34
51
  type: 'object',
35
52
  properties: {
36
53
  value: { type: 'string' },
37
- name: { type: 'string' },
54
+ name: {
55
+ type: 'string',
56
+ description:
57
+ 'The param key, or the source of one — see '
58
+ + '`shortenNames`. Must stay distinct from other targets\' '
59
+ + 'names after whichever treatment applies.',
60
+ },
38
61
  hash: {
39
62
  type: 'string',
40
63
  enum: ['oprf-raw', 'oprf', 'oprf-mpc'],
@@ -54,6 +77,9 @@ export function proposeTool(resolveBackend: ResolveBackend): RegisteredTool {
54
77
  const requestId = String(args.requestId)
55
78
  const providerName = String(args.name)
56
79
  const defaultHash = args.hash as OprfMode | undefined
80
+ const shortenNames = typeof args.shortenNames === 'boolean'
81
+ ? args.shortenNames
82
+ : true
57
83
 
58
84
  // Explicit multi-value `targets`, else the single `target` shorthand.
59
85
  const raw = Array.isArray(args.targets) ? args.targets : []
@@ -103,7 +129,7 @@ export function proposeTool(resolveBackend: ResolveBackend): RegisteredTool {
103
129
  }
104
130
 
105
131
  const { provider, secretRefs, extractions } = draftProvider(
106
- captured, targets, providerName, defaultHash,
132
+ captured, targets, providerName, defaultHash, { shortenNames },
107
133
  )
108
134
  // Materialize the real secret header values for later replay/prove;
109
135
  // they stay in the server-side draft, never in this result.
@@ -136,7 +136,6 @@ interface PublishArgs {
136
136
  allowedJsRequests?: AllowedJsRequest[]
137
137
  interceptorOptions?: {
138
138
  interceptorType: 'HAWKEYE' | 'MSWJS' | 'CDP'
139
- isDocumentRequestReplayEnabled?: boolean
140
139
  interceptorSettings?: string
141
140
  }
142
141
  }
@@ -268,17 +267,19 @@ export function createVersionFromCaptureTool(
268
267
  type: 'object',
269
268
  description:
270
269
  'In-app interception settings, stored as ' +
271
- 'webSettings.clientOptions.inapp.interceptorOptions. ' +
272
- '`isDocumentRequestReplayEnabled` is the INVERSE of ' +
273
- 'old-devtools\' `disableRequestReplay`: true replays the ' +
274
- 'main document request via the attestor (default false).',
270
+ 'webSettings.clientOptions.inapp.interceptorOptions — only ' +
271
+ 'needed for a genuine live-interception provider (a real ' +
272
+ 'HAWKEYE/MSWJS script, not the standard capture→replay flow). ' +
273
+ 'Document replay (webSettings.clientOptions.inapp' +
274
+ '.interceptorOptions.isDocumentRequestReplayEnabled) is ' +
275
+ 'ALWAYS published disabled regardless of this field — there ' +
276
+ 'is no way to enable it here.',
275
277
  properties: {
276
278
  interceptorType: {
277
279
  type: 'string',
278
280
  enum: ['HAWKEYE', 'MSWJS', 'CDP'],
279
281
  description: 'How traffic interception is implemented.',
280
282
  },
281
- isDocumentRequestReplayEnabled: { type: 'boolean' },
282
283
  interceptorSettings: {
283
284
  type: 'string',
284
285
  description:
@@ -13,7 +13,7 @@ import {
13
13
  extendTool,
14
14
  } from '../../../authoring/index.ts'
15
15
  import { runAttestorProof } from '../../../proof/attestor.ts'
16
- import { replayProvider } from '../../../proof/replay.ts'
16
+ import { replayProviderInPage } from '../../../proof/page-replay.ts'
17
17
  import type { OprfMode, ReclaimProvider } from '../../../provider/schema.ts'
18
18
  import type { RegisteredTool } from '../../server.ts'
19
19
  import type { CaptureEntry, CaptureStore } from './capture.ts'
@@ -51,14 +51,18 @@ function withLocalOprfMode(provider: ReclaimProvider): ReclaimProvider {
51
51
  }
52
52
 
53
53
  /** The local backend for one capture: drive its tab over CDP, read its
54
- * captured requests, prove via the in-process attestor, replay by re-fetch. */
54
+ * captured requests, prove via the in-process attestor, replay by re-fetching
55
+ * FROM INSIDE the tab (real cookies/session — see proof/page-replay.ts). */
55
56
  function localBackend(entry: CaptureEntry): AuthoringBackend {
56
57
  const { tabClient, session, drafts } = entry
58
+ const sendCdp = (method: string, params?: Record<string, unknown>) => {
59
+ return tabClient.send(method, params ?? {})
60
+ }
57
61
 
58
62
  return {
59
63
  capture: session,
60
64
  drafts,
61
- sendCdp: (method, params) => tabClient.send(method, params ?? {}),
65
+ sendCdp,
62
66
  waitForEvent: (method, timeoutMs) => new Promise((resolve) => {
63
67
  let stopListening = () => {}
64
68
 
@@ -79,10 +83,11 @@ function localBackend(entry: CaptureEntry): AuthoringBackend {
79
83
  stopListening = tabClient.subscribeOnce(method, onEvent)
80
84
  }),
81
85
  replay: async(draft, opts) => {
82
- // Auth-bound check: re-fetch with the secrets dropped. replayProvider
83
- // already does a live Node fetch, so empty secrets == anonymous.
84
- const secrets = opts?.withoutSecrets ? {} : draft.secrets
85
- const result = await replayProvider(draft.provider, secrets)
86
+ // Replayed FROM INSIDE the attached tab (real cookies/session), not
87
+ // a detached Node fetch — see proof/page-replay.ts.
88
+ const result = await replayProviderInPage(
89
+ { sendCdp }, draft.provider, draft.secrets, opts,
90
+ )
86
91
  return {
87
92
  status: result.status,
88
93
  matchesOriginal: result.matchesOriginal,
@@ -127,7 +132,13 @@ const RUN_PROOF_EXTRAS = {
127
132
  + 'specific alternative — do NOT invent backup URLs. `ownerAddress` '
128
133
  + '(the credential to sign with) is REQUIRED — call resolve_owner_key '
129
134
  + 'first. errorKind="missing-owner-key" means that address has no known '
130
- + 'private key; re-run resolve_owner_key or issue/import a credential.',
135
+ + 'private key; re-run resolve_owner_key or issue/import a credential. '
136
+ + 'NOTE: unlike replay_request (which re-issues from inside the live '
137
+ + 'attached browser tab), the ATTESTOR calls the target from its OWN '
138
+ + 'network/IP, separate from the browser\'s. A request that replayed '
139
+ + 'fine can still fail here for geo/IP-bound content (country-locked '
140
+ + "pages, IP-allowlisted APIs) — if that happens, it's a property of "
141
+ + 'the site, not a bug in the draft.',
131
142
  properties: {
132
143
  ownerAddress: {
133
144
  type: 'string',
@@ -1,8 +1,7 @@
1
1
  import type { ReclaimClient } from '@reclaimprotocol/client'
2
- import assert from 'node:assert'
3
2
  import { spawn } from 'node:child_process'
4
- import { createHash, randomBytes } from 'node:crypto'
5
3
  import { createServer } from 'node:http'
4
+ import * as oauth from 'openid-client'
6
5
  import { defineTool, type RegisteredTool } from '../../server.ts'
7
6
  import { writeSession } from './session-cache.ts'
8
7
 
@@ -19,9 +18,17 @@ export function authenticateTool(
19
18
  name,
20
19
  description:
21
20
  (name === 'authenticate_builder'
22
- ? 'Authenticate specifically with Builder. Available regardless '
23
- + 'of '
24
- + 'the selected publishing backend. '
21
+ ? 'This tool only exists in OLD-DEVTOOLS mode — Builder mode has '
22
+ + 'no separate version of it; its own reclaim_authenticate '
23
+ + 'already covers everything below. In old-devtools mode, this '
24
+ + 'authenticates with Builder for exactly two Builder-exclusive '
25
+ + 'features: allocating a Popcorn remote browser (attach_browser '
26
+ + 'mode="builder") and listing Builder organizations '
27
+ + '(list_builder_organizations — old-devtools has no generated '
28
+ + 'org tool of its own). It does NOT authorize '
29
+ + 'create_provider_version_from_capture or get_me_providers, '
30
+ + 'which need their OWN, separate login via the old-devtools '
31
+ + 'reclaim_authenticate tool. Check both with get_devtools_mode. '
25
32
  : '')
26
33
  + 'Authorize the CLI / MCP to make authenticated API calls. Opens '
27
34
  + 'the '
@@ -39,43 +46,117 @@ export function authenticateTool(
39
46
  )
40
47
  }
41
48
 
42
- async function runOAuthLoopback(client: ReclaimClient): Promise<unknown> {
49
+ const _customFetchForOauth: oauth.CustomFetch = async(
50
+ url: string,
51
+ options: oauth.CustomFetchOptions,
52
+ ) => {
53
+ const res = await fetch(url, options as RequestInit)
54
+ if(
55
+ options.method === 'POST' && res.status === 200 &&
56
+ new URL(url).pathname.endsWith('/oauth2/register')
57
+ ) {
58
+ return new Response(await res.text(), {
59
+ status: 201,
60
+ headers: res.headers,
61
+ })
62
+ }
63
+
64
+ return res
65
+ }
66
+
67
+ /**
68
+ * Standard OAuth 2.0 Authorization Code + PKCE, native-app style (RFC 8252):
69
+ * dynamically register a public client (RFC 7591 — Better Auth's
70
+ * `oauth-provider` plugin issues no secret for it), send the user to the
71
+ * browser, and catch the redirect on a loopback server. `openid-client`
72
+ * handles PKCE, `state` CSRF protection, and authorization-response
73
+ * validation (including a denied/failed login surfacing as
74
+ * `AuthorizationResponseError`) — this function only wires those calls
75
+ * together and owns the loopback server (no library spins that up for you).
76
+ */
77
+ export async function runOAuthLoopback(
78
+ client: ReclaimClient,
79
+ // Overridable only so tests can intercept the authorize URL instead of
80
+ // actually spawning a browser; every real caller uses the default.
81
+ openBrowserFn: (url: string) => void = openBrowser,
82
+ ): Promise<unknown> {
43
83
  const authBase = `${client.baseUrl}/auth`
44
- const verifier = base64url(randomBytes(32))
45
- const challenge = base64url(createHash('sha256').update(verifier).digest())
46
- const state = base64url(randomBytes(16))
84
+ const codeVerifier = oauth.randomPKCECodeVerifier()
85
+ const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier)
86
+ const state = oauth.randomState()
47
87
 
48
88
  // Bind the callback port first: the client is registered for this exact
49
- // loopback redirect, and the same client_id has to be presented again at the
50
- // token endpoint (public clients authenticate by client_id alone).
51
- const callback = await listenForCallback(state)
52
- let token: { access_token: string, expires_in: number }
89
+ // loopback redirect.
90
+ const callback = await listenForCallback()
91
+ let tokens: oauth.TokenEndpointResponse
53
92
  try {
54
- const clientId = await registerClient(authBase, callback.redirectUri)
55
- openBrowser(
56
- authorizeUrl(authBase, {
57
- clientId,
58
- redirectUri: callback.redirectUri,
59
- challenge,
93
+ const config = await oauth.dynamicClientRegistration(
94
+ new URL(authBase),
95
+ {
96
+ client_name: 'Reclaim CLI',
97
+ redirect_uris: [callback.redirectUri],
98
+ token_endpoint_auth_method: 'none',
99
+ grant_types: ['authorization_code', 'refresh_token'],
100
+ response_types: ['code'],
101
+ scope: SCOPE,
102
+ },
103
+ undefined,
104
+ {
105
+ // Better Auth exposes its metadata at the RFC 8414 well-known path
106
+ // (`.well-known/oauth-authorization-server`), not the OIDC one.
107
+ algorithm: 'oauth2',
108
+ // RFC 7591 §3.2.1 requires 201 Created on a successful registration;
109
+ // openid-client enforces that strictly. Better Auth's
110
+ // `oauth-provider` plugin doesn't conform — confirmed live against
111
+ // the real server: `POST .../oauth2/register` returns 200 with an
112
+ // otherwise well-formed body, which openid-client rejects outright
113
+ // ("unexpected HTTP response status code") before ever looking at
114
+ // it. `customFetch` set here persists onto the returned
115
+ // `Configuration` for ALL its future calls too (token exchange
116
+ // included, which correctly returns 200) — so the URL itself, not
117
+ // just method+status, must gate the rewrite, or a genuine 200 from
118
+ // the token endpoint gets corrupted into a 201 and
119
+ // `authorizationCodeGrant` fails instead (confirmed by hitting
120
+ // exactly that when this only checked method === 'POST').
121
+ [oauth.customFetch]: _customFetchForOauth,
122
+ },
123
+ )
124
+ openBrowserFn(
125
+ oauth.buildAuthorizationUrl(config, {
126
+ redirect_uri: callback.redirectUri,
127
+ scope: SCOPE,
60
128
  state,
61
- }),
129
+ code_challenge: codeChallenge,
130
+ code_challenge_method: 'S256',
131
+ }).href,
62
132
  )
63
- const code = await callback.code
64
- token = await exchangeCode(authBase, {
65
- code,
66
- verifier,
67
- redirectUri: callback.redirectUri,
68
- clientId,
133
+ const callbackUrl = await callback.url
134
+ tokens = await oauth.authorizationCodeGrant(config, callbackUrl, {
135
+ pkceCodeVerifier: codeVerifier,
136
+ expectedState: state,
69
137
  })
138
+ } catch(err) {
139
+ // A denied or failed authorization surfaces as this error class with
140
+ // the raw callback params on `.cause` — reword it like the previous
141
+ // hand-rolled check did (RFC 6749 §4.1.2.1's error/error_description).
142
+ if(err instanceof oauth.AuthorizationResponseError) {
143
+ throw new Error(
144
+ err.cause.get('error_description')
145
+ ?? err.cause.get('error')
146
+ ?? 'Sign-in failed or was cancelled.',
147
+ )
148
+ }
149
+
150
+ throw err
70
151
  } finally {
71
152
  callback.close()
72
153
  }
73
154
 
74
155
  const expiresAt = new Date(
75
- Date.now() + token.expires_in * 1000,
156
+ Date.now() + (tokens.expires_in ?? 3600) * 1000,
76
157
  ).toISOString()
77
- writeSession({ token: token.access_token, expiresAt })
78
- client.setToken(token.access_token)
158
+ writeSession({ token: tokens.access_token, expiresAt })
159
+ client.setToken(tokens.access_token)
79
160
 
80
161
  return {
81
162
  status: 'AUTHENTICATED',
@@ -86,57 +167,32 @@ async function runOAuthLoopback(client: ReclaimClient): Promise<unknown> {
86
167
 
87
168
  /**
88
169
  * Bind an ephemeral loopback port and start listening for the provider's
89
- * redirect. Resolves as soon as the port is known — the `code` promise settles
170
+ * redirect. Resolves as soon as the port is known — the `url` promise settles
90
171
  * later, when the browser hits `/callback` — so the caller can register a
91
172
  * client for this exact redirect URI before sending the user off to authorize.
92
- * The ephemeral port lets any number of CLIs run concurrently. Defined below
93
- * its caller per repo convention.
173
+ * The ephemeral port lets any number of CLIs run concurrently. Hands back the
174
+ * RAW callback URL rather than picking `code`/`state` apart itself —
175
+ * `oauth.authorizationCodeGrant` does that validation. Defined below its
176
+ * caller per repo convention.
94
177
  */
95
- async function listenForCallback(state: string): Promise<{
178
+ async function listenForCallback(): Promise<{
96
179
  redirectUri: string
97
- code: Promise<string>
180
+ url: Promise<URL>
98
181
  close: () => void
99
182
  }> {
100
- let resolveCode: (code: string) => void
101
- let rejectCode: (err: Error) => void
102
- const code = new Promise<string>((resolve, reject) => {
103
- resolveCode = resolve
104
- rejectCode = reject
183
+ let resolveUrl: (url: URL) => void
184
+ let rejectUrl: (err: Error) => void
185
+ const url = new Promise<URL>((resolve, reject) => {
186
+ resolveUrl = resolve
187
+ rejectUrl = reject
105
188
  })
106
189
 
107
190
  const server = createServer()
108
191
  const timer = setTimeout(
109
- () => rejectCode(new Error('Timed out waiting for sign-in (5 min).')),
192
+ () => rejectUrl(new Error('Timed out waiting for sign-in (5 min).')),
110
193
  CALLBACK_TIMEOUT_MS,
111
194
  )
112
195
 
113
- server.on('request', (req, res) => {
114
- const url = new URL(req.url ?? '/', 'http://127.0.0.1')
115
- if(url.pathname !== '/callback') {
116
- res.writeHead(404).end()
117
- return
118
- }
119
-
120
- res.writeHead(200, { 'content-type': 'text/html' })
121
- res.end(
122
- '<!doctype html><meta charset=utf-8><body style="font:16px '
123
- + 'system-ui;padding:3rem;text-align:center">Signed in. You can '
124
- + 'close this tab and return to your terminal.</body>',
125
- )
126
-
127
- const authCode = url.searchParams.get('code')
128
- if(!authCode || url.searchParams.get('state') !== state) {
129
- // A denied or failed authorization comes back as `error`/
130
- // `error_description` (RFC 6749 §4.1.2.1) — surface it verbatim.
131
- const err = url.searchParams.get('error_description')
132
- ?? url.searchParams.get('error')
133
- rejectCode(new Error(err ?? 'Sign-in failed or was cancelled.'))
134
- return
135
- }
136
-
137
- resolveCode(authCode)
138
- })
139
-
140
196
  const port = await new Promise<number>((resolve, reject) => {
141
197
  server.once('error', reject)
142
198
  server.listen(0, '127.0.0.1', () => {
@@ -148,13 +204,37 @@ async function listenForCallback(state: string): Promise<{
148
204
 
149
205
  resolve(addr.port)
150
206
  })
207
+ }).catch((err) => {
208
+ // Bind failed before `close()` (returned below) is ever reachable —
209
+ // clear the timer ourselves, or it fires 5 minutes later and rejects
210
+ // the now-unobserved `url` promise (unhandled rejection) while keeping
211
+ // the process alive in the meantime.
212
+ clearTimeout(timer)
213
+ throw err
151
214
  })
152
215
  // Past bind, a socket error fails the pending wait rather than throwing.
153
- server.on('error', (err) => rejectCode(err))
216
+ server.on('error', (err) => rejectUrl(err))
217
+
218
+ const redirectUri = `http://127.0.0.1:${port}/callback`
219
+ server.on('request', (req, res) => {
220
+ const reqUrl = new URL(req.url ?? '/', redirectUri)
221
+ if(reqUrl.pathname !== '/callback') {
222
+ res.writeHead(404).end()
223
+ return
224
+ }
225
+
226
+ res.writeHead(200, { 'content-type': 'text/html' })
227
+ res.end(
228
+ '<!doctype html><meta charset=utf-8><body style="font:16px '
229
+ + 'system-ui;padding:3rem;text-align:center">Signed in. You can '
230
+ + 'close this tab and return to your terminal.</body>',
231
+ )
232
+ resolveUrl(reqUrl)
233
+ })
154
234
 
155
235
  return {
156
- redirectUri: `http://127.0.0.1:${port}/callback`,
157
- code,
236
+ redirectUri,
237
+ url,
158
238
  close: () => {
159
239
  clearTimeout(timer)
160
240
  server.close()
@@ -162,99 +242,6 @@ async function listenForCallback(state: string): Promise<{
162
242
  }
163
243
  }
164
244
 
165
- function authorizeUrl(
166
- authBase: string,
167
- opts: {
168
- clientId: string
169
- redirectUri: string
170
- challenge: string
171
- state: string
172
- },
173
- ): string {
174
- return `${authBase}/oauth2/authorize?`
175
- + new URLSearchParams({
176
- response_type: 'code',
177
- client_id: opts.clientId,
178
- redirect_uri: opts.redirectUri,
179
- scope: SCOPE,
180
- state: opts.state,
181
- code_challenge: opts.challenge,
182
- code_challenge_method: 'S256',
183
- }).toString()
184
- }
185
-
186
- /** Dynamic client registration (RFC 7591) for a public PKCE client. */
187
- async function registerClient(
188
- authBase: string,
189
- redirectUri: string,
190
- ): Promise<string> {
191
- const res = await fetch(`${authBase}/oauth2/register`, {
192
- method: 'POST',
193
- headers: { 'content-type': 'application/json' },
194
- body: JSON.stringify({
195
- client_name: 'Reclaim CLI',
196
- redirect_uris: [redirectUri],
197
- token_endpoint_auth_method: 'none',
198
- grant_types: ['authorization_code', 'refresh_token'],
199
- response_types: ['code'],
200
- scope: SCOPE,
201
- }),
202
- })
203
- assert(res.ok, new Error(`Client registration failed (${res.status}).`))
204
-
205
- const json = (await res.json()) as { client_id?: string }
206
- assert(
207
- json.client_id,
208
- new Error('Client registration returned no client_id.'),
209
- )
210
-
211
- return json.client_id
212
- }
213
-
214
- async function exchangeCode(
215
- authBase: string,
216
- opts: {
217
- code: string
218
- verifier: string
219
- redirectUri: string
220
- clientId: string
221
- },
222
- ): Promise<{ access_token: string, expires_in: number }> {
223
- const res = await fetch(`${authBase}/oauth2/token`, {
224
- method: 'POST',
225
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
226
- body: new URLSearchParams({
227
- grant_type: 'authorization_code',
228
- code: opts.code,
229
- redirect_uri: opts.redirectUri,
230
- code_verifier: opts.verifier,
231
- // Required even though this is a public client with no secret: it's
232
- // how the token endpoint identifies the client (RFC 6749 §4.1.3).
233
- client_id: opts.clientId,
234
- }).toString(),
235
- })
236
- const body = await res.text()
237
- assert(res.ok, new Error(`Token exchange failed (${res.status}): ${body}`))
238
-
239
- const json = JSON.parse(body) as {
240
- access_token?: string
241
- expires_in?: number
242
- }
243
- assert(
244
- json.access_token,
245
- new Error('Token exchange returned no access_token.'),
246
- )
247
-
248
- return {
249
- access_token: json.access_token,
250
- expires_in: json.expires_in ?? 3600,
251
- }
252
- }
253
-
254
- function base64url(buf: Buffer): string {
255
- return buf.toString('base64url')
256
- }
257
-
258
245
  function openBrowser(url: string) {
259
246
  const cmd = process.platform === 'darwin'
260
247
  ? 'open'
@@ -2,8 +2,11 @@ import type { ReclaimClient } from '@reclaimprotocol/client'
2
2
  import { defineTool, type RegisteredTool } from '../../server.ts'
3
3
  import { authenticateTool } from './authenticate.ts'
4
4
 
5
- /** Legacy-devtools mode needs a distinct Builder login solely for allocating
6
- * quota-accounted Popcorn sessions. Builder mode uses its native auth tool. */
5
+ /** Legacy-devtools mode needs a distinct Builder login for its two
6
+ * Builder-exclusive features: allocating quota-accounted Popcorn sessions
7
+ * (see `buildBuilderOrganizationTools` below) and listing Builder
8
+ * organizations. Builder mode needs neither — its native auth tool alone
9
+ * covers both, org listing included (via its generated `list_orgs`). */
7
10
  export function buildBuilderAuthenticationTools(
8
11
  client: ReclaimClient,
9
12
  ): RegisteredTool[] {
@@ -11,7 +14,9 @@ export function buildBuilderAuthenticationTools(
11
14
  }
12
15
 
13
16
  /** Legacy-devtools mode lacks generated Builder API tools, so it needs this
14
- * narrow organization-discovery bridge for remote-browser allocation. */
17
+ * narrow organization-discovery bridge — an org id is required to allocate a
18
+ * remote-browser (Popcorn) session. Builder mode's own `list_orgs` already
19
+ * covers this under its native auth. */
15
20
  export function buildBuilderOrganizationTools(
16
21
  client: ReclaimClient,
17
22
  ): RegisteredTool[] {
@@ -3,6 +3,20 @@ import { defaultConfigPath } from '../../../config/store.ts'
3
3
  import { oldModeSource } from '../../../consts.ts'
4
4
  import { defineTool, type RegisteredTool } from '../../server.ts'
5
5
 
6
+ /** Whether `config`'s cached builder session token is present and not yet
7
+ * expired. Mirrors `readSession`'s check, but against the CALLER's
8
+ * `ConfigStore` instance (not always the default `~/.reclaim/config.json`
9
+ * path) so this stays test-isolatable and consistent with the rest of this
10
+ * tool's config reads. */
11
+ function hasValidBuilderSession(config: ConfigStore): boolean {
12
+ const session = config.read()?.session
13
+ if(!session?.token || !session.expiresAt) {
14
+ return false
15
+ }
16
+
17
+ return new Date(session.expiresAt) > new Date()
18
+ }
19
+
6
20
  /**
7
21
  * The `set_devtools_mode` / `get_devtools_mode` tools: persist which backend
8
22
  * the MCP server targets (`old` devtools vs the `builder`), and
@@ -65,16 +79,35 @@ export function buildModeTools(
65
79
  + 'backend). Also reports WHY that mode is in effect (source: '
66
80
  + 'a USE_OLD_DEVTOOLS env override, the persisted config, or '
67
81
  + 'the default) and which mode is persisted for the next '
68
- + 'start. Use set_devtools_mode to change it.',
82
+ + 'start. Use set_devtools_mode to change it. Also reports '
83
+ + 'AUTH status for BOTH auth systems — builderAuthenticated (the '
84
+ + 'Builder OAuth login). In BUILDER mode this is the only login '
85
+ + 'that exists and covers everything, including org listing '
86
+ + '(list_orgs) and remote-browser allocation. In OLD-DEVTOOLS '
87
+ + 'mode it is a SEPARATE, additional login (authenticate_builder) '
88
+ + 'needed only for two Builder-exclusive features: remote-browser '
89
+ + '(Popcorn) allocation and org listing (list_builder_organizations '
90
+ + '— old-devtools has no generated org tool of its own). '
91
+ + 'oldDevtoolsAuthenticated (the OLD-mode reclaim_authenticate) is '
92
+ + 'required separately for create_provider_version_from_capture '
93
+ + 'and get_me_providers, and only exists/matters in old-devtools '
94
+ + 'mode. Both are presence checks, not '
95
+ + 'validity checks — a stale old-devtools token still reports '
96
+ + 'true here; a real publish/list call will surface staleness '
97
+ + 'with a clear error pointing back at reclaim_authenticate.',
69
98
  inputSchema: { type: 'object', properties: {} },
70
99
  },
71
100
  async() => {
72
101
  const persisted = config.read()?.mode
102
+ const oldIdentity = config.read()?.old_devtools
73
103
  return {
74
104
  mode: current,
75
105
  source: oldModeSource(persisted),
76
106
  persistedMode: persisted ?? null,
77
107
  configPath: defaultConfigPath(),
108
+ builderAuthenticated: hasValidBuilderSession(config),
109
+ oldDevtoolsAuthenticated: !!oldIdentity,
110
+ oldDevtoolsIdentityKind: oldIdentity?.kind ?? null,
78
111
  }
79
112
  },
80
113
  ),
package/src/old/client.ts CHANGED
@@ -47,6 +47,15 @@ export interface RegisterProviderBody {
47
47
  * controller's destructure + its `providerConfig` write, both of which
48
48
  * include it alongside `pageTitle`/`userAgent`. */
49
49
  stepsToFollow?: string
50
+ /** Interception mechanism (`NONE` = standard replay flow, the default;
51
+ * `HAWKEYE`/`MSWJS`/`XHOOK` = JS-context interception; `CDP` = browser-
52
+ * level). Column default is `HAWKEYE` when omitted entirely — unlike
53
+ * `stepsToFollow` etc. above, whether the register endpoint actually
54
+ * PERSISTS this field is UNVERIFIED (no source access to confirm); sent
55
+ * here on the chance it does, but the publish tool always follows up
56
+ * with a config (add-version) call regardless, which is confirmed to
57
+ * persist it. */
58
+ injectionType?: 'NONE' | 'MSWJS' | 'XHOOK' | 'CDP' | 'HAWKEYE'
50
59
  }
51
60
 
52
61
  export interface UpdateProviderConfigBody {
@@ -353,11 +362,31 @@ export class ReclaimOldClient {
353
362
  }
354
363
 
355
364
  #asBoom(res: Response, json: unknown): Boom {
356
- const message =
365
+ const rawMessage =
357
366
  (json && typeof json === 'object' &&
358
367
  typeof (json as { message?: unknown }).message === 'string'
359
368
  ? (json as { message: string }).message
360
369
  : undefined) || res.statusText || 'Old devtools request failed'
370
+ // An identity ALREADY SET (as opposed to `#requireIdentity`'s "never
371
+ // authenticated" case, which never reaches the backend) plus either a
372
+ // 401/403 OR a message that itself names token verification means the
373
+ // backend rejected a token we had — i.e. it's stale/invalid, not merely
374
+ // absent. The message check matters because this backend does NOT
375
+ // reliably use 401/403 for it — confirmed live: an actually-expired
376
+ // Firebase token surfaced as "Error while verifying token" with a
377
+ // status this narrow check (401/403-only) missed entirely, silently
378
+ // skipping the enrichment. The backend's own message gives no hint of
379
+ // the fix, and nothing else distinguishes this from Builder auth, so
380
+ // append repo-side guidance rather than replacing the raw detail.
381
+ const staleAuth = !!this.#identity && (
382
+ res.status === 401 || res.status === 403 ||
383
+ /verify.{0,20}token|token.{0,20}verif/i.test(rawMessage)
384
+ )
385
+ const message = staleAuth
386
+ ? `${rawMessage} — your old-devtools login looks stale or invalid `
387
+ + '(this is separate from Builder auth). Re-run reclaim_authenticate '
388
+ + 'to refresh it, then retry.'
389
+ : rawMessage
361
390
  // Shape `data` like a ProblemDetails so remapErrorAsResponse can render
362
391
  // `${message}: ${data.detail}` and expose the raw body as structured.
363
392
  return new Boom(message, {