@reclaimprotocol/agent 0.1.36 → 0.1.37

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 (101) hide show
  1. package/README.md +8 -8
  2. package/lib/authoring/backend.d.ts +12 -1
  3. package/lib/authoring/backend.js +3 -0
  4. package/lib/authoring/tools.js +33 -5
  5. package/lib/authoring/util.d.ts +43 -0
  6. package/lib/authoring/util.js +54 -0
  7. package/lib/cdp/chrome-for-testing.d.ts +14 -0
  8. package/lib/cdp/chrome-for-testing.js +43 -0
  9. package/lib/cdp/geo.d.ts +3 -0
  10. package/lib/cdp/geo.js +27 -15
  11. package/lib/cdp/launcher.d.ts +79 -1
  12. package/lib/cdp/launcher.js +244 -24
  13. package/lib/logger.js +3 -1
  14. package/lib/mcp/instructions.js +3 -3
  15. package/lib/mcp/start.js +17 -2
  16. package/lib/mcp/tools/agent/attach.d.ts +6 -0
  17. package/lib/mcp/tools/agent/attach.js +58 -118
  18. package/lib/mcp/tools/agent/build.js +6 -0
  19. package/lib/mcp/tools/agent/capture.js +33 -10
  20. package/lib/mcp/tools/agent/check-attestor.js +15 -7
  21. package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
  22. package/lib/mcp/tools/agent/draft-tools.js +170 -0
  23. package/lib/mcp/tools/agent/pod-proof.js +5 -1
  24. package/lib/mcp/tools/agent/proof.d.ts +28 -0
  25. package/lib/mcp/tools/agent/proof.js +72 -0
  26. package/lib/mcp/tools/agent/shared.d.ts +8 -1
  27. package/lib/mcp/tools/agent/shared.js +98 -19
  28. package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
  29. package/lib/mcp/tools/agent/start-integration.js +10 -6
  30. package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
  31. package/lib/mcp/tools/authenticate/authenticate.js +212 -116
  32. package/lib/mcp/tools/authenticate/build.d.ts +2 -2
  33. package/lib/mcp/tools/authenticate/build.js +6 -3
  34. package/lib/mcp/tools/authenticate/builder-access.js +5 -2
  35. package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
  36. package/lib/mcp/tools/authenticate/eth-key.js +29 -5
  37. package/lib/mcp/tools/authenticate/index.d.ts +1 -1
  38. package/lib/mcp/tools/authenticate/index.js +1 -1
  39. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
  40. package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
  41. package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
  42. package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
  43. package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
  44. package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
  45. package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
  46. package/lib/mcp/tools/authenticate/session-cache.js +95 -4
  47. package/lib/mcp/tools/mode/mode.js +6 -6
  48. package/lib/old/login-cdp.js +2 -3
  49. package/lib/proof/attestor.js +2 -3
  50. package/lib/proof/check-attestor.d.ts +12 -2
  51. package/lib/proof/check-attestor.js +40 -10
  52. package/lib/proof/check-matchers.d.ts +36 -0
  53. package/lib/proof/check-matchers.js +161 -0
  54. package/lib/proof/zk-files.d.ts +50 -11
  55. package/lib/proof/zk-files.js +259 -63
  56. package/lib/provider/draft.d.ts +24 -1
  57. package/lib/provider/draft.js +135 -0
  58. package/lib/provider/matcher.js +171 -4
  59. package/package.json +6 -4
  60. package/skill/SKILL.builder.md +7 -2
  61. package/skill/SKILL.common.md +90 -70
  62. package/skill/SKILL.old.md +3 -3
  63. package/skill/builder-mode.md +35 -5
  64. package/skill/old-mode.md +18 -13
  65. package/skill/start-integration.md +48 -33
  66. package/skill/verification-client-integration.md +21 -6
  67. package/src/authoring/backend.ts +12 -1
  68. package/src/authoring/tools.ts +48 -7
  69. package/src/authoring/util.ts +64 -0
  70. package/src/cdp/chrome-for-testing.ts +66 -0
  71. package/src/cdp/geo.ts +36 -21
  72. package/src/cdp/launcher.ts +328 -24
  73. package/src/logger.ts +3 -1
  74. package/src/mcp/instructions.ts +3 -3
  75. package/src/mcp/start.ts +20 -1
  76. package/src/mcp/tools/agent/attach.ts +65 -128
  77. package/src/mcp/tools/agent/build.ts +7 -0
  78. package/src/mcp/tools/agent/capture.ts +54 -11
  79. package/src/mcp/tools/agent/check-attestor.ts +15 -7
  80. package/src/mcp/tools/agent/draft-tools.ts +234 -0
  81. package/src/mcp/tools/agent/pod-proof.ts +5 -1
  82. package/src/mcp/tools/agent/proof.ts +110 -1
  83. package/src/mcp/tools/agent/shared.ts +130 -19
  84. package/src/mcp/tools/agent/start-integration.ts +10 -6
  85. package/src/mcp/tools/authenticate/authenticate.ts +276 -131
  86. package/src/mcp/tools/authenticate/build.ts +6 -3
  87. package/src/mcp/tools/authenticate/builder-access.ts +5 -2
  88. package/src/mcp/tools/authenticate/eth-key.ts +41 -7
  89. package/src/mcp/tools/authenticate/index.ts +1 -1
  90. package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
  91. package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
  92. package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
  93. package/src/mcp/tools/authenticate/session-cache.ts +125 -5
  94. package/src/mcp/tools/mode/mode.ts +6 -6
  95. package/src/old/login-cdp.ts +2 -3
  96. package/src/proof/attestor.ts +2 -3
  97. package/src/proof/check-attestor.ts +55 -12
  98. package/src/proof/check-matchers.ts +227 -0
  99. package/src/proof/zk-files.ts +357 -65
  100. package/src/provider/draft.ts +215 -7
  101. package/src/provider/matcher.ts +237 -4
@@ -3,15 +3,53 @@ import { spawn } from 'node:child_process'
3
3
  import { createServer } from 'node:http'
4
4
  import * as oauth from 'openid-client'
5
5
  import { defineTool, type RegisteredTool } from '../../server.ts'
6
- import { writeSession } from './session-cache.ts'
6
+ import {
7
+ authBaseFor,
8
+ oauthRequestOptions,
9
+ SCOPE,
10
+ sessionFromTokens,
11
+ } from './oauth-config.ts'
12
+ import { readSession, writeSession } from './session-cache.ts'
7
13
 
8
- const SCOPE = 'openid profile email'
14
+ type AuthToolName = 'reclaim_authenticate' | 'authenticate_builder'
15
+
16
+ /** How long a sign-in link stays valid (the loopback listener's lifetime). */
9
17
  const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
18
+ /** Longest single `wait_for_sign_in` call; stays under common 60 s tool
19
+ * timeouts. */
20
+ const MAX_WAIT_SECONDS = 55
21
+ const DEFAULT_WAIT_SECONDS = 50
22
+ /** How long a browser launcher gets to fail before it counts as opened. */
23
+ const BROWSER_GRACE_MS = 1500
24
+
25
+ /** Whether the browser launcher started, and why not when it didn't. */
26
+ export type BrowserLaunch = { opened: boolean, reason?: string }
27
+
28
+ export type PendingSignIn = {
29
+ status: 'PENDING'
30
+ signInUrl: string
31
+ browserOpened: boolean
32
+ reason?: string
33
+ /** ISO timestamp when `signInUrl` stops working. */
34
+ expiresAt: string
35
+ }
36
+
37
+ export type SignInOutcome =
38
+ | { status: 'AUTHENTICATED', expiresAt: string }
39
+ | { status: 'FAILED', reason: string }
40
+
41
+ type SignInFlow = {
42
+ started: Promise<PendingSignIn>
43
+ completion: Promise<SignInOutcome>
44
+ outcome?: SignInOutcome
45
+ }
46
+
47
+ /** The sign-in in progress (or last finished) for each Builder client. */
48
+ const FLOWS = new WeakMap<ReclaimClient, SignInFlow>()
10
49
 
11
50
  export function authenticateTool(
12
51
  client: ReclaimClient,
13
- name: 'reclaim_authenticate' | 'authenticate_builder' =
14
- 'reclaim_authenticate',
52
+ name: AuthToolName = 'reclaim_authenticate',
15
53
  ): RegisteredTool {
16
54
  return defineTool<Record<string, never>>(
17
55
  {
@@ -25,37 +63,121 @@ export function authenticateTool(
25
63
  + 'get_me_providers; those need the separate old-devtools login, '
26
64
  + 'reclaim_authenticate. Check both with get_devtools_mode. '
27
65
  : '')
28
- + 'Signs the developer in to Builder. Opens the Builder sign-in page '
29
- + 'in their browser (OAuth 2.0 authorization code with PKCE, '
30
- + 'redirecting to a localhost port this server opens) and waits for '
31
- + 'them to approve. Saves the access token in ~/.reclaim/config.json '
32
- + 'with mode 0600 and uses it for this session; the token is never '
33
- + 'returned. The token authorizes organization-management tools such '
34
- + 'as `list_orgs`. Programmatic verification tools use a separate '
35
- + 'organization key; see `issue_org_token`. Call with no arguments.',
66
+ + 'Starts signing the developer in to Builder (OAuth 2.0 '
67
+ + 'authorization code with PKCE, redirecting to a localhost port '
68
+ + 'this server opens) and returns at once without waiting for them: '
69
+ + '`{ status: "PENDING", signInUrl, browserOpened, reason?, '
70
+ + 'expiresAt }`. It tries to open signInUrl in their browser; '
71
+ + '`browserOpened` says whether that worked and `reason` why not. '
72
+ + 'Always give the developer signInUrl and ask them to open it in a '
73
+ + 'browser on this machine. When browserOpened is false, end your '
74
+ + 'turn after sharing it — nothing happens until they open it. Then '
75
+ + 'call `wait_for_sign_in` to confirm. The link expires after 5 '
76
+ + 'minutes; calling again while one is pending returns the same '
77
+ + 'link. On success the token is saved in ~/.reclaim/config.json '
78
+ + 'with mode 0600, refreshed automatically, and never returned. It '
79
+ + 'authorizes organization-management tools such as `list_orgs`. '
80
+ + 'Programmatic verification tools use a separate organization key; '
81
+ + 'see `issue_org_token`. Call with no arguments.',
36
82
  inputSchema: { type: 'object', properties: {} },
37
83
  annotations: { openWorldHint: true, idempotentHint: true },
38
84
  },
39
- async() => runOAuthLoopback(client),
85
+ async() => startSignIn(client),
40
86
  )
41
87
  }
42
88
 
43
- const _customFetchForOauth: oauth.CustomFetch = async(
44
- url: string,
45
- options: oauth.CustomFetchOptions,
46
- ) => {
47
- const res = await fetch(url, options as RequestInit)
48
- if(
49
- options.method === 'POST' && res.status === 200 &&
50
- new URL(url).pathname.endsWith('/oauth2/register')
51
- ) {
52
- return new Response(await res.text(), {
53
- status: 201,
54
- headers: res.headers,
55
- })
89
+ export function waitForSignInTool(
90
+ client: ReclaimClient,
91
+ authToolName: AuthToolName = 'reclaim_authenticate',
92
+ ): RegisteredTool {
93
+ return defineTool<{ timeoutSeconds?: number }>(
94
+ {
95
+ name: 'wait_for_sign_in',
96
+ description:
97
+ `Waits for the Builder sign-in started by \`${authToolName}\` to `
98
+ + `finish, for up to timeoutSeconds (default ${DEFAULT_WAIT_SECONDS}`
99
+ + `, max ${MAX_WAIT_SECONDS}). Returns status AUTHENTICATED (with `
100
+ + 'expiresAt); PENDING (not finished yet: repeats signInUrl — call '
101
+ + 'again, or remind the developer to open the link); FAILED (with '
102
+ + `reason: call \`${authToolName}\` for a new link); or NOT_STARTED `
103
+ + '(no sign-in in progress and no valid session: call '
104
+ + `\`${authToolName}\`).`,
105
+ inputSchema: {
106
+ type: 'object',
107
+ properties: {
108
+ timeoutSeconds: {
109
+ type: 'integer',
110
+ minimum: 0,
111
+ maximum: MAX_WAIT_SECONDS,
112
+ },
113
+ },
114
+ },
115
+ annotations: { readOnlyHint: true, openWorldHint: false },
116
+ },
117
+ async({ timeoutSeconds }) => waitForSignIn(
118
+ client, (timeoutSeconds ?? DEFAULT_WAIT_SECONDS) * 1000,
119
+ ),
120
+ )
121
+ }
122
+
123
+ /**
124
+ * Start a sign-in and return its link without waiting for the developer. A
125
+ * sign-in already pending for `client` is returned as is rather than
126
+ * replaced. The flow finishes in the background: the loopback server keeps
127
+ * listening until the browser redirects back or the link expires, and on
128
+ * success caches the session and activates it on `client`.
129
+ */
130
+ export async function startSignIn(
131
+ client: ReclaimClient,
132
+ // Overridable only so tests can intercept the authorize URL instead of
133
+ // actually spawning a browser; every real caller uses the default.
134
+ openBrowserFn: (url: string) => Promise<BrowserLaunch> = openBrowser,
135
+ ): Promise<PendingSignIn> {
136
+ const existing = FLOWS.get(client)
137
+ if(existing && !existing.outcome) {
138
+ return existing.started
139
+ }
140
+
141
+ const begun = beginSignIn(client, openBrowserFn)
142
+ const flow: SignInFlow = {
143
+ started: begun.then(({ pending }) => pending),
144
+ completion: begun.then(
145
+ ({ completion }) => completion,
146
+ (err) => ({ status: 'FAILED' as const, reason: failureReason(err) }),
147
+ ),
148
+ }
149
+ void flow.completion.then((outcome) => {
150
+ flow.outcome = outcome
151
+ })
152
+ FLOWS.set(client, flow)
153
+ return flow.started
154
+ }
155
+
156
+ /**
157
+ * Wait up to `timeoutMs` for `client`'s sign-in to finish. Reports PENDING
158
+ * (with the link again) if it's still open when the wait runs out.
159
+ */
160
+ export async function waitForSignIn(
161
+ client: ReclaimClient,
162
+ timeoutMs: number,
163
+ ): Promise<PendingSignIn | SignInOutcome | { status: 'NOT_STARTED' }> {
164
+ const flow = FLOWS.get(client)
165
+ if(!flow) {
166
+ const session = readSession()
167
+ return session
168
+ ? { status: 'AUTHENTICATED', expiresAt: session.expiresAt }
169
+ : { status: 'NOT_STARTED' }
56
170
  }
57
171
 
58
- return res
172
+ let timer: ReturnType<typeof setTimeout> | undefined
173
+ const outcome = await Promise.race([
174
+ flow.completion,
175
+ new Promise<undefined>((resolve) => {
176
+ timer = setTimeout(() => resolve(undefined), timeoutMs)
177
+ }),
178
+ ])
179
+ clearTimeout(timer)
180
+ return outcome ?? flow.started
59
181
  }
60
182
 
61
183
  /**
@@ -67,36 +189,24 @@ const _customFetchForOauth: oauth.CustomFetch = async(
67
189
  * validation (including a denied/failed login surfacing as
68
190
  * `AuthorizationResponseError`) — this function only wires those calls
69
191
  * together and owns the loopback server (no library spins that up for you).
192
+ * Resolves once the link is ready; `completion` settles when the flow ends
193
+ * and never rejects.
70
194
  */
71
- export async function runOAuthLoopback(
195
+ async function beginSignIn(
72
196
  client: ReclaimClient,
73
- // Overridable only so tests can intercept the authorize URL instead of
74
- // actually spawning a browser; every real caller uses the default.
75
- openBrowserFn: (url: string) => void = openBrowser,
76
- ): Promise<unknown> {
77
- const authBase = `${client.baseUrl}/auth`
197
+ openBrowserFn: (url: string) => Promise<BrowserLaunch>,
198
+ ) {
199
+ const authBase = authBaseFor(client.baseUrl)
78
200
  const codeVerifier = oauth.randomPKCECodeVerifier()
79
201
  const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier)
80
202
  const state = oauth.randomState()
81
203
 
82
- // openid-client/oauth4webapi refuse any non-HTTPS issuer by default
83
- // ("only requests to HTTPS are allowed"). Better Auth's own dev default
84
- // (`canonicalOrigin()`, packages/app/src/consts.ts) is plain
85
- // `http://localhost:4001` — the server side has no HTTPS requirement at
86
- // all — so a local `RECLAIM_API_URL=http://localhost:...` would otherwise
87
- // always fail here. Loosen it ONLY for loopback hosts, matching what the
88
- // server already trusts (`authTrustedOrigins()` defaults to
89
- // localhost:4000/4001 outside production); a non-loopback http:// origin
90
- // still gets the strict check.
91
- const isLoopbackAuthBase =
92
- ['localhost', '127.0.0.1', '[::1]'].includes(new URL(authBase).hostname)
93
-
94
204
  // Bind the callback port first: the client is registered for this exact
95
205
  // loopback redirect.
96
206
  const callback = await listenForCallback()
97
- let tokens: oauth.TokenEndpointResponse
207
+ let config: oauth.Configuration
98
208
  try {
99
- const config = await oauth.dynamicClientRegistration(
209
+ config = await oauth.dynamicClientRegistration(
100
210
  new URL(authBase),
101
211
  {
102
212
  client_name: 'Reclaim CLI',
@@ -107,90 +217,83 @@ export async function runOAuthLoopback(
107
217
  scope: SCOPE,
108
218
  },
109
219
  undefined,
110
- {
111
- // Better Auth exposes its metadata at the RFC 8414 well-known path
112
- // (`.well-known/oauth-authorization-server`), not the OIDC one.
113
- algorithm: 'oauth2',
114
- // RFC 7591 §3.2.1 requires 201 Created on a successful registration;
115
- // openid-client enforces that strictly. Better Auth's
116
- // `oauth-provider` plugin doesn't conform — confirmed live against
117
- // the real server: `POST .../oauth2/register` returns 200 with an
118
- // otherwise well-formed body, which openid-client rejects outright
119
- // ("unexpected HTTP response status code") before ever looking at
120
- // it. `customFetch` set here persists onto the returned
121
- // `Configuration` for ALL its future calls too (token exchange
122
- // included, which correctly returns 200) — so the URL itself, not
123
- // just method+status, must gate the rewrite, or a genuine 200 from
124
- // the token endpoint gets corrupted into a 201 and
125
- // `authorizationCodeGrant` fails instead (confirmed by hitting
126
- // exactly that when this only checked method === 'POST').
127
- [oauth.customFetch]: _customFetchForOauth,
128
- // Applies to the `Configuration` this call returns, so the later
129
- // `buildAuthorizationUrl`/`authorizationCodeGrant` calls below (which
130
- // reuse that same `config`) stop enforcing HTTPS too — no separate
131
- // opt-in needed for them.
132
- ...(isLoopbackAuthBase
133
- ? { execute: [oauth.allowInsecureRequests] }
134
- : {}),
135
- },
220
+ oauthRequestOptions(authBase),
136
221
  )
137
- openBrowserFn(
138
- oauth.buildAuthorizationUrl(config, {
139
- redirect_uri: callback.redirectUri,
140
- scope: SCOPE,
141
- state,
142
- code_challenge: codeChallenge,
143
- code_challenge_method: 'S256',
144
- }).href,
145
- )
146
- const callbackUrl = await callback.url
147
- tokens = await oauth.authorizationCodeGrant(config, callbackUrl, {
148
- pkceCodeVerifier: codeVerifier,
149
- expectedState: state,
150
- })
151
222
  } catch(err) {
152
- // A denied or failed authorization surfaces as this error class with
153
- // the raw callback params on `.cause` — reword it like the previous
154
- // hand-rolled check did (RFC 6749 §4.1.2.1's error/error_description).
155
- if(err instanceof oauth.AuthorizationResponseError) {
156
- throw new Error(
157
- err.cause.get('error_description')
158
- ?? err.cause.get('error')
159
- ?? 'Sign-in failed or was cancelled.',
160
- )
161
- }
162
-
163
- throw err
164
- } finally {
165
223
  callback.close()
224
+ throw err
166
225
  }
167
226
 
168
- const expiresAt = new Date(
169
- Date.now() + (tokens.expires_in ?? 3600) * 1000,
170
- ).toISOString()
171
- writeSession({ token: tokens.access_token, expiresAt })
172
- client.setToken(tokens.access_token)
227
+ const signInUrl = oauth.buildAuthorizationUrl(config, {
228
+ redirect_uri: callback.redirectUri,
229
+ scope: SCOPE,
230
+ state,
231
+ code_challenge: codeChallenge,
232
+ code_challenge_method: 'S256',
233
+ }).href
234
+ const completion = finish()
235
+ const launch = await openBrowserFn(signInUrl).catch((err) => ({
236
+ opened: false,
237
+ reason: failureReason(err),
238
+ }))
239
+ const pending: PendingSignIn = {
240
+ status: 'PENDING',
241
+ signInUrl,
242
+ browserOpened: launch.opened,
243
+ ...(launch.reason ? { reason: launch.reason } : {}),
244
+ expiresAt: callback.expiresAt,
245
+ }
246
+ return { pending, completion }
173
247
 
174
- return {
175
- status: 'AUTHENTICATED',
176
- note: 'Access token cached in ~/.reclaim/config.json and active now.',
177
- expiresAt,
248
+ async function finish(): Promise<SignInOutcome> {
249
+ try {
250
+ const tokens = await oauth.authorizationCodeGrant(
251
+ config, await callback.url,
252
+ { pkceCodeVerifier: codeVerifier, expectedState: state },
253
+ )
254
+ const session = sessionFromTokens(tokens, {
255
+ clientId: config.clientMetadata().client_id,
256
+ authBase,
257
+ })
258
+ writeSession(session)
259
+ client.setToken(session.token)
260
+ return { status: 'AUTHENTICATED', expiresAt: session.expiresAt }
261
+ } catch(err) {
262
+ return { status: 'FAILED', reason: failureReason(err) }
263
+ } finally {
264
+ callback.close()
265
+ }
178
266
  }
179
267
  }
180
268
 
269
+ function failureReason(err: unknown) {
270
+ // A denied or failed authorization surfaces as this error class with the
271
+ // raw callback params on `.cause` (RFC 6749 §4.1.2.1's
272
+ // error/error_description).
273
+ if(err instanceof oauth.AuthorizationResponseError) {
274
+ return err.cause.get('error_description')
275
+ ?? err.cause.get('error')
276
+ ?? 'Sign-in failed or was cancelled.'
277
+ }
278
+
279
+ return err instanceof Error ? err.message : String(err)
280
+ }
281
+
181
282
  /**
182
283
  * Bind an ephemeral loopback port and start listening for the provider's
183
284
  * redirect. Resolves as soon as the port is known — the `url` promise settles
184
- * later, when the browser hits `/callback` — so the caller can register a
185
- * client for this exact redirect URI before sending the user off to authorize.
186
- * The ephemeral port lets any number of CLIs run concurrently. Hands back the
187
- * RAW callback URL rather than picking `code`/`state` apart itself —
188
- * `oauth.authorizationCodeGrant` does that validation. Defined below its
189
- * caller per repo convention.
285
+ * later, when the browser hits `/callback`, or rejects when the link expires —
286
+ * so the caller can register a client for this exact redirect URI before
287
+ * sending the user off to authorize. The ephemeral port lets any number of
288
+ * CLIs run concurrently. Hands back the RAW callback URL rather than picking
289
+ * `code`/`state` apart itself — `oauth.authorizationCodeGrant` does that
290
+ * validation. The server and timer are unref'd: a pending sign-in alone
291
+ * doesn't keep the process alive.
190
292
  */
191
293
  async function listenForCallback(): Promise<{
192
294
  redirectUri: string
193
295
  url: Promise<URL>
296
+ expiresAt: string
194
297
  close: () => void
195
298
  }> {
196
299
  let resolveUrl: (url: URL) => void
@@ -201,10 +304,14 @@ async function listenForCallback(): Promise<{
201
304
  })
202
305
 
203
306
  const server = createServer()
307
+ const expiresAt = new Date(Date.now() + CALLBACK_TIMEOUT_MS).toISOString()
204
308
  const timer = setTimeout(
205
- () => rejectUrl(new Error('Timed out waiting for sign-in (5 min).')),
309
+ () => rejectUrl(new Error(
310
+ 'Sign-in link expired (5 min). Call the sign-in tool again for a new '
311
+ + 'link.',
312
+ )),
206
313
  CALLBACK_TIMEOUT_MS,
207
- )
314
+ ).unref()
208
315
 
209
316
  const port = await new Promise<number>((resolve, reject) => {
210
317
  server.once('error', reject)
@@ -220,11 +327,11 @@ async function listenForCallback(): Promise<{
220
327
  }).catch((err) => {
221
328
  // Bind failed before `close()` (returned below) is ever reachable —
222
329
  // clear the timer ourselves, or it fires 5 minutes later and rejects
223
- // the now-unobserved `url` promise (unhandled rejection) while keeping
224
- // the process alive in the meantime.
330
+ // the now-unobserved `url` promise (unhandled rejection).
225
331
  clearTimeout(timer)
226
332
  throw err
227
333
  })
334
+ server.unref()
228
335
  // Past bind, a socket error fails the pending wait rather than throwing.
229
336
  server.on('error', (err) => rejectUrl(err))
230
337
 
@@ -248,6 +355,7 @@ async function listenForCallback(): Promise<{
248
355
  return {
249
356
  redirectUri,
250
357
  url,
358
+ expiresAt,
251
359
  close: () => {
252
360
  clearTimeout(timer)
253
361
  server.close()
@@ -255,18 +363,55 @@ async function listenForCallback(): Promise<{
255
363
  }
256
364
  }
257
365
 
258
- function openBrowser(url: string) {
259
- const cmd = process.platform === 'darwin'
260
- ? 'open'
261
- : process.platform === 'win32'
262
- ? 'cmd'
263
- : 'xdg-open'
264
- const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
265
- try {
266
- spawn(cmd, args, { stdio: 'ignore', detached: true }).unref()
267
- } catch{
268
- // Non-fatal: the URL is still logged for the user to open manually.
366
+ /**
367
+ * Try to open `url` in the system browser. Never writes to stdout (it carries
368
+ * MCP JSON-RPC); the URL goes to stderr for a developer watching the logs.
369
+ * On Linux without a display server it doesn't try. Otherwise a launcher
370
+ * that errors or exits non-zero within a short grace window counts as not
371
+ * opened.
372
+ */
373
+ export async function openBrowser(
374
+ url: string,
375
+ { env = process.env, platform = process.platform } = {},
376
+ ): Promise<BrowserLaunch> {
377
+ process.stderr.write(`\nOpen this URL to sign in:\n${url}\n\n`)
378
+ const hasDisplay = platform === 'darwin' || platform === 'win32'
379
+ || !!env.WAYLAND_DISPLAY || !!env.DISPLAY
380
+ if(!hasDisplay) {
381
+ return {
382
+ opened: false,
383
+ reason: 'no display (WAYLAND_DISPLAY/DISPLAY not set)',
384
+ }
269
385
  }
270
386
 
271
- process.stderr.write(`\nOpen this URL to sign in:\n${url}\n\n`)
387
+ const cmd = platform === 'darwin'
388
+ ? 'open'
389
+ : platform === 'win32' ? 'cmd' : 'xdg-open'
390
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url]
391
+ return new Promise((resolve) => {
392
+ const timer = setTimeout(() => resolve({ opened: true }), BROWSER_GRACE_MS)
393
+ try {
394
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true, env })
395
+ child.once('error', (err) => done({
396
+ opened: false,
397
+ reason: `${cmd} failed: ${err.message}`,
398
+ }))
399
+ child.once('exit', (code, signal) => done(
400
+ code === 0
401
+ ? { opened: true }
402
+ : {
403
+ opened: false,
404
+ reason: `${cmd} exited with ${code ?? signal}`,
405
+ },
406
+ ))
407
+ child.unref()
408
+ } catch(err) {
409
+ done({ opened: false, reason: `${cmd} failed: ${failureReason(err)}` })
410
+ }
411
+
412
+ function done(launch: BrowserLaunch) {
413
+ clearTimeout(timer)
414
+ resolve(launch)
415
+ }
416
+ })
272
417
  }
@@ -1,8 +1,9 @@
1
1
  import type { ReclaimClient } from '@reclaimprotocol/client/api'
2
2
  import type { RegisteredTool } from '../../server.ts'
3
- import { authenticateTool } from './authenticate.ts'
3
+ import { authenticateTool, waitForSignInTool } from './authenticate.ts'
4
4
  import { importTool } from './import.ts'
5
5
  import { issueTool } from './issue.ts'
6
+ import { issueOrgKeypairTool } from './issue-org-keypair.ts'
6
7
  import { issueOrgTokenTool } from './issue-org-token.ts'
7
8
  import { resolveOwnerKeyTool } from './resolve-owner-key.ts'
8
9
 
@@ -22,13 +23,15 @@ export function buildLocalCredentialTools(): RegisteredTool[] {
22
23
 
23
24
  /**
24
25
  * Full builder-mode credential set: the local key tools plus the backend
25
- * `reclaim_authenticate` device-code flow. Old mode does NOT use the
26
- * authenticate tool — it has its own.
26
+ * `reclaim_authenticate` sign-in flow and its `wait_for_sign_in` companion.
27
+ * Old mode does NOT use the authenticate tool — it has its own.
27
28
  */
28
29
  export function buildCredentialTools(client: ReclaimClient): RegisteredTool[] {
29
30
  return [
30
31
  authenticateTool(client),
32
+ waitForSignInTool(client),
31
33
  issueOrgTokenTool(client),
34
+ issueOrgKeypairTool(client),
32
35
  ...buildLocalCredentialTools(),
33
36
  ]
34
37
  }
@@ -1,6 +1,6 @@
1
1
  import type { ReclaimClient } from '@reclaimprotocol/client/api'
2
2
  import { defineTool, type RegisteredTool } from '../../server.ts'
3
- import { authenticateTool } from './authenticate.ts'
3
+ import { authenticateTool, waitForSignInTool } from './authenticate.ts'
4
4
 
5
5
  /** Legacy-devtools mode needs a distinct Builder login for its two
6
6
  * Builder-exclusive features: allocating quota-accounted Popcorn sessions
@@ -10,7 +10,10 @@ import { authenticateTool } from './authenticate.ts'
10
10
  export function buildBuilderAuthenticationTools(
11
11
  client: ReclaimClient,
12
12
  ): RegisteredTool[] {
13
- return [authenticateTool(client, 'authenticate_builder')]
13
+ return [
14
+ authenticateTool(client, 'authenticate_builder'),
15
+ waitForSignInTool(client, 'authenticate_builder'),
16
+ ]
14
17
  }
15
18
 
16
19
  /** Legacy-devtools mode lacks generated Builder API tools, so it needs this
@@ -7,14 +7,15 @@
7
7
  * 2. A `.env` file in the project root — its `RECLAIM_PRIVATE_KEY=0x…` line.
8
8
  * 3. A key file holding the raw 0x-hex scalar, at `RECLAIM_PRIVATE_KEY_FILE`
9
9
  * (absolute or project-relative).
10
- * 4. Absent — the caller offers to generate one or asks the dev for a key.
10
+ * 4. Absent — `resolveOrCreateEthKey` generates a local test key; nobody is
11
+ * asked.
11
12
  *
12
13
  * Generated/imported keys are written to the project `.env` (mode 0600, and the
13
14
  * file is added to `.gitignore`). NO `~/.reclaim` caching. This is a LOCAL
14
15
  * signing key only — unrelated to org auth (the org secret) or encryption
15
16
  * (the eth decrypt key).
16
17
  */
17
- import { deriveFromPrivateKey } from '@reclaimprotocol/client'
18
+ import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client'
18
19
  import assert from 'node:assert'
19
20
  import {
20
21
  chmodSync,
@@ -37,8 +38,18 @@ const ENV_VAR = 'RECLAIM_PRIVATE_KEY'
37
38
  /** The dotenv file the agent reads from / writes the key to. */
38
39
  export const ENV_FILE = '.env'
39
40
 
40
- /** Where the resolved eth key came from. */
41
- export type EthKeySource = 'env' | 'dotenv' | 'file'
41
+ /** Where the resolved eth key came from. `generated`: created just now
42
+ * because none existed. */
43
+ export type EthKeySource = 'env' | 'dotenv' | 'file' | 'generated'
44
+
45
+ /** Tells the agent to inform the developer once, after the fact. */
46
+ export const GENERATED_KEY_NOTE =
47
+ 'No proof signing key existed, so a local test signing key was created and '
48
+ + 'saved to the project .env as RECLAIM_PRIVATE_KEY (mode 0600, '
49
+ + 'gitignored). Tell the developer once, in one sentence, that you created '
50
+ + 'it; do not ask for approval. It is a local signing key only: not '
51
+ + 'registered anywhere and unrelated to org auth. A developer who wants '
52
+ + 'their own key can use import_credentials.'
42
53
 
43
54
  export type ResolvedEthKey = {
44
55
  /** Raw 0x-hex 32-byte scalar. */
@@ -63,14 +74,19 @@ export function normalizeEthHex(value: string): string {
63
74
  return '0x' + v.replace(/^0x/, '').toLowerCase()
64
75
  }
65
76
 
66
- /** Read `RECLAIM_PRIVATE_KEY` from a dotenv file (simple KEY=VALUE parse). */
67
- function readDotenvKey(path: string): string | undefined {
77
+ /** Read `name` (default `RECLAIM_PRIVATE_KEY`) from a dotenv file (simple
78
+ * KEY=VALUE parse). */
79
+ export function readDotenvKey(
80
+ path: string,
81
+ name = ENV_VAR,
82
+ ): string | undefined {
68
83
  if(!existsSync(path)) {
69
84
  return undefined
70
85
  }
71
86
 
87
+ const re = new RegExp(`^\\s*${name}\\s*=\\s*(.+?)\\s*$`)
72
88
  for(const line of readFileSync(path, 'utf8').split('\n')) {
73
- const m = line.match(/^\s*RECLAIM_PRIVATE_KEY\s*=\s*(.+?)\s*$/)
89
+ const m = line.match(re)
74
90
  if(m) {
75
91
  return m[1]
76
92
  }
@@ -135,6 +151,24 @@ export function resolveEthKey(
135
151
  return undefined
136
152
  }
137
153
 
154
+ /**
155
+ * Resolve the eth proof-owner key, or generate a local test key and write it
156
+ * to the project `.env` when none is configured. Never prompts.
157
+ */
158
+ export function resolveOrCreateEthKey(
159
+ projectDir = findProjectRoot(process.cwd()),
160
+ ): ResolvedEthKey {
161
+ const existing = resolveEthKey(projectDir)
162
+ if(existing) {
163
+ return existing
164
+ }
165
+
166
+ const { path } = writeEthKey(generateKeypair().privateKey, projectDir)
167
+ const created = resolveEthKey(projectDir)
168
+ assert(created, `generated key not readable from ${path}`)
169
+ return { ...created, source: 'generated' }
170
+ }
171
+
138
172
  /** Append `entry` to the project `.gitignore` if not already present. */
139
173
  export function ensureGitignored(projectDir: string, entry: string) {
140
174
  const path = join(projectDir, '.gitignore')
@@ -5,4 +5,4 @@ export {
5
5
  buildBuilderOrganizationTools,
6
6
  } from './builder-access.ts'
7
7
  export { buildCredentialTools, buildLocalCredentialTools } from './build.ts'
8
- export { readSession } from './session-cache.ts'
8
+ export { readSession, sessionRefreshingFetch } from './session-cache.ts'