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