@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
@@ -1,9 +1,29 @@
1
+ import { conflict, failedDependency, gatewayTimeout, preconditionFailed, } from '@hapi/boom';
2
+ import assert from 'node:assert';
1
3
  import { spawn } from 'node:child_process';
2
- import { existsSync, mkdirSync } from 'node:fs';
3
- import { homedir, platform } from 'node:os';
4
+ import { existsSync, lstatSync, mkdirSync, readlinkSync, unlinkSync } from 'node:fs';
5
+ import { homedir, hostname, platform } from 'node:os';
4
6
  import { join } from 'node:path';
5
7
  import { chromePath, localAppData } from "../consts.js";
8
+ import { LOGGER } from "../logger.js";
6
9
  import { reclaimHomeDirPath } from "../paths.js";
10
+ import { installChromeForTesting } from "./chrome-for-testing.js";
11
+ /** How long a launched browser gets to answer CDP. A cold first start on a
12
+ * slow disk, or Chrome for Testing's first run, can take well over 10s. */
13
+ export const CDP_READY_TIMEOUT_MS = 30000;
14
+ /** How much of the browser's stderr to keep for error messages. */
15
+ const STDERR_TAIL_CHARS = 4096;
16
+ /** The files Chrome uses on POSIX to claim a profile. `SingletonLock` is a
17
+ * symlink to `<hostname>-<pid>`; the other two point at the IPC socket and a
18
+ * random cookie, and are only meaningful while that process lives. */
19
+ const SINGLETON_FILES = ['SingletonLock', 'SingletonSocket', 'SingletonCookie'];
20
+ const DEFAULT_DEPENDENCIES = {
21
+ platform,
22
+ env: () => process.env,
23
+ chromePath,
24
+ findInstalled: () => findInstalledBrowsers(),
25
+ installChromeForTesting,
26
+ };
7
27
  /**
8
28
  * Build the platform-specific list of Chromium-family binaries to probe.
9
29
  * Order matters: Chrome first, then Chromium, then Edge (Edge is
@@ -15,8 +35,8 @@ import { reclaimHomeDirPath } from "../paths.js";
15
35
  * without admin rights ("Install for me only"). Skipping this was the
16
36
  * #1 first-run failure observed in the field.
17
37
  */
18
- function browserCandidates() {
19
- switch (platform()) {
38
+ export function browserCandidates(os = platform()) {
39
+ switch (os) {
20
40
  case 'darwin':
21
41
  return [
22
42
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
@@ -50,26 +70,105 @@ function browserCandidates() {
50
70
  return [];
51
71
  }
52
72
  }
73
+ /** The candidates that exist on this machine, in preference order. */
74
+ export function findInstalledBrowsers(candidates = browserCandidates()) {
75
+ return candidates.filter((c) => existsSync(c));
76
+ }
53
77
  export function defaultProfileDir() {
54
78
  return join(reclaimHomeDirPath(), 'chrome-profile');
55
79
  }
56
80
  export function findChromeBinary() {
57
81
  const candidates = browserCandidates();
58
- for (const c of candidates) {
59
- if (existsSync(c)) {
60
- return c;
61
- }
82
+ const found = chromePath() ?? findInstalledBrowsers(candidates)[0];
83
+ if (found) {
84
+ return found;
62
85
  }
63
86
  throw new Error(`No Chromium-family browser found on ${platform()}. Tried:\n ` +
64
87
  candidates.join('\n ') +
65
88
  '\nSet RECLAIM_AGENT_CHROME_PATH to point at ' +
66
89
  'your chrome.exe / msedge.exe / chromium binary.');
67
90
  }
68
- export function launchChrome(opts) {
69
- const binary = chromePath() ?? findChromeBinary();
70
- if (!existsSync(opts.profileDir)) {
71
- mkdirSync(opts.profileDir, { recursive: true });
91
+ /**
92
+ * Why a visible browser cannot open here, or undefined when it can. Only
93
+ * Linux needs a display server named in the environment; an MCP server
94
+ * started outside the desktop session (a sandboxed agent, a service) often
95
+ * has neither variable, and Chrome then exits before CDP ever answers.
96
+ */
97
+ export function missingDisplayReason(os = platform(), env = process.env) {
98
+ if (os !== 'linux' || env.WAYLAND_DISPLAY || env.DISPLAY) {
99
+ return undefined;
100
+ }
101
+ return 'No display: neither WAYLAND_DISPLAY nor DISPLAY is set in the '
102
+ + 'Reclaim MCP server\'s environment, so a visible browser cannot open. '
103
+ + 'Start the agent from the desktop session, or pass the variables to '
104
+ + 'the MCP server (for example DISPLAY=:0, or WAYLAND_DISPLAY=wayland-0 '
105
+ + 'with XDG_RUNTIME_DIR), then restart the agent.';
106
+ }
107
+ /**
108
+ * Launch a visible browser on this machine with the given profile, and wait
109
+ * until it answers CDP.
110
+ *
111
+ * Uses `RECLAIM_AGENT_CHROME_PATH` when set, else the first installed browser
112
+ * (Chrome, then Chromium, then Edge). When none is installed, or that one
113
+ * fails to start, it falls back to Chrome for Testing, downloaded once into
114
+ * `~/.reclaim/browsers`. A missing display fails fast instead: no browser can
115
+ * open without one, so a download would only waste time.
116
+ */
117
+ export async function launchLocalBrowser(opts, dependencies = {}) {
118
+ const deps = { ...DEFAULT_DEPENDENCIES, ...dependencies };
119
+ const noDisplay = missingDisplayReason(deps.platform(), deps.env());
120
+ assert(!noDisplay, preconditionFailed(noDisplay));
121
+ // A live owner would make every launch below fail the same way, so refuse
122
+ // once with the reason instead of trying Chrome for Testing too.
123
+ releaseStaleProfileLock(opts.profileDir, deps.platform());
124
+ const override = deps.chromePath();
125
+ const preferred = override ?? deps.findInstalled()[0];
126
+ let fallbackReason = 'No Chrome, Chromium, or Edge is installed.';
127
+ if (preferred) {
128
+ try {
129
+ return {
130
+ binary: preferred,
131
+ source: override ? 'override' : 'installed',
132
+ process: await startChrome(preferred, opts),
133
+ downloaded: false,
134
+ };
135
+ }
136
+ catch (err) {
137
+ fallbackReason = errorMessage(err);
138
+ LOGGER.warn({ binary: preferred, reason: fallbackReason }, 'local browser failed to start; trying Chrome for Testing');
139
+ }
140
+ }
141
+ let cft;
142
+ try {
143
+ cft = await deps.installChromeForTesting();
144
+ }
145
+ catch (err) {
146
+ throw failedDependency(`${fallbackReason}\nChrome for Testing could not be installed into `
147
+ + `~/.reclaim/browsers: ${errorMessage(err)}`);
72
148
  }
149
+ try {
150
+ return {
151
+ binary: cft.executablePath,
152
+ source: 'chrome-for-testing',
153
+ process: await startChrome(cft.executablePath, opts),
154
+ downloaded: cft.downloaded,
155
+ fallbackReason,
156
+ };
157
+ }
158
+ catch (err) {
159
+ throw failedDependency(`${fallbackReason}\nChrome for Testing (${cft.executablePath}) also `
160
+ + `failed: ${errorMessage(err)}`);
161
+ }
162
+ }
163
+ /**
164
+ * Spawn `binary` on `opts.profileDir` and wait for CDP. On failure the
165
+ * process is stopped and the error says why: it could not be spawned, it
166
+ * exited (with its code and the tail of its stderr), or it never answered.
167
+ */
168
+ export async function startChrome(binary, opts) {
169
+ const timeoutMs = opts.timeoutMs ?? CDP_READY_TIMEOUT_MS;
170
+ mkdirSync(opts.profileDir, { recursive: true });
171
+ releaseStaleProfileLock(opts.profileDir);
73
172
  const args = [
74
173
  `--remote-debugging-port=${opts.port}`,
75
174
  `--user-data-dir=${opts.profileDir}`,
@@ -80,24 +179,145 @@ export function launchChrome(opts) {
80
179
  args.push('--headless=new', '--disable-gpu');
81
180
  }
82
181
  const child = spawn(binary, args, {
83
- stdio: 'ignore',
182
+ stdio: ['ignore', 'ignore', 'pipe'],
84
183
  detached: false,
85
184
  });
86
- return child;
87
- }
88
- export async function waitForCdpReady(port, timeoutMs = 10000) {
185
+ // Drained for the browser's whole life, not just until it is ready: a
186
+ // full pipe would block its writes.
187
+ let stderr = '';
188
+ child.stderr?.setEncoding('utf8');
189
+ child.stderr?.on('data', (chunk) => {
190
+ stderr = (stderr + chunk).slice(-STDERR_TAIL_CHARS);
191
+ });
192
+ let spawnError;
193
+ child.once('error', (err) => {
194
+ spawnError = err;
195
+ });
89
196
  const deadline = Date.now() + timeoutMs;
90
197
  while (Date.now() < deadline) {
198
+ if (await cdpAnswers(opts.port)) {
199
+ return child;
200
+ }
201
+ if (spawnError) {
202
+ throw failedDependency(`Could not start ${binary}: ${spawnError.message}`);
203
+ }
204
+ if (child.exitCode !== null || child.signalCode !== null) {
205
+ throw failedDependency(`${binary} exited with ${child.exitCode !== null
206
+ ? `code ${child.exitCode}`
207
+ : `signal ${child.signalCode}`} before CDP answered on port `
208
+ + `${opts.port}.${stderrTail()}`);
209
+ }
210
+ await sleep(150);
211
+ }
212
+ await stopProcess(child);
213
+ throw gatewayTimeout(`${binary} did not answer CDP on port ${opts.port} within `
214
+ + `${timeoutMs}ms.${stderrTail()}`);
215
+ function stderrTail() {
216
+ const tail = stderr.trim();
217
+ return tail ? `\nBrowser stderr (last ${tail.length} chars):\n${tail}` : '';
218
+ }
219
+ }
220
+ /**
221
+ * Remove a dead browser's claim on `profileDir` so a new launch can take it.
222
+ *
223
+ * Chrome on POSIX claims a profile with `SingletonLock -> <hostname>-<pid>`.
224
+ * If that process is gone (a crash, a killed session) the links stay behind
225
+ * and the next launch hands off to the missing owner and exits. A lock whose
226
+ * PID is alive on this host belongs to a running browser, and is left alone:
227
+ * that browser must be closed first. A lock from another hostname is treated
228
+ * as stale: `~/.reclaim` is local, and a sandbox or container that changes the
229
+ * hostname between runs is the usual way to get one. Callers reach this only
230
+ * after nothing answered CDP on the port.
231
+ */
232
+ export function releaseStaleProfileLock(profileDir, os = platform()) {
233
+ // Windows locks the profile with a plain file the OS releases on exit.
234
+ if (os === 'win32') {
235
+ return;
236
+ }
237
+ const target = readLink(join(profileDir, 'SingletonLock'));
238
+ const owner = target?.match(/^(.+)-(\d+)$/);
239
+ if (owner?.[1] === hostname() && processAlive(Number(owner[2]))) {
240
+ throw conflict(`The browser profile ${profileDir} is in use by a running browser `
241
+ + `(PID ${owner[2]}) that is not answering CDP on the requested port. `
242
+ + 'Close that browser window, then retry.');
243
+ }
244
+ const removed = SINGLETON_FILES.filter((name) => {
245
+ const path = join(profileDir, name);
246
+ if (readLink(path) === undefined) {
247
+ return false;
248
+ }
91
249
  try {
92
- const res = await fetch(`http://127.0.0.1:${port}/json/version`);
93
- if (res.ok) {
94
- return;
95
- }
250
+ unlinkSync(path);
251
+ return true;
252
+ }
253
+ catch (err) {
254
+ LOGGER.warn({ err, path }, 'could not remove a stale profile lock');
255
+ return false;
96
256
  }
97
- catch {
98
- // retry
257
+ });
258
+ if (removed.length) {
259
+ LOGGER.info({ profileDir, removed, owner: target }, 'removed a stale browser profile lock');
260
+ }
261
+ }
262
+ /**
263
+ * Wait until something answers CDP on `port`, polling every 150ms.
264
+ */
265
+ export async function waitForCdpReady(port, timeoutMs = CDP_READY_TIMEOUT_MS) {
266
+ const deadline = Date.now() + timeoutMs;
267
+ while (Date.now() < deadline) {
268
+ if (await cdpAnswers(port)) {
269
+ return;
99
270
  }
100
- await new Promise((r) => setTimeout(r, 150));
271
+ await sleep(150);
101
272
  }
102
- throw new Error(`Chrome CDP did not become ready on port ${port} within ${timeoutMs}ms`);
273
+ throw gatewayTimeout(`Chrome CDP did not become ready on port ${port} within ${timeoutMs}ms`);
274
+ }
275
+ /** A listener that accepts but never answers (a stale non-CDP service on the
276
+ * port) fails the poll after 2s instead of outliving the launch deadline. */
277
+ async function cdpAnswers(port) {
278
+ try {
279
+ const res = await fetch(`http://127.0.0.1:${port}/json/version`, {
280
+ signal: AbortSignal.timeout(2000),
281
+ });
282
+ return res.ok;
283
+ }
284
+ catch {
285
+ return false;
286
+ }
287
+ }
288
+ /** SIGTERM, then SIGKILL if it lingers, and wait for the exit either way, so
289
+ * the profile lock is released before anything launches on it again. */
290
+ async function stopProcess(child) {
291
+ if (child.exitCode !== null || child.signalCode !== null) {
292
+ return;
293
+ }
294
+ const exited = new Promise((r) => child.once('exit', () => r()));
295
+ child.kill('SIGTERM');
296
+ const timer = setTimeout(() => child.kill('SIGKILL'), 3000);
297
+ await exited;
298
+ clearTimeout(timer);
299
+ }
300
+ function readLink(path) {
301
+ try {
302
+ return lstatSync(path).isSymbolicLink() ? readlinkSync(path) : undefined;
303
+ }
304
+ catch {
305
+ return undefined;
306
+ }
307
+ }
308
+ function processAlive(pid) {
309
+ try {
310
+ process.kill(pid, 0);
311
+ return true;
312
+ }
313
+ catch (err) {
314
+ // EPERM: it exists, it just belongs to someone else.
315
+ return err instanceof Error && 'code' in err && err.code === 'EPERM';
316
+ }
317
+ }
318
+ function errorMessage(err) {
319
+ return err instanceof Error ? err.message : String(err);
320
+ }
321
+ function sleep(ms) {
322
+ return new Promise((r) => setTimeout(r, ms));
103
323
  }
package/lib/logger.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import pino, { stdTimeFunctions } from 'pino';
2
2
  import { LOG_LEVEL } from "./consts.js";
3
+ // stderr, not pino's default stdout: the MCP server speaks JSON-RPC over
4
+ // stdout, so a log line there corrupts the protocol stream.
3
5
  export const LOGGER = pino({
4
6
  name: '@reclaimprotocol/agent',
5
7
  timestamp: stdTimeFunctions.isoTime,
6
8
  base: { hostname: undefined, pid: undefined },
7
- });
9
+ }, pino.destination(2));
8
10
  LOGGER.level = LOG_LEVEL;
@@ -51,9 +51,9 @@ const TOPIC_SUMMARIES = {
51
51
  'auth': 'Signing in to the backend, and which token authorizes what.',
52
52
  'devtools-migration': 'Importing a public legacy provider with the same '
53
53
  + 'UUID.',
54
- 'browser': 'Choosing a browser source (local Chrome, the Reclaim runtime in '
55
- + 'Docker, a remote Builder browser, or a custom CDP endpoint), '
56
- + 'geolocation, live view, and launch troubleshooting.',
54
+ 'browser': 'The default local browser, the opt-in modes (attach, the '
55
+ + 'Reclaim runtime in Docker, a remote Builder browser, a custom CDP '
56
+ + 'endpoint), geolocation, live view, and launch troubleshooting.',
57
57
  'capture': 'Tabs, sign-in detection, capturing through the navigation '
58
58
  + 'script, and picking an authenticated request to prove.',
59
59
  'draft': 'The OPRF/privacy decision, propose_provider, and the canonical '
package/lib/mcp/start.js CHANGED
@@ -10,8 +10,9 @@ import { OLD_MODE_INSTRUCTIONS } from "../old/instructions.js";
10
10
  import { ReclaimSdkClient } from "../old/sdk-client.js";
11
11
  import { buildOldBackendTools } from "../old/tools/build.js";
12
12
  import { NAME, VERSION } from "../pkg.js";
13
+ import { startZkFilesDownload } from "../proof/zk-files.js";
13
14
  import { registerTools } from "./api/tools.js";
14
- import { buildAgentState, buildAgentTools, buildBuilderAuthenticationTools, buildBuilderOrganizationTools, buildCredentialTools, buildLocalCredentialTools, buildModeTools, buildVerificationTools, disposeAgentState, readSession, releaseAllListeners, stopAllForwarders, } from "./tools/index.js";
15
+ import { buildAgentState, buildAgentTools, buildBuilderAuthenticationTools, buildBuilderOrganizationTools, buildCredentialTools, buildLocalCredentialTools, buildModeTools, buildVerificationTools, disposeAgentState, readSession, releaseAllListeners, sessionRefreshingFetch, stopAllForwarders, } from "./tools/index.js";
15
16
  import { BUILDER_MODE_INSTRUCTIONS } from "./instructions.js";
16
17
  import { registerToolList } from "./server.js";
17
18
  export async function startMcpServer() {
@@ -31,11 +32,19 @@ export async function startMcpServer() {
31
32
  const agentToolsEnabled = !agentDisabled();
32
33
  /** The agent state this server registered, for shutdown to release. */
33
34
  let agentState;
35
+ const envToken = apiToken();
34
36
  const builderClient = new ReclaimClient({
35
37
  baseUrl: apiUrl(),
36
38
  // Builder access is orthogonal to publishing mode. A legacy-devtools
37
39
  // session can authenticate here solely to allocate a remote browser.
38
- token: apiToken() || readSession()?.token,
40
+ token: envToken || readSession()?.token,
41
+ // The cached sign-in refreshes itself; an explicit RECLAIM_API_TOKEN
42
+ // takes precedence and is sent as is.
43
+ ...(envToken
44
+ ? {}
45
+ : {
46
+ fetch: sessionRefreshingFetch(oldMode ? 'authenticate_builder' : 'reclaim_authenticate'),
47
+ }),
39
48
  headers: { 'User-Agent': `${NAME}/${VERSION}` },
40
49
  });
41
50
  // Backend-mode toggle — available in both modes and regardless of whether
@@ -99,6 +108,12 @@ export async function startMcpServer() {
99
108
  }
100
109
  const transport = new StdioServerTransport();
101
110
  await server.connect(transport);
111
+ // Fetch the proof files in the background so the first proof doesn't wait
112
+ // for them. Progress goes to stderr; `check_attestor` reports the state,
113
+ // and every proof still awaits the same download.
114
+ if (agentToolsEnabled) {
115
+ startZkFilesDownload();
116
+ }
102
117
  }
103
118
  /** How long teardown gets before the process leaves anyway. */
104
119
  const SHUTDOWN_GRACE_MS = 10000;
@@ -1,6 +1,7 @@
1
1
  import type { ReclaimClient } from '@reclaimprotocol/client/api';
2
2
  import { type CdpHandles, connectCdp, connectCdpUrl } from '../../../cdp/client.ts';
3
3
  import { dockerAvailable, startContainer } from '../../../cdp/container.ts';
4
+ import { launchLocalBrowser, type LocalBrowser, waitForCdpReady } from '../../../cdp/launcher.ts';
4
5
  import type { ConfigStore } from '../../../config/store.ts';
5
6
  import { type RegisteredTool } from '../../server.ts';
6
7
  export type BrowserMode = 'dedicated' | 'attach' | 'builder' | 'custom' | 'container';
@@ -34,6 +35,8 @@ export interface AttachState {
34
35
  viewerPort?: number;
35
36
  /** Whether the runtime image had to be downloaded on this attach. */
36
37
  imagePulled?: boolean;
38
+ /** `dedicated` mode: the browser this attach launched, if it launched one. */
39
+ launched?: Omit<LocalBrowser, 'process'>;
37
40
  disposeRemote?: () => Promise<void>;
38
41
  }
39
42
  export interface AttachRef {
@@ -46,6 +49,9 @@ interface AttachDependencies {
46
49
  /** Injected so container mode is testable without Docker installed. */
47
50
  dockerAvailable: typeof dockerAvailable;
48
51
  startContainer: typeof startContainer;
52
+ /** Injected so the default launch is testable without a real browser. */
53
+ launchLocal: typeof launchLocalBrowser;
54
+ waitForCdp: typeof waitForCdpReady;
49
55
  }
50
56
  export declare function attachTool(state: AttachRef, builderClient?: ReclaimClient, beforeReplace?: () => Promise<void>, dependencies?: Partial<AttachDependencies>): RegisteredTool;
51
57
  export declare function disposeAttached(state: AttachRef): Promise<void>;
@@ -3,8 +3,7 @@ import assert from 'node:assert';
3
3
  import { connectCdp, connectCdpUrl, } from "../../../cdp/client.js";
4
4
  import { dockerAvailable, dockerMissingMessage, startContainer, } from "../../../cdp/container.js";
5
5
  import { suggestCountryFromIp } from "../../../cdp/geo.js";
6
- import { defaultProfileDir, launchChrome, waitForCdpReady, } from "../../../cdp/launcher.js";
7
- import { defaultConfigPath } from "../../../config/store.js";
6
+ import { defaultProfileDir, launchLocalBrowser, waitForCdpReady, } from "../../../cdp/launcher.js";
8
7
  import { defineTool } from "../../server.js";
9
8
  import { ensureLiveViewUrl } from "./live-view-url.js";
10
9
  /**
@@ -16,84 +15,13 @@ import { ensureLiveViewUrl } from "./live-view-url.js";
16
15
  * show — one on this machine is watched by looking at it.
17
16
  */
18
17
  export const SHAREABLE_MODES = new Set(['container']);
19
- /** Browser sources returned when the caller omits `mode`. Keep selection detail
20
- * here instead of repeating it in the always-loaded tool description. */
21
- const BROWSER_OPTIONS = [
22
- {
23
- mode: 'dedicated',
24
- useCase: 'Working on your own machine',
25
- label: 'Local Chrome, launched for you',
26
- detail: 'Launches Chrome with a dedicated profile under '
27
- + '~/.reclaim/chrome-profile. Nothing to set up, and the profile '
28
- + 'persists, so a site you sign in to stays signed in. The easiest '
29
- + 'choice when you are doing the work yourself.',
30
- requires: [],
31
- liveView: 'None, and none needed — the browser is on your screen.',
32
- },
33
- {
34
- mode: 'attach',
35
- useCase: 'Working on your own machine',
36
- label: 'The Chrome you already have open',
37
- detail: 'Connects to a Chrome you started yourself, keeping the tabs, '
38
- + 'extensions and logins already in it. That Chrome must have been '
39
- + 'started with BOTH --remote-debugging-port AND a non-default '
40
- + '--user-data-dir: since Chrome 136 the debug flag is silently '
41
- + 'ignored on the default profile.',
42
- requires: ['port (default 9222)'],
43
- liveView: 'None, and none needed — the browser is on your screen.',
44
- },
45
- {
46
- mode: 'container',
47
- useCase: 'Someone else has to sign in, or you need a phone',
48
- label: 'Reclaim runtime in Docker — free, runs on your machine',
49
- detail: 'Runs the Reclaim browser runtime (the same image the remote browser '
50
- + 'uses) as a container on this machine, and gives you a link you can '
51
- + 'send to whoever holds the account. They open it in any browser, '
52
- + 'including a phone, and can see and click in it. Free — it is your '
53
- + 'Docker and your bandwidth. Costs a 432 MB first-run download and '
54
- + 'about 2 GB of RAM. The image is amd64, so Apple Silicon runs it '
55
- + 'translated. OrbStack handles that itself; Docker Desktop needs '
56
- + 'Rosetta enabled.',
57
- requires: ['Docker running'],
58
- liveView: 'Built in. This call returns a local `viewerUrl`, and '
59
- + 'share_browser_view turns that into a public link for the person '
60
- + 'helping you.',
61
- },
62
- {
63
- mode: 'builder',
64
- useCase: 'Someone else has to sign in, or you need a phone',
65
- label: 'Remote browser, hosted by Reclaim — CHARGEABLE',
66
- detail: 'A browser allocated in Reclaim\'s cloud, billed against the '
67
- + 'organization\'s quota. Nothing to install, and the shareable link '
68
- + 'comes back immediately. It is also the only way to browse a site '
69
- + 'from a SPECIFIC COUNTRY — its whole egress is routed there. '
70
- + 'Geolocation is optional: omit countryCode to be offered a '
71
- + 'suggestion, or pass skipCountry to allocate without geo-routing.',
72
- requires: [
73
- 'Builder authentication',
74
- 'orgId (auto-resolved when you have only one)',
75
- 'quota — this one costs money',
76
- ],
77
- liveView: 'Included. This call returns a hosted liveViewUrl to pass on; '
78
- + 'share_browser_view does not apply.',
79
- },
80
- {
81
- mode: 'custom',
82
- useCase: 'Advanced',
83
- label: 'A CDP endpoint you supply',
84
- detail: 'Connects to any ws:// or wss:// CDP websocket — a browser you run '
85
- + 'elsewhere, or another tool\'s. The URL is treated as a credential: '
86
- + 'never logged, persisted, or returned. Only pick this if you were '
87
- + 'given an endpoint and know why.',
88
- requires: ['cdpUrl'],
89
- liveView: 'None, unless you pass your own liveViewUrl alongside the cdpUrl.',
90
- },
91
- ];
92
18
  const DEFAULT_DEPENDENCIES = {
93
19
  connectLocal: connectCdp,
94
20
  connectRemote: connectCdpUrl,
95
21
  dockerAvailable,
96
22
  startContainer,
23
+ launchLocal: launchLocalBrowser,
24
+ waitForCdp: waitForCdpReady,
97
25
  };
98
26
  export function attachTool(state, builderClient, beforeReplace,
99
27
  // Partial so a caller can stub one thing without restating the rest — the
@@ -104,16 +32,15 @@ dependencies = {}) {
104
32
  };
105
33
  return defineTool({
106
34
  name: 'attach_browser',
107
- description: 'Connects the authoring tools to a browser that loads the '
108
- + 'developer\'s sign-in page and target site. `builder` and '
109
- + '`container` run the browser remotely or in Docker, away from the '
110
- + 'developer\'s own profile; `dedicated`, `attach`, and `custom` use a '
111
- + 'local or supplied Chrome. Call without `mode` to receive the '
112
- + 'options, ask the developer to choose, then call again once with '
113
- + 'the chosen mode. `builder` consumes quota. Only `container` and '
114
- + '`builder` support a shareable live view. Relay returned live-view '
115
- + 'URLs verbatim. See how_it_works({ topic: "browser" }) for '
116
- + 'selection, geolocation, and lifecycle.',
35
+ description: 'Opens a visible browser on the developer\'s own machine for '
36
+ + 'sign-in and capture. Call it with no arguments and do not ask '
37
+ + 'which browser to use: it launches installed Chrome (else '
38
+ + 'Chromium, Edge, or a one-time Chrome for Testing download) with '
39
+ + 'the persistent Reclaim profile. Pass `mode` only when the '
40
+ + 'developer explicitly asks for that mode: `attach` (their running '
41
+ + 'Chrome on `port`), `container` (Docker), `custom` (`cdpUrl`), or '
42
+ + '`builder` (billed). Never offer or pick those yourself. See '
43
+ + 'how_it_works({ topic: "browser" }).',
117
44
  inputSchema: {
118
45
  type: 'object',
119
46
  properties: {
@@ -122,6 +49,8 @@ dependencies = {}) {
122
49
  enum: [
123
50
  'dedicated', 'attach', 'container', 'builder', 'custom',
124
51
  ],
52
+ description: 'Omit for the default local browser (`dedicated`). Set '
53
+ + 'only to a mode the developer explicitly asked for.',
125
54
  },
126
55
  attachMode: {
127
56
  type: 'string',
@@ -159,31 +88,12 @@ dependencies = {}) {
159
88
  annotations: { openWorldHint: true, destructiveHint: false },
160
89
  }, async (parsed) => {
161
90
  const persisted = state.config.read()?.browser_agent;
162
- // Deliberately NOT falling back to the persisted mode: which browser
163
- // to drive is the developer's call every time (a remote one costs
164
- // quota, a local one exposes their own profile), and silently reusing
165
- // last session's answer takes that decision away from them. The
166
- // persisted choice is offered back as a default to suggest, not
167
- // applied.
168
- const mode = parsed.mode ?? parsed.attachMode;
169
- if (!mode) {
170
- return {
171
- needsChoice: true,
172
- message: 'Ask FIRST whether they will be doing this themselves or '
173
- + 'someone else has to sign in — that answer decides half the '
174
- + 'list. Themselves: `dedicated` or `attach`. Someone else, or '
175
- + 'a phone-only site: `container` (free, needs Docker) or '
176
- + '`builder` (no install, chargeable) — the only two that give '
177
- + 'you a link to send. Then present the matching options and '
178
- + 'call attach_browser again with the `mode` they pick. Do not '
179
- + 'pick one for them.',
180
- options: BROWSER_OPTIONS,
181
- ...(persisted?.attachMode
182
- ? { previouslyUsed: persisted.attachMode }
183
- : {}),
184
- configPath: defaultConfigPath(),
185
- };
186
- }
91
+ // The default is a visible browser on the developer's own machine,
92
+ // with no question first. Every other mode costs something (Docker,
93
+ // quota, the developer's own profile) and is used only when the
94
+ // developer asked for it by name, so a persisted choice is never
95
+ // reapplied either.
96
+ const mode = parsed.mode ?? parsed.attachMode ?? 'dedicated';
187
97
  let orgId = parsed.orgId;
188
98
  if (mode === 'builder' && !orgId) {
189
99
  assert(builderClient, badRequest('Builder browser service is unavailable'));
@@ -279,7 +189,17 @@ dependencies = {}) {
279
189
  ...(next.countryCode ? { countryCode: next.countryCode } : {}),
280
190
  ...(next.containerName ? { containerName: next.containerName } : {}),
281
191
  ...(canShare ? { liveViewAvailable: true } : {}),
192
+ ...(next.launched ? { browser: next.launched.source } : {}),
282
193
  _notes: [
194
+ ...(next.launched?.source === 'chrome-for-testing'
195
+ ? ['Launched Chrome for Testing'
196
+ + (next.launched.downloaded
197
+ ? ', downloaded just now into ~/.reclaim/browsers (later '
198
+ + 'launches reuse it)'
199
+ : ' from ~/.reclaim/browsers')
200
+ + `, because: ${next.launched.fallbackReason}. Mention `
201
+ + 'this to the developer in one line.']
202
+ : []),
283
203
  ...(next.imagePulled
284
204
  ? ['The browser runtime image was downloaded just now (about '
285
205
  + '432 MB, about 1.3 GB on disk). Mention it — later attaches '
@@ -303,9 +223,12 @@ dependencies = {}) {
303
223
  : []),
304
224
  ...(!localView && !hostedView
305
225
  ? ['This browser has no live view: it runs on the developer\'s '
306
- + 'own machine, so they watch it by looking at it. If someone '
307
- + 'ELSE needs to see or drive it, say that `container` mode '
308
- + 'runs a browser with a shareable live view built in.']
226
+ + 'own machine, so they watch it by looking at it. Do not '
227
+ + 'offer other modes. Tell the developer once, as a side '
228
+ + 'note, that if a volunteer is helping them sign in, a '
229
+ + 'shareable link can be set up (`container` mode and '
230
+ + 'share_browser_view); do not ask or set it up unless they '
231
+ + 'ask.']
309
232
  : []),
310
233
  ],
311
234
  };
@@ -398,24 +321,41 @@ async function connect(mode, parsed, persisted, builderClient, dependencies) {
398
321
  }
399
322
  }
400
323
  const port = parsed.port ?? persisted?.port ?? 9222;
324
+ let launched;
401
325
  if (mode === 'dedicated') {
402
326
  try {
403
- await waitForCdpReady(port, 500);
327
+ // Reuse the browser an earlier attach launched on this port.
328
+ await dependencies.waitForCdp(port, 500);
404
329
  }
405
330
  catch {
406
- launchChrome({ port, profileDir: defaultProfileDir() });
407
- await waitForCdpReady(port, 10000);
331
+ const browser = await dependencies
332
+ .launchLocal({ port, profileDir: defaultProfileDir() });
333
+ // The browser outlives this attach on purpose, so its process handle
334
+ // is not kept.
335
+ launched = {
336
+ binary: browser.binary,
337
+ source: browser.source,
338
+ downloaded: browser.downloaded,
339
+ ...(browser.fallbackReason
340
+ ? { fallbackReason: browser.fallbackReason }
341
+ : {}),
342
+ };
408
343
  }
409
344
  }
410
345
  else {
411
- await waitForCdpReady(port, 2000).catch(() => {
346
+ await dependencies.waitForCdp(port, 2000).catch(() => {
412
347
  throw new Error(`attach mode: nothing answering CDP on port ${port}. Chrome must `
413
348
  + `be launched with --remote-debugging-port=${port} AND `
414
349
  + '--user-data-dir=<some-non-default-path>. Chrome 136+ ignores '
415
350
  + 'the debugging flag for the default profile.');
416
351
  });
417
352
  }
418
- return { handles: await dependencies.connectLocal(port), mode, port };
353
+ return {
354
+ handles: await dependencies.connectLocal(port),
355
+ mode,
356
+ port,
357
+ ...(launched ? { launched } : {}),
358
+ };
419
359
  }
420
360
  async function disposeState(state) {
421
361
  try {