@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
@@ -2,17 +2,21 @@ import type { ConfigStore } from '../../../config/store.ts'
2
2
  import { defineTool, type RegisteredTool } from '../../server.ts'
3
3
  import { type AttachState, disposeAttached } from './attach.ts'
4
4
  import { type CaptureStore, resetCaptures } from './capture.ts'
5
+ import { type LiveShareRef, stopLiveShare } from './live-view.ts'
5
6
 
6
7
  export function sessionTools(
7
8
  attachRef: { current?: AttachState, config: ConfigStore },
8
9
  captures: CaptureStore,
10
+ liveShare: LiveShareRef,
9
11
  ): RegisteredTool[] {
10
12
  return [
11
13
  defineTool<Record<string, never>>(
12
14
  {
13
15
  name: 'get_session',
14
16
  description:
15
- 'Return current attach state, active captures, and persisted config.',
17
+ 'Report the current attach state, the live captureIds, and the '
18
+ + 'persisted browser config. Call it to check whether a browser is '
19
+ + 'still attached before reattaching, or to recover a captureId.',
16
20
  inputSchema: { type: 'object', properties: {} },
17
21
  },
18
22
  async() => ({
@@ -25,6 +29,17 @@ export function sessionTools(
25
29
  orgId: attachRef.current?.orgId,
26
30
  captures: [...captures.sessions.keys()],
27
31
  activeTab: captures.activeTab,
32
+ // The public share, when one is open — so a caller that lost the
33
+ // URL can recover it instead of opening a second tunnel.
34
+ ...(liveShare.current
35
+ ? {
36
+ sharedView: {
37
+ url: liveShare.current.url,
38
+ interactive: liveShare.current.interactive,
39
+ viewers: liveShare.current.view.viewers(),
40
+ },
41
+ }
42
+ : {}),
28
43
  }),
29
44
  ),
30
45
  defineTool<Record<string, never>>(
@@ -33,11 +48,12 @@ export function sessionTools(
33
48
  description:
34
49
  'Stop captures, disconnect the current browser, and immediately '
35
50
  + 'delete a Builder-hosted Popcorn session. Custom CDP sessions '
36
- + 'are disconnected but never deleted by Reclaim.',
51
+ + 'are disconnected but never deleted by Reclaim. Also closes a '
52
+ + 'public live view opened by share_browser_view.',
37
53
  inputSchema: { type: 'object', properties: {} },
38
54
  },
39
55
  async() => {
40
- await resetAll(attachRef, captures)
56
+ await resetAll(attachRef, captures, liveShare)
41
57
  return { disposed: true }
42
58
  },
43
59
  ),
@@ -46,11 +62,12 @@ export function sessionTools(
46
62
  name: 'reset_session',
47
63
  description:
48
64
  'Stop all captures and clear capture state — drops every '
49
- + 'session\'s drafts and their in-memory secrets.',
65
+ + 'session\'s drafts and their in-memory secrets, and closes a '
66
+ + 'public live view opened by share_browser_view.',
50
67
  inputSchema: { type: 'object', properties: {} },
51
68
  },
52
69
  async() => {
53
- await resetAll(attachRef, captures)
70
+ await resetAll(attachRef, captures, liveShare)
54
71
  return { reset: true }
55
72
  },
56
73
  ),
@@ -60,7 +77,11 @@ export function sessionTools(
60
77
  async function resetAll(
61
78
  attachRef: { current?: AttachState, config: ConfigStore },
62
79
  captures: CaptureStore,
80
+ liveShare: LiveShareRef,
63
81
  ) {
82
+ // Before the captures: the live view holds a tab session of its own, and a
83
+ // public URL must never outlive the browser it points at.
84
+ await stopLiveShare(liveShare)
64
85
  await resetCaptures(captures)
65
86
  await disposeAttached(attachRef)
66
87
  }
@@ -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,12 @@ 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
+ ? 'OLD-DEVTOOLS MODE ONLY, and narrow: it authorizes exactly two '
22
+ + 'Builder-exclusive features — allocating a remote browser '
23
+ + '(attach_browser mode="builder") and list_builder_organizations. '
24
+ + 'It does NOT authorize create_provider_version_from_capture or '
25
+ + 'get_me_providers; those need the separate old-devtools login, '
26
+ + 'reclaim_authenticate. Check both with get_devtools_mode. '
25
27
  : '')
26
28
  + 'Authorize the CLI / MCP to make authenticated API calls. Opens '
27
29
  + 'the '
@@ -30,8 +32,9 @@ export function authenticateTool(
30
32
  'the user to sign in and approve. On success the access token is ' +
31
33
  'cached in ~/.reclaim/config.json and activated for this session.\n\n' +
32
34
  'Call with no arguments. This bearer token authenticates ' +
33
- 'dashboard/org-management calls (e.g. list_orgs, issuing an org ' +
34
- 'secret). Programmatic verification calls use a separate org secret ' +
35
+ 'dashboard/org-management calls (for example, list_orgs, or ' +
36
+ 'issuing an org secret). Programmatic verification calls use a ' +
37
+ 'separate org secret ' +
35
38
  '(rorg_…, set as RECLAIM_ORG_SECRET).',
36
39
  inputSchema: { type: 'object', properties: {} },
37
40
  },
@@ -39,43 +42,136 @@ export function authenticateTool(
39
42
  )
40
43
  }
41
44
 
42
- async function runOAuthLoopback(client: ReclaimClient): Promise<unknown> {
45
+ const _customFetchForOauth: oauth.CustomFetch = async(
46
+ url: string,
47
+ options: oauth.CustomFetchOptions,
48
+ ) => {
49
+ const res = await fetch(url, options as RequestInit)
50
+ if(
51
+ options.method === 'POST' && res.status === 200 &&
52
+ new URL(url).pathname.endsWith('/oauth2/register')
53
+ ) {
54
+ return new Response(await res.text(), {
55
+ status: 201,
56
+ headers: res.headers,
57
+ })
58
+ }
59
+
60
+ return res
61
+ }
62
+
63
+ /**
64
+ * Standard OAuth 2.0 Authorization Code + PKCE, native-app style (RFC 8252):
65
+ * dynamically register a public client (RFC 7591 — Better Auth's
66
+ * `oauth-provider` plugin issues no secret for it), send the user to the
67
+ * browser, and catch the redirect on a loopback server. `openid-client`
68
+ * handles PKCE, `state` CSRF protection, and authorization-response
69
+ * validation (including a denied/failed login surfacing as
70
+ * `AuthorizationResponseError`) — this function only wires those calls
71
+ * together and owns the loopback server (no library spins that up for you).
72
+ */
73
+ export async function runOAuthLoopback(
74
+ client: ReclaimClient,
75
+ // Overridable only so tests can intercept the authorize URL instead of
76
+ // actually spawning a browser; every real caller uses the default.
77
+ openBrowserFn: (url: string) => void = openBrowser,
78
+ ): Promise<unknown> {
43
79
  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))
80
+ const codeVerifier = oauth.randomPKCECodeVerifier()
81
+ const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier)
82
+ const state = oauth.randomState()
83
+
84
+ // openid-client/oauth4webapi refuse any non-HTTPS issuer by default
85
+ // ("only requests to HTTPS are allowed"). Better Auth's own dev default
86
+ // (`canonicalOrigin()`, packages/app/src/consts.ts) is plain
87
+ // `http://localhost:4001` — the server side has no HTTPS requirement at
88
+ // all — so a local `RECLAIM_API_URL=http://localhost:...` would otherwise
89
+ // always fail here. Loosen it ONLY for loopback hosts, matching what the
90
+ // server already trusts (`authTrustedOrigins()` defaults to
91
+ // localhost:4000/4001 outside production); a non-loopback http:// origin
92
+ // still gets the strict check.
93
+ const isLoopbackAuthBase =
94
+ ['localhost', '127.0.0.1', '[::1]'].includes(new URL(authBase).hostname)
47
95
 
48
96
  // 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 }
97
+ // loopback redirect.
98
+ const callback = await listenForCallback()
99
+ let tokens: oauth.TokenEndpointResponse
53
100
  try {
54
- const clientId = await registerClient(authBase, callback.redirectUri)
55
- openBrowser(
56
- authorizeUrl(authBase, {
57
- clientId,
58
- redirectUri: callback.redirectUri,
59
- challenge,
101
+ const config = await oauth.dynamicClientRegistration(
102
+ new URL(authBase),
103
+ {
104
+ client_name: 'Reclaim CLI',
105
+ redirect_uris: [callback.redirectUri],
106
+ token_endpoint_auth_method: 'none',
107
+ grant_types: ['authorization_code', 'refresh_token'],
108
+ response_types: ['code'],
109
+ scope: SCOPE,
110
+ },
111
+ undefined,
112
+ {
113
+ // Better Auth exposes its metadata at the RFC 8414 well-known path
114
+ // (`.well-known/oauth-authorization-server`), not the OIDC one.
115
+ algorithm: 'oauth2',
116
+ // RFC 7591 §3.2.1 requires 201 Created on a successful registration;
117
+ // openid-client enforces that strictly. Better Auth's
118
+ // `oauth-provider` plugin doesn't conform — confirmed live against
119
+ // the real server: `POST .../oauth2/register` returns 200 with an
120
+ // otherwise well-formed body, which openid-client rejects outright
121
+ // ("unexpected HTTP response status code") before ever looking at
122
+ // it. `customFetch` set here persists onto the returned
123
+ // `Configuration` for ALL its future calls too (token exchange
124
+ // included, which correctly returns 200) — so the URL itself, not
125
+ // just method+status, must gate the rewrite, or a genuine 200 from
126
+ // the token endpoint gets corrupted into a 201 and
127
+ // `authorizationCodeGrant` fails instead (confirmed by hitting
128
+ // exactly that when this only checked method === 'POST').
129
+ [oauth.customFetch]: _customFetchForOauth,
130
+ // Applies to the `Configuration` this call returns, so the later
131
+ // `buildAuthorizationUrl`/`authorizationCodeGrant` calls below (which
132
+ // reuse that same `config`) stop enforcing HTTPS too — no separate
133
+ // opt-in needed for them.
134
+ ...(isLoopbackAuthBase
135
+ ? { execute: [oauth.allowInsecureRequests] }
136
+ : {}),
137
+ },
138
+ )
139
+ openBrowserFn(
140
+ oauth.buildAuthorizationUrl(config, {
141
+ redirect_uri: callback.redirectUri,
142
+ scope: SCOPE,
60
143
  state,
61
- }),
144
+ code_challenge: codeChallenge,
145
+ code_challenge_method: 'S256',
146
+ }).href,
62
147
  )
63
- const code = await callback.code
64
- token = await exchangeCode(authBase, {
65
- code,
66
- verifier,
67
- redirectUri: callback.redirectUri,
68
- clientId,
148
+ const callbackUrl = await callback.url
149
+ tokens = await oauth.authorizationCodeGrant(config, callbackUrl, {
150
+ pkceCodeVerifier: codeVerifier,
151
+ expectedState: state,
69
152
  })
153
+ } catch(err) {
154
+ // A denied or failed authorization surfaces as this error class with
155
+ // the raw callback params on `.cause` — reword it like the previous
156
+ // hand-rolled check did (RFC 6749 §4.1.2.1's error/error_description).
157
+ if(err instanceof oauth.AuthorizationResponseError) {
158
+ throw new Error(
159
+ err.cause.get('error_description')
160
+ ?? err.cause.get('error')
161
+ ?? 'Sign-in failed or was cancelled.',
162
+ )
163
+ }
164
+
165
+ throw err
70
166
  } finally {
71
167
  callback.close()
72
168
  }
73
169
 
74
170
  const expiresAt = new Date(
75
- Date.now() + token.expires_in * 1000,
171
+ Date.now() + (tokens.expires_in ?? 3600) * 1000,
76
172
  ).toISOString()
77
- writeSession({ token: token.access_token, expiresAt })
78
- client.setToken(token.access_token)
173
+ writeSession({ token: tokens.access_token, expiresAt })
174
+ client.setToken(tokens.access_token)
79
175
 
80
176
  return {
81
177
  status: 'AUTHENTICATED',
@@ -86,57 +182,32 @@ async function runOAuthLoopback(client: ReclaimClient): Promise<unknown> {
86
182
 
87
183
  /**
88
184
  * 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
185
+ * redirect. Resolves as soon as the port is known — the `url` promise settles
90
186
  * later, when the browser hits `/callback` — so the caller can register a
91
187
  * 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.
188
+ * The ephemeral port lets any number of CLIs run concurrently. Hands back the
189
+ * RAW callback URL rather than picking `code`/`state` apart itself —
190
+ * `oauth.authorizationCodeGrant` does that validation. Defined below its
191
+ * caller per repo convention.
94
192
  */
95
- async function listenForCallback(state: string): Promise<{
193
+ async function listenForCallback(): Promise<{
96
194
  redirectUri: string
97
- code: Promise<string>
195
+ url: Promise<URL>
98
196
  close: () => void
99
197
  }> {
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
198
+ let resolveUrl: (url: URL) => void
199
+ let rejectUrl: (err: Error) => void
200
+ const url = new Promise<URL>((resolve, reject) => {
201
+ resolveUrl = resolve
202
+ rejectUrl = reject
105
203
  })
106
204
 
107
205
  const server = createServer()
108
206
  const timer = setTimeout(
109
- () => rejectCode(new Error('Timed out waiting for sign-in (5 min).')),
207
+ () => rejectUrl(new Error('Timed out waiting for sign-in (5 min).')),
110
208
  CALLBACK_TIMEOUT_MS,
111
209
  )
112
210
 
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
211
  const port = await new Promise<number>((resolve, reject) => {
141
212
  server.once('error', reject)
142
213
  server.listen(0, '127.0.0.1', () => {
@@ -148,13 +219,37 @@ async function listenForCallback(state: string): Promise<{
148
219
 
149
220
  resolve(addr.port)
150
221
  })
222
+ }).catch((err) => {
223
+ // Bind failed before `close()` (returned below) is ever reachable —
224
+ // clear the timer ourselves, or it fires 5 minutes later and rejects
225
+ // the now-unobserved `url` promise (unhandled rejection) while keeping
226
+ // the process alive in the meantime.
227
+ clearTimeout(timer)
228
+ throw err
151
229
  })
152
230
  // Past bind, a socket error fails the pending wait rather than throwing.
153
- server.on('error', (err) => rejectCode(err))
231
+ server.on('error', (err) => rejectUrl(err))
232
+
233
+ const redirectUri = `http://127.0.0.1:${port}/callback`
234
+ server.on('request', (req, res) => {
235
+ const reqUrl = new URL(req.url ?? '/', redirectUri)
236
+ if(reqUrl.pathname !== '/callback') {
237
+ res.writeHead(404).end()
238
+ return
239
+ }
240
+
241
+ res.writeHead(200, { 'content-type': 'text/html' })
242
+ res.end(
243
+ '<!doctype html><meta charset=utf-8><body style="font:16px '
244
+ + 'system-ui;padding:3rem;text-align:center">Signed in. You can '
245
+ + 'close this tab and return to your terminal.</body>',
246
+ )
247
+ resolveUrl(reqUrl)
248
+ })
154
249
 
155
250
  return {
156
- redirectUri: `http://127.0.0.1:${port}/callback`,
157
- code,
251
+ redirectUri,
252
+ url,
158
253
  close: () => {
159
254
  clearTimeout(timer)
160
255
  server.close()
@@ -162,99 +257,6 @@ async function listenForCallback(state: string): Promise<{
162
257
  }
163
258
  }
164
259
 
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
260
  function openBrowser(url: string) {
259
261
  const cmd = process.platform === 'darwin'
260
262
  ? '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
@@ -10,7 +24,7 @@ import { defineTool, type RegisteredTool } from '../../server.ts'
10
24
  * `~/.reclaim/config.json` and applied the NEXT time the server starts —
11
25
  * the registered tool set, server instructions, and backend client are all
12
26
  * fixed at connect time, so the switch requires a restart/reconnect. Follows
13
- * the `attach_chrome` persist-through-config pattern.
27
+ * the `attach_browser` persist-through-config pattern.
14
28
  *
15
29
  * Registered in BOTH modes (so the user can switch either direction) and
16
30
  * outside `buildAgentTools` (so it survives `RECLAIM_AGENT_DISABLED=1`). An
@@ -60,21 +74,28 @@ export function buildModeTools(
60
74
  {
61
75
  name: 'get_devtools_mode',
62
76
  description:
63
- 'Report which backend this MCP server is currently targeting: '
64
- + '"old" (devtools) or "builder" (the builder '
65
- + 'backend). Also reports WHY that mode is in effect (source: '
66
- + 'a USE_OLD_DEVTOOLS env override, the persisted config, or '
67
- + 'the default) and which mode is persisted for the next '
68
- + 'start. Use set_devtools_mode to change it.',
77
+ 'Report which backend this server targets ("old" devtools or '
78
+ + '"builder"), WHY (a USE_OLD_DEVTOOLS env override, the persisted '
79
+ + 'config, or the default), and which mode is persisted for the '
80
+ + 'next start. Change it with set_devtools_mode. Also reports auth '
81
+ + 'status for both login systems: builderAuthenticated and '
82
+ + 'oldDevtoolsAuthenticated. Both are PRESENCE checks, not validity '
83
+ + 'checks — a stale token still reports true; a real call surfaces '
84
+ + 'the staleness with an error naming the tool to re-run. Which '
85
+ + 'login authorizes what: how_it_works({ topic: "auth" }).',
69
86
  inputSchema: { type: 'object', properties: {} },
70
87
  },
71
88
  async() => {
72
89
  const persisted = config.read()?.mode
90
+ const oldIdentity = config.read()?.old_devtools
73
91
  return {
74
92
  mode: current,
75
93
  source: oldModeSource(persisted),
76
94
  persistedMode: persisted ?? null,
77
95
  configPath: defaultConfigPath(),
96
+ builderAuthenticated: hasValidBuilderSession(config),
97
+ oldDevtoolsAuthenticated: !!oldIdentity,
98
+ oldDevtoolsIdentityKind: oldIdentity?.kind ?? null,
78
99
  }
79
100
  },
80
101
  ),