@reclaimprotocol/agent 0.1.36 → 0.1.38

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 (104) 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 +8 -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/example-app.d.ts +2 -0
  24. package/lib/mcp/tools/agent/example-app.js +167 -0
  25. package/lib/mcp/tools/agent/pod-proof.js +5 -1
  26. package/lib/mcp/tools/agent/proof.d.ts +28 -0
  27. package/lib/mcp/tools/agent/proof.js +72 -0
  28. package/lib/mcp/tools/agent/shared.d.ts +8 -1
  29. package/lib/mcp/tools/agent/shared.js +98 -19
  30. package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
  31. package/lib/mcp/tools/agent/start-integration.js +10 -6
  32. package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
  33. package/lib/mcp/tools/authenticate/authenticate.js +212 -116
  34. package/lib/mcp/tools/authenticate/build.d.ts +2 -2
  35. package/lib/mcp/tools/authenticate/build.js +6 -3
  36. package/lib/mcp/tools/authenticate/builder-access.js +5 -2
  37. package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
  38. package/lib/mcp/tools/authenticate/eth-key.js +29 -5
  39. package/lib/mcp/tools/authenticate/index.d.ts +1 -1
  40. package/lib/mcp/tools/authenticate/index.js +1 -1
  41. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
  42. package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
  43. package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
  44. package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
  45. package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
  46. package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
  47. package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
  48. package/lib/mcp/tools/authenticate/session-cache.js +95 -4
  49. package/lib/mcp/tools/mode/mode.js +6 -6
  50. package/lib/old/login-cdp.js +2 -3
  51. package/lib/proof/attestor.js +2 -3
  52. package/lib/proof/check-attestor.d.ts +12 -2
  53. package/lib/proof/check-attestor.js +40 -10
  54. package/lib/proof/check-matchers.d.ts +36 -0
  55. package/lib/proof/check-matchers.js +161 -0
  56. package/lib/proof/zk-files.d.ts +50 -11
  57. package/lib/proof/zk-files.js +259 -63
  58. package/lib/provider/draft.d.ts +24 -1
  59. package/lib/provider/draft.js +135 -0
  60. package/lib/provider/matcher.js +171 -4
  61. package/package.json +6 -4
  62. package/skill/SKILL.builder.md +7 -2
  63. package/skill/SKILL.common.md +90 -70
  64. package/skill/SKILL.old.md +3 -3
  65. package/skill/builder-mode.md +35 -5
  66. package/skill/old-mode.md +18 -13
  67. package/skill/start-integration.md +50 -33
  68. package/skill/verification-client-integration.md +21 -6
  69. package/src/authoring/backend.ts +12 -1
  70. package/src/authoring/tools.ts +48 -7
  71. package/src/authoring/util.ts +64 -0
  72. package/src/cdp/chrome-for-testing.ts +66 -0
  73. package/src/cdp/geo.ts +36 -21
  74. package/src/cdp/launcher.ts +328 -24
  75. package/src/logger.ts +3 -1
  76. package/src/mcp/instructions.ts +3 -3
  77. package/src/mcp/start.ts +20 -1
  78. package/src/mcp/tools/agent/attach.ts +65 -128
  79. package/src/mcp/tools/agent/build.ts +9 -0
  80. package/src/mcp/tools/agent/capture.ts +54 -11
  81. package/src/mcp/tools/agent/check-attestor.ts +15 -7
  82. package/src/mcp/tools/agent/draft-tools.ts +234 -0
  83. package/src/mcp/tools/agent/example-app.ts +227 -0
  84. package/src/mcp/tools/agent/pod-proof.ts +5 -1
  85. package/src/mcp/tools/agent/proof.ts +110 -1
  86. package/src/mcp/tools/agent/shared.ts +130 -19
  87. package/src/mcp/tools/agent/start-integration.ts +10 -6
  88. package/src/mcp/tools/authenticate/authenticate.ts +276 -131
  89. package/src/mcp/tools/authenticate/build.ts +6 -3
  90. package/src/mcp/tools/authenticate/builder-access.ts +5 -2
  91. package/src/mcp/tools/authenticate/eth-key.ts +41 -7
  92. package/src/mcp/tools/authenticate/index.ts +1 -1
  93. package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
  94. package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
  95. package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
  96. package/src/mcp/tools/authenticate/session-cache.ts +125 -5
  97. package/src/mcp/tools/mode/mode.ts +6 -6
  98. package/src/old/login-cdp.ts +2 -3
  99. package/src/proof/attestor.ts +2 -3
  100. package/src/proof/check-attestor.ts +55 -12
  101. package/src/proof/check-matchers.ts +227 -0
  102. package/src/proof/zk-files.ts +357 -65
  103. package/src/provider/draft.ts +215 -7
  104. package/src/provider/matcher.ts +237 -4
package/README.md CHANGED
@@ -22,12 +22,12 @@ This package **is** the Reclaim MCP server (bin: `reclaim-mcp-server`). It depen
22
22
 
23
23
  **Prerequisites:**
24
24
 
25
- - **Node 20 or later.** Any line works, including non-LTS ones such as 25, but a current LTS line (22 or 24) is recommended.
26
- - **A local Chrome, Chromium, or Edge**, used for capture and for the dashboard login.
25
+ - **Node 22.12 or later.** Any line from there works, including non-LTS ones such as 25, but a current LTS line (22 or 24) is recommended.
26
+ - **A local Chrome, Chromium, or Edge**, used for capture and for the dashboard login. Without one, the agent downloads Chrome for Testing once into `~/.reclaim/browsers`. On Linux the MCP server needs `DISPLAY` or `WAYLAND_DISPLAY` set, because the browser opens a visible window.
27
27
  - **Docker** — only for `attach_browser`'s `container` mode, which runs the Reclaim browser runtime (the same image the hosted browser uses) and gives you a link you can send to whoever holds the account. It is the free way to get that link; the hosted remote browser is the other, and it is chargeable. Not needed if you are doing the work yourself. The image is amd64, so Apple Silicon runs it translated. OrbStack handles that itself; Docker Desktop needs Rosetta enabled. `RECLAIM_BROWSER_RUNTIME_IMAGE` overrides the pinned digest.
28
28
  - **`cloudflared`** — only to share a `container` browser with someone remote. The MCP server does not run or install it: `share_browser_view` returns the command, and the developer chooses to run `cloudflared tunnel --url http://localhost:<port>` themselves (recommended) or approve the agent running it. Install it first.
29
29
 
30
- The first time you call `run_proof`, the agent downloads the ZK circuit files it needs (about 280 MB), so allow a moment for that first proof. The download is deferred to the first proof rather than run at install time, so the MCP server itself starts immediately.
30
+ The MCP server downloads the ZK proof files it needs (about 1.5 MB) in the background when it starts, so it starts immediately and the files are usually ready by the first `run_proof`. If that download fails, it falls back to the full circuit archive (about 350 MB). `check_attestor` reports where the download stands.
31
31
 
32
32
  It runs as a **stdio MCP server**. Point any MCP-capable agent at this command:
33
33
 
@@ -89,7 +89,7 @@ Or more loosely:
89
89
  The agent orchestrates the tools for you:
90
90
 
91
91
  1. **`reclaim_authenticate`** — a local Chrome window opens the Reclaim dashboard; you sign in. The token is captured automatically and the tab closes. It never uses a Builder, container, custom-CDP, or shared browser. (Or it reuses a still-valid cached identity.)
92
- 2. **`attach_browser` + `navigate`** — uses local Chrome, a Builder-hosted remote browser, or a custom CDP websocket; **`wait_for_page`** waits while you log in.
92
+ 2. **`attach_browser` + `navigate`** — opens a visible local browser with the persistent Reclaim profile (other modes only when you ask for them); **`wait_for_page`** waits while you log in.
93
93
  3. **`start_capture` → `find_requests_containing` → `get_request`** — records network traffic and locates the request carrying your value.
94
94
  4. **`propose_provider`** — drafts the provider (a `contains` match + `jsonPath`/`xPath`/`regex` redaction), keeping any secret headers server-side.
95
95
  5. **`replay_request` → `run_proof`** — pre-flights the draft, then produces a verified zkTLS proof via the attestor.
@@ -107,7 +107,7 @@ These capture/synthesis/proof tools are backend-independent and registered in bo
107
107
 
108
108
  | Tool | Purpose |
109
109
  | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
110
- | `attach_browser` | Connect to a browser. Grouped by use case: working alone → local Chrome (launched, or the one already open); someone else has to sign in → the Reclaim runtime in Docker (free) or a hosted remote browser (chargeable). The tool description asks which case applies before listing anything, so an agent can put the choice to the developer and connect in one call |
110
+ | `attach_browser` | Connect to a browser. With no arguments it launches a visible local browser (Chrome, then Chromium, then Edge, else a one-time Chrome for Testing download) with the persistent Reclaim profile, without asking. `attach` (your open Chrome), `container` (Docker), `builder` (hosted, chargeable), and `custom` (a CDP websocket) are used only when you ask for them |
111
111
  | `list_tabs` | List open Chrome tabs |
112
112
  | `navigate`, `wait_for_page`, `eval_in_page` | Drive the tab; poll for login completion |
113
113
  | `start_capture`, `stop_capture` | Network capture lifecycle |
@@ -134,7 +134,7 @@ The MCP server targets one of two backends. An explicit `USE_OLD_DEVTOOLS` setti
134
134
  - `get_me_providers` — list the providers you own (the only provider-listing tool in this mode).
135
135
  - `session_analytics_logs` — the milestone events for a verification session, which show how far it got.
136
136
  - `session_logs` — the log entries the in-app SDK emitted during that session, each with its event type, log level, and logger name. Filter by event type, level, or substring, page with `limit` and `offset`, or set `saveTo` to write every matching entry to an NDJSON file. The backend searches only the last 3 days unless you pass `startTime` and `endTime`, and it deletes entries after 30 days.
137
- - **Builder (default).** Registers the auto-generated HTTP-API tools, including `create_org`, `issue_org_token`, and `create_org_callback_subscription`, plus authentication/credential tools, `get_verification_result`, `read_callback_events` (read the org's pull-listener events), `proxy_callback_events` (forward them to a localhost callback route; the pull listener lives only while the MCP server runs), and `check_attestor`. `issue_credentials` creates a local proof-owner key; `issue_org_token` issues the organization's API secret.
137
+ - **Builder (default).** Registers the auto-generated HTTP-API tools, including `create_org`, `issue_org_token`, and `create_org_callback_subscription`, plus authentication/credential tools, `get_verification_result`, `read_callback_events` (read the org's pull-listener events), `proxy_callback_events` (forward them to a localhost callback route; the pull listener lives only while the MCP server runs), and `check_attestor`. `issue_credentials` creates a local proof-owner key; `issue_org_token` issues the organization's API secret; `create_example_app` writes the builder-example app into a workspace that has none; `issue_org_keypair` creates the organization's key pair for session binding and encrypted results, writing the private key to the app's env file.
138
138
 
139
139
  Relevant env vars: `RECLAIM_OLD_API_URL` (default `https://devapi.reclaimprotocol.org`), `RECLAIM_OLD_API_TOKEN` / `RECLAIM_OLD_ETH_UID` (headless identity), `RECLAIM_OLD_LOGIN_URL` (default `https://dev.reclaimprotocol.org`); builder mode uses `RECLAIM_API_URL` / `RECLAIM_API_TOKEN`.
140
140
 
@@ -154,9 +154,9 @@ npm run download:zk-files --workspace=packages/agent
154
154
 
155
155
  ## Platform notes
156
156
 
157
- - **Node 20 or later** is required to run the published package (declared in `engines`). The package ships compiled `lib/*.js`, so no TypeScript type-stripping is needed at runtime.
157
+ - **Node 22.12 or later** is required to run the published package (declared in `engines`; `@puppeteer/browsers`, used to download Chrome for Testing, needs it). The package ships compiled `lib/*.js`, so no TypeScript type-stripping is needed at runtime.
158
158
 
159
- Any line works, including non-LTS ones such as 25. The one native addon that used to break this, `re2`, became an **optional** dependency in `@reclaimprotocol/attestor-core` 5.0.8 and degrades to the native `RegExp` when its binary is absent. The other native dependency, `koffi`, is N-API, so it ships ABI-independent prebuilts for every platform and was never version-sensitive. An LTS line (22 or 24) is still recommended, because `re2` is present there and gives faster, ReDoS-safe matching.
159
+ Any line from 22.12 works, including non-LTS ones such as 25. The one native addon that used to break this, `re2`, became an **optional** dependency in `@reclaimprotocol/attestor-core` 5.0.8 and degrades to the native `RegExp` when its binary is absent. The other native dependency, `koffi`, is N-API, so it ships ABI-independent prebuilts for every platform and was never version-sensitive. An LTS line (22 or 24) is still recommended, because `re2` is present there and gives faster, ReDoS-safe matching.
160
160
 
161
161
  Working on the package **from a checkout** additionally needs **Node 22.18 or later**: the test suite and the dev workflow run bare `.ts` files directly, and unflagged TypeScript type-stripping landed in 22.18.
162
162
  - **Chrome 136+ silently disables `--remote-debugging-port`** when launched against the default user profile. `dedicated` mode (the default) sidesteps this by always passing `--user-data-dir=~/.reclaim/chrome-profile`.
@@ -20,6 +20,13 @@ export interface Draft {
20
20
  /** One entry per extracted value (value + its redaction), so replay can
21
21
  * validate each against its own selector. */
22
22
  extractions: Extraction[];
23
+ /** Outcome of the latest authenticated in-browser replay, recorded by
24
+ * backends that track it. Lets a failed proof tell "the site blocks
25
+ * non-browser clients" apart from a broken recipe. */
26
+ lastReplay?: {
27
+ status: number;
28
+ ok: boolean;
29
+ };
23
30
  }
24
31
  /** A navigation script installed for every later page load of the authoring
25
32
  * session. `identifier` is the CDP registration, so it can be removed. */
@@ -31,10 +38,12 @@ export interface KeptScript {
31
38
  export interface KeptScriptRef {
32
39
  keptScript?: KeptScript;
33
40
  }
34
- /** Per-session draft store: `propose` puts, `replay`/`run_proof` get by id. */
41
+ /** Per-session draft store: `propose` puts, `replay`/`run_proof` get by id,
42
+ * `update_draft` replaces one in place (same id). */
35
43
  export interface DraftStore {
36
44
  put(draft: Draft): string;
37
45
  get(id: string): Draft | undefined;
46
+ set(id: string, draft: Draft): void;
38
47
  }
39
48
  export declare function makeDraftStore(): DraftStore;
40
49
  /** Options for a proof run. `ownerAddress` selects the local signing credential
@@ -43,6 +52,8 @@ export declare function makeDraftStore(): DraftStore;
43
52
  export interface ProveOptions {
44
53
  ownerAddress?: string;
45
54
  attestorUrl?: string;
55
+ /** ISO country the attestor's request goes out from (local proofs). */
56
+ geoLocation?: string;
46
57
  }
47
58
  /**
48
59
  * Everything the shared authoring tools need from one live session. Browser ops
@@ -20,5 +20,8 @@ export function makeDraftStore() {
20
20
  return id;
21
21
  },
22
22
  get: (id) => map.get(id),
23
+ set: (id, draft) => {
24
+ map.set(id, draft);
25
+ },
23
26
  };
24
27
  }
@@ -19,7 +19,7 @@ import { findTool } from "../mcp/tools/agent/find.js";
19
19
  import { proposeTool } from "../mcp/tools/agent/propose.js";
20
20
  import { replayTool } from "../mcp/tools/agent/replay.js";
21
21
  import { defineTool } from "../tools/registry.js";
22
- import { objectSchema, publicRequest } from "./util.js";
22
+ import { DEFAULT_MAX_CHARS, objectSchema, publicRequest, TEXT_WINDOW_PROPERTIES, textWindow, } from "./util.js";
23
23
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
24
24
  function matchesFilter(r, f) {
25
25
  if (f.method && r.method !== f.method.toUpperCase()) {
@@ -108,10 +108,32 @@ export function authoringTools(resolveBackend) {
108
108
  + 'Use it to read a data point from the DOM, check sign-in state, '
109
109
  + 'or trigger the target request (for example, `location.reload()`). '
110
110
  + 'The page cannot reach files or processes on this machine. Return '
111
- + 'only the values you need, never cookies or tokens.',
112
- inputSchema: objectSchema({ expression: { type: 'string' } }, ['expression']),
111
+ + 'only the values you need, never cookies or tokens. A result '
112
+ + `longer than ${DEFAULT_MAX_CHARS} characters comes back `
113
+ + 'truncated (objects as JSON text) with `valueTruncation`; page '
114
+ + 'with `offset`, or pass `maxChars` or `full`.',
115
+ inputSchema: objectSchema({ expression: { type: 'string' }, ...TEXT_WINDOW_PROPERTIES }, ['expression']),
113
116
  annotations: { openWorldHint: true, destructiveHint: false },
114
- }, (args) => evalInPage(resolveBackend(args), String(args.expression))),
117
+ }, async (args) => {
118
+ const res = await evalInPage(resolveBackend(args), String(args.expression));
119
+ if (res.value === undefined) {
120
+ return res;
121
+ }
122
+ const text = typeof res.value === 'string'
123
+ ? res.value
124
+ : JSON.stringify(res.value);
125
+ const slice = textWindow(text, args);
126
+ if (!slice.truncation) {
127
+ return res;
128
+ }
129
+ return {
130
+ value: slice.text,
131
+ valueTruncation: {
132
+ ...slice.truncation,
133
+ ...(typeof res.value === 'string' ? {} : { format: 'json' }),
134
+ },
135
+ };
136
+ }),
115
137
  defineTool({
116
138
  name: 'wait_for_page',
117
139
  description: 'Evaluates a JavaScript expression in the remote authoring browser '
@@ -369,7 +391,10 @@ export function authoringTools(resolveBackend) {
369
391
  + 'attestor calls the target from its own network and IP. A request '
370
392
  + 'that replayed can still fail here for geo- or IP-bound content '
371
393
  + '(country-locked pages, IP-allowlisted APIs); that is a property '
372
- + 'of the site, not a bug in the draft.',
394
+ + 'of the site, not a bug in the draft. HTTP 403/406/429/503 or a '
395
+ + 'challenge page from the attestor while the replay succeeded '
396
+ + 'means the site blocks non-browser clients: copying headers or '
397
+ + 'cookies will not help, so try a different endpoint or stop.',
373
398
  inputSchema: objectSchema({ draftId: { type: 'string' } }, ['draftId']),
374
399
  annotations: { openWorldHint: true, destructiveHint: false },
375
400
  },
@@ -391,6 +416,9 @@ export function authoringTools(resolveBackend) {
391
416
  if (typeof args.attestorUrl === 'string') {
392
417
  opts.attestorUrl = args.attestorUrl;
393
418
  }
419
+ if (typeof args.geoLocation === 'string') {
420
+ opts.geoLocation = args.geoLocation;
421
+ }
394
422
  return backend.prove(draft, opts);
395
423
  }),
396
424
  ];
@@ -25,3 +25,46 @@ export declare function publicRequest(r: CapturedRequest): {
25
25
  contentType: string;
26
26
  size: number;
27
27
  };
28
+ /** Characters a long text field returns by default (about 20 KB of ASCII),
29
+ * so one page body or eval result can't flood the caller's context. */
30
+ export declare const DEFAULT_MAX_CHARS = 20000;
31
+ export interface TextWindowArgs {
32
+ maxChars?: unknown;
33
+ offset?: unknown;
34
+ full?: unknown;
35
+ }
36
+ /** Input properties that let a caller page through, or opt out of, the
37
+ * window {@link textWindow} applies. */
38
+ export declare const TEXT_WINDOW_PROPERTIES: {
39
+ maxChars: {
40
+ type: "integer";
41
+ minimum: number;
42
+ description: string;
43
+ };
44
+ offset: {
45
+ type: "integer";
46
+ minimum: number;
47
+ description: string;
48
+ };
49
+ full: {
50
+ type: "boolean";
51
+ description: string;
52
+ };
53
+ };
54
+ /**
55
+ * Returns characters `[offset, offset + maxChars)` of `text`. When that isn't
56
+ * the whole text, `truncation` reports the total size and how to get the rest.
57
+ */
58
+ export declare function textWindow(text: string, args: TextWindowArgs): {
59
+ text: string;
60
+ truncation?: undefined;
61
+ } | {
62
+ text: string;
63
+ truncation: {
64
+ note: string;
65
+ nextOffset?: number | undefined;
66
+ totalChars: number;
67
+ offset: number;
68
+ returnedChars: number;
69
+ };
70
+ };
@@ -22,3 +22,57 @@ export function publicRequest(r) {
22
22
  ...(r.graphqlOp ? { graphqlOp: r.graphqlOp } : {}),
23
23
  };
24
24
  }
25
+ /** Characters a long text field returns by default (about 20 KB of ASCII),
26
+ * so one page body or eval result can't flood the caller's context. */
27
+ export const DEFAULT_MAX_CHARS = 20000;
28
+ /** Input properties that let a caller page through, or opt out of, the
29
+ * window {@link textWindow} applies. */
30
+ export const TEXT_WINDOW_PROPERTIES = {
31
+ maxChars: {
32
+ type: 'integer',
33
+ minimum: 1,
34
+ description: `Maximum characters to return. Default ${DEFAULT_MAX_CHARS}.`,
35
+ },
36
+ offset: {
37
+ type: 'integer',
38
+ minimum: 0,
39
+ description: 'Character offset to start from, such as a previous '
40
+ + '`nextOffset`. Default 0.',
41
+ },
42
+ full: {
43
+ type: 'boolean',
44
+ description: 'Return the whole text, ignoring `maxChars`. It can be '
45
+ + 'megabytes, so prefer paging with `offset`.',
46
+ },
47
+ };
48
+ /**
49
+ * Returns characters `[offset, offset + maxChars)` of `text`. When that isn't
50
+ * the whole text, `truncation` reports the total size and how to get the rest.
51
+ */
52
+ export function textWindow(text, args) {
53
+ const totalChars = text.length;
54
+ const offset = Math.min(Math.max(Number(args.offset) || 0, 0), totalChars);
55
+ const maxChars = args.full === true
56
+ ? totalChars
57
+ : Math.max(Number(args.maxChars) || DEFAULT_MAX_CHARS, 1);
58
+ const end = Math.min(offset + maxChars, totalChars);
59
+ if (offset === 0 && end === totalChars) {
60
+ return { text };
61
+ }
62
+ const part = text.slice(offset, end);
63
+ const more = end < totalChars;
64
+ return {
65
+ text: part,
66
+ truncation: {
67
+ totalChars,
68
+ offset,
69
+ returnedChars: part.length,
70
+ ...(more ? { nextOffset: end } : {}),
71
+ note: `Returned characters ${offset}-${end} of ${totalChars}.`
72
+ + (more
73
+ ? ` Call again with offset: ${end} for more, or full: true `
74
+ + 'for the whole text.'
75
+ : ''),
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,14 @@
1
+ export interface ChromeForTestingInstall {
2
+ executablePath: string;
3
+ /** True when this call downloaded it; false when an earlier one had. */
4
+ downloaded: boolean;
5
+ }
6
+ /** Where downloaded browsers live: `~/.reclaim/browsers`. */
7
+ export declare function browsersDirPath(): string;
8
+ /**
9
+ * Chrome for Testing, the fallback when no local Chromium-family browser is
10
+ * installed or the installed one will not start. Downloaded once into
11
+ * `~/.reclaim/browsers` and reused after that, so only the first fallback pays
12
+ * the ~150 MB download.
13
+ */
14
+ export declare function installChromeForTesting(cacheDir?: string): Promise<ChromeForTestingInstall>;
@@ -0,0 +1,43 @@
1
+ import { failedDependency } from '@hapi/boom';
2
+ import assert from 'node:assert';
3
+ import { existsSync } from 'node:fs';
4
+ import { arch, platform } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { LOGGER } from "../logger.js";
7
+ import { reclaimHomeDirPath } from "../paths.js";
8
+ /** Where downloaded browsers live: `~/.reclaim/browsers`. */
9
+ export function browsersDirPath() {
10
+ return join(reclaimHomeDirPath(), 'browsers');
11
+ }
12
+ /**
13
+ * Chrome for Testing, the fallback when no local Chromium-family browser is
14
+ * installed or the installed one will not start. Downloaded once into
15
+ * `~/.reclaim/browsers` and reused after that, so only the first fallback pays
16
+ * the ~150 MB download.
17
+ */
18
+ export async function installChromeForTesting(cacheDir = browsersDirPath()) {
19
+ // Loaded on demand: only the fallback path needs it.
20
+ const { Browser, detectBrowserPlatform, getInstalledBrowsers, getVersionComparator, install, resolveBuildId, } = await import('@puppeteer/browsers');
21
+ const browserPlatform = detectBrowserPlatform();
22
+ assert(browserPlatform, failedDependency(`Chrome for Testing has no build for ${platform()}/${arch()}.`));
23
+ const compare = getVersionComparator(Browser.CHROME);
24
+ const all = await getInstalledBrowsers({ cacheDir });
25
+ const installed = all
26
+ .filter((b) => b.browser === Browser.CHROME
27
+ && b.platform === browserPlatform
28
+ && existsSync(b.executablePath))
29
+ .sort((a, b) => compare(a.buildId, b.buildId));
30
+ const newest = installed.at(-1);
31
+ if (newest) {
32
+ return { executablePath: newest.executablePath, downloaded: false };
33
+ }
34
+ const buildId = await resolveBuildId(Browser.CHROME, browserPlatform, 'stable');
35
+ LOGGER.info({ buildId, cacheDir }, 'downloading Chrome for Testing');
36
+ const result = await install({
37
+ browser: Browser.CHROME,
38
+ platform: browserPlatform,
39
+ buildId,
40
+ cacheDir,
41
+ });
42
+ return { executablePath: result.executablePath, downloaded: true };
43
+ }
package/lib/cdp/geo.d.ts CHANGED
@@ -8,3 +8,6 @@ export interface IpGeolocation {
8
8
  * lookup failure just loses the "best guess" convenience, falling back to
9
9
  * {@link FALLBACK_COUNTRY_CODE} rather than blocking applying a proxy. */
10
10
  export declare function suggestCountryFromIp(fetchImpl?: typeof fetch): Promise<IpGeolocation>;
11
+ /** The calling machine's public-IP country, or undefined when the lookup
12
+ * fails: unlike {@link suggestCountryFromIp}, no guessed fallback. */
13
+ export declare function lookupIpCountry(fetchImpl?: typeof fetch): Promise<string | undefined>;
package/lib/cdp/geo.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * public-IP country, resolved the same way reclaim-portal's `ip-geolocation`
6
6
  * util does.
7
7
  */
8
+ import assert from 'node:assert';
8
9
  import { LOGGER } from "../logger.js";
9
10
  const IPINFO_URL = 'https://ipinfo.io/json';
10
11
  const FALLBACK_COUNTRY_CODE = 'US';
@@ -13,24 +14,35 @@ const FALLBACK_COUNTRY_CODE = 'US';
13
14
  * {@link FALLBACK_COUNTRY_CODE} rather than blocking applying a proxy. */
14
15
  export async function suggestCountryFromIp(fetchImpl = fetch) {
15
16
  try {
16
- const res = await fetchImpl(IPINFO_URL);
17
- if (!res.ok) {
18
- throw new Error(`ipinfo.io ${res.status}`);
19
- }
20
- const data = await res.json();
21
- const countryCode = data.country?.toUpperCase();
22
- if (!countryCode) {
23
- throw new Error('ipinfo.io response had no country');
24
- }
25
- return {
26
- countryCode,
27
- country: data.country,
28
- region: data.region,
29
- city: data.city,
30
- };
17
+ return await fetchIpGeolocation(fetchImpl);
31
18
  }
32
19
  catch (err) {
33
20
  LOGGER.warn({ err }, 'suggestCountryFromIp failed; using fallback');
34
21
  return { countryCode: FALLBACK_COUNTRY_CODE };
35
22
  }
36
23
  }
24
+ /** The calling machine's public-IP country, or undefined when the lookup
25
+ * fails: unlike {@link suggestCountryFromIp}, no guessed fallback. */
26
+ export async function lookupIpCountry(fetchImpl = fetch) {
27
+ try {
28
+ const { countryCode } = await fetchIpGeolocation(fetchImpl);
29
+ return countryCode;
30
+ }
31
+ catch (err) {
32
+ LOGGER.warn({ err }, 'IP country lookup failed');
33
+ return undefined;
34
+ }
35
+ }
36
+ async function fetchIpGeolocation(fetchImpl) {
37
+ const res = await fetchImpl(IPINFO_URL);
38
+ assert(res.ok, new Error(`ipinfo.io ${res.status}`));
39
+ const data = await res.json();
40
+ const countryCode = data.country?.toUpperCase();
41
+ assert(countryCode, new Error('ipinfo.io response had no country'));
42
+ return {
43
+ countryCode,
44
+ country: data.country,
45
+ region: data.region,
46
+ city: data.city,
47
+ };
48
+ }
@@ -1,4 +1,8 @@
1
1
  import { type ChildProcess } from 'node:child_process';
2
+ import { installChromeForTesting } from './chrome-for-testing.ts';
3
+ /** How long a launched browser gets to answer CDP. A cold first start on a
4
+ * slow disk, or Chrome for Testing's first run, can take well over 10s. */
5
+ export declare const CDP_READY_TIMEOUT_MS = 30000;
2
6
  export interface LaunchOptions {
3
7
  /** Port for --remote-debugging-port. */
4
8
  port: number;
@@ -7,8 +11,82 @@ export interface LaunchOptions {
7
11
  /** If true, launch without a visible window (--headless=new). Useful
8
12
  * for tests and CI; user-driven flows want it false. */
9
13
  headless?: boolean;
14
+ /** How long to wait for CDP to answer. */
15
+ timeoutMs?: number;
10
16
  }
17
+ export type LocalBrowserSource = 'installed' | 'override' | 'chrome-for-testing';
18
+ export interface LocalBrowser {
19
+ binary: string;
20
+ source: LocalBrowserSource;
21
+ process: ChildProcess;
22
+ /** Chrome for Testing was downloaded by this launch. */
23
+ downloaded: boolean;
24
+ /** Why the installed browser was passed over for Chrome for Testing. */
25
+ fallbackReason?: string;
26
+ }
27
+ export interface LocalBrowserDependencies {
28
+ platform: () => NodeJS.Platform;
29
+ env: () => NodeJS.ProcessEnv;
30
+ /** Explicit binary override (`RECLAIM_AGENT_CHROME_PATH`). */
31
+ chromePath: () => string | undefined;
32
+ /** Installed Chromium-family browsers, most preferred first. */
33
+ findInstalled: () => string[];
34
+ installChromeForTesting: typeof installChromeForTesting;
35
+ }
36
+ /**
37
+ * Build the platform-specific list of Chromium-family binaries to probe.
38
+ * Order matters: Chrome first, then Chromium, then Edge (Edge is
39
+ * Chromium-based, supports CDP, and is preinstalled on Win 10/11 — a
40
+ * useful fallback when Chrome itself isn't installed).
41
+ *
42
+ * On Windows we also probe the per-user Chrome install under
43
+ * %LOCALAPPDATA%, which is what you get when Chrome was installed
44
+ * without admin rights ("Install for me only"). Skipping this was the
45
+ * #1 first-run failure observed in the field.
46
+ */
47
+ export declare function browserCandidates(os?: NodeJS.Platform): string[];
48
+ /** The candidates that exist on this machine, in preference order. */
49
+ export declare function findInstalledBrowsers(candidates?: string[]): string[];
11
50
  export declare function defaultProfileDir(): string;
12
51
  export declare function findChromeBinary(): string;
13
- export declare function launchChrome(opts: LaunchOptions): ChildProcess;
52
+ /**
53
+ * Why a visible browser cannot open here, or undefined when it can. Only
54
+ * Linux needs a display server named in the environment; an MCP server
55
+ * started outside the desktop session (a sandboxed agent, a service) often
56
+ * has neither variable, and Chrome then exits before CDP ever answers.
57
+ */
58
+ export declare function missingDisplayReason(os?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string | undefined;
59
+ /**
60
+ * Launch a visible browser on this machine with the given profile, and wait
61
+ * until it answers CDP.
62
+ *
63
+ * Uses `RECLAIM_AGENT_CHROME_PATH` when set, else the first installed browser
64
+ * (Chrome, then Chromium, then Edge). When none is installed, or that one
65
+ * fails to start, it falls back to Chrome for Testing, downloaded once into
66
+ * `~/.reclaim/browsers`. A missing display fails fast instead: no browser can
67
+ * open without one, so a download would only waste time.
68
+ */
69
+ export declare function launchLocalBrowser(opts: LaunchOptions, dependencies?: Partial<LocalBrowserDependencies>): Promise<LocalBrowser>;
70
+ /**
71
+ * Spawn `binary` on `opts.profileDir` and wait for CDP. On failure the
72
+ * process is stopped and the error says why: it could not be spawned, it
73
+ * exited (with its code and the tail of its stderr), or it never answered.
74
+ */
75
+ export declare function startChrome(binary: string, opts: LaunchOptions): Promise<ChildProcess>;
76
+ /**
77
+ * Remove a dead browser's claim on `profileDir` so a new launch can take it.
78
+ *
79
+ * Chrome on POSIX claims a profile with `SingletonLock -> <hostname>-<pid>`.
80
+ * If that process is gone (a crash, a killed session) the links stay behind
81
+ * and the next launch hands off to the missing owner and exits. A lock whose
82
+ * PID is alive on this host belongs to a running browser, and is left alone:
83
+ * that browser must be closed first. A lock from another hostname is treated
84
+ * as stale: `~/.reclaim` is local, and a sandbox or container that changes the
85
+ * hostname between runs is the usual way to get one. Callers reach this only
86
+ * after nothing answered CDP on the port.
87
+ */
88
+ export declare function releaseStaleProfileLock(profileDir: string, os?: NodeJS.Platform): void;
89
+ /**
90
+ * Wait until something answers CDP on `port`, polling every 150ms.
91
+ */
14
92
  export declare function waitForCdpReady(port: number, timeoutMs?: number): Promise<void>;