@reclaimprotocol/agent 0.1.25 → 0.1.27

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.
@@ -0,0 +1,310 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, isAbsolute, join } from 'node:path';
3
+ import { defineTool } from "../../mcp/server.js";
4
+ import { findProjectRoot } from "../../paths.js";
5
+ /** The log-stream service caps a page at 1000 entries however large a
6
+ * `limit` asks for, so paging is the only way past it. */
7
+ const MAX_PAGE = 1000;
8
+ /** Inline default. Small enough to stay readable in a tool result; a bigger
9
+ * pull belongs in `saveTo`. */
10
+ const DEFAULT_LIMIT = 200;
11
+ /** Ceiling on a `saveTo` dump, so a chatty session can't page forever. */
12
+ const DEFAULT_MAX_ENTRIES = 20000;
13
+ export function sessionLogsTool(client) {
14
+ return defineTool({
15
+ name: 'session_logs',
16
+ description: 'Fetch the raw log entries the in-app SDK emitted during a '
17
+ + 'verification session, each with its event type (for example '
18
+ + 'REQUEST_MATCHED, CLAIM_CREATION_STARTED, or PROOF_GENERATED), '
19
+ + 'log level, and logger name. This is the data behind the '
20
+ + 'dashboard session-logs tab, and it is far more detailed than '
21
+ + 'session_analytics_logs, which returns only the coarse milestone '
22
+ + 'events. Use it to diagnose a session that stalled, matched '
23
+ + 'nothing, or produced no claim. Narrow the result with eventType, '
24
+ + 'logLevel, or contains; page through it with limit and offset; or '
25
+ + 'set saveTo to write every matching entry to an NDJSON file and '
26
+ + 'read that file with your own tools. Old-devtools mode only. You '
27
+ + 'must own the app the session belongs to, so call '
28
+ + 'reclaim_authenticate first.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ sessionId: {
33
+ type: 'string',
34
+ description: 'Id of the verification session to read log entries for.',
35
+ },
36
+ eventType: {
37
+ type: 'string',
38
+ description: 'Keep only entries that carry this exact event type, for '
39
+ + 'example REQUEST_MATCHED or PROOF_GENERATION_FAILED_'
40
+ + 'EXCEPTION. Most entries carry no event type at all, so '
41
+ + 'prefer eventsOnly when you want the milestone timeline '
42
+ + 'rather than guessing a name.',
43
+ },
44
+ eventsOnly: {
45
+ type: 'boolean',
46
+ description: 'Keep only entries that carry an event type, which gives you '
47
+ + 'a compact milestone timeline for the session. This tool '
48
+ + 'applies the filter after fetching, so it narrows what you '
49
+ + 'read rather than what it fetches.',
50
+ },
51
+ logLevel: {
52
+ type: 'string',
53
+ enum: ['fine', 'config', 'info', 'warning', 'severe', 'unknown'],
54
+ description: 'Lowest severity to include. Each value is hierarchical: '
55
+ + '`fine` includes FINE, CONFIG, INFO, WARNING, and SEVERE; '
56
+ + '`info` drops FINE and CONFIG; `severe` keeps only '
57
+ + 'failures. Defaults to `fine`, which is wider than the '
58
+ + 'backend default of `info`. The FINER and FINEST tiers '
59
+ + 'contain PII, and this route rejects them.',
60
+ },
61
+ logType: {
62
+ type: 'string',
63
+ description: 'Exact client-side logger name, which the SDK calls `type` — '
64
+ + 'for example reclaim_flutter_sdk.ClaimCreation. This is not '
65
+ + 'the event type.',
66
+ },
67
+ contains: {
68
+ type: 'string',
69
+ description: 'Substring that an entry\'s log line must contain.',
70
+ },
71
+ providerId: {
72
+ type: 'string',
73
+ description: 'Keep only entries for this provider. A session can touch '
74
+ + 'more than one provider, so this narrows within the '
75
+ + 'session; it cannot search across sessions.',
76
+ },
77
+ deviceId: {
78
+ type: 'string',
79
+ description: 'Keep only entries for this device id, which is the claimant '
80
+ + 'id.',
81
+ },
82
+ source: {
83
+ type: 'string',
84
+ description: 'Keep only entries from this client build. The source string '
85
+ + 'carries the SDK, platform, and version, so it is the field '
86
+ + 'to compare a working run against a broken one.',
87
+ },
88
+ startTime: {
89
+ type: 'string',
90
+ description: 'Lower bound, as an ISO-8601 timestamp. If you set neither '
91
+ + 'bound, the backend searches only the last 3 days.',
92
+ },
93
+ endTime: {
94
+ type: 'string',
95
+ description: 'Upper bound, as an ISO-8601 timestamp.',
96
+ },
97
+ limit: {
98
+ type: 'number',
99
+ description: `Log entries per page. Defaults to ${DEFAULT_LIMIT} and is `
100
+ + `capped at ${MAX_PAGE}. Ignored when you set saveTo.`,
101
+ },
102
+ offset: {
103
+ type: 'number',
104
+ description: 'Entries to skip, counting back from the newest match. '
105
+ + 'Ignored when you set saveTo.',
106
+ },
107
+ saveTo: {
108
+ type: 'string',
109
+ description: 'Path to write every matching entry to, as NDJSON: one raw '
110
+ + 'entry per line, oldest first. When you set this, the tool '
111
+ + 'returns a summary instead of the entries. Relative paths '
112
+ + 'resolve against the project root. Use it when a session '
113
+ + 'has more entries than you want to read inline.',
114
+ },
115
+ maxEntries: {
116
+ type: 'number',
117
+ description: 'Maximum entries a saveTo dump collects. Defaults to '
118
+ + `${DEFAULT_MAX_ENTRIES}.`,
119
+ },
120
+ },
121
+ required: ['sessionId'],
122
+ },
123
+ }, async (args) => {
124
+ // The backend's own default is `info`, which silently hides every
125
+ // FINE and CONFIG entry — the ones that say what the interceptor
126
+ // actually saw. Debugging is the whole point of this tool, so widen
127
+ // the default here.
128
+ const query = {
129
+ logLevel: (args.logLevel ?? 'fine'),
130
+ logType: args.logType,
131
+ eventType: args.eventType,
132
+ providerId: args.providerId,
133
+ deviceId: args.deviceId,
134
+ source: args.source,
135
+ startTime: args.startTime,
136
+ endTime: args.endTime,
137
+ logLine: args.contains,
138
+ };
139
+ return args.saveTo
140
+ ? await dump(client, args, query, args.saveTo)
141
+ : await page(client, args, query);
142
+ });
143
+ }
144
+ /** One page, returned inline. */
145
+ async function page(client, args, query) {
146
+ const limit = clampLimit(args.limit ?? DEFAULT_LIMIT);
147
+ const offset = Math.max(args.offset ?? 0, 0);
148
+ const res = await client.getSessionLogs(args.sessionId, {
149
+ ...query,
150
+ limit,
151
+ offset,
152
+ includeCount: true,
153
+ });
154
+ // Newest-first off the wire; a debugger reads a session forwards.
155
+ const chronological = [...res.data].reverse();
156
+ const entries = args.eventsOnly
157
+ ? chronological.filter(hasEvent)
158
+ : chronological;
159
+ const fetched = res.data.length;
160
+ const total = res.totalCount;
161
+ return {
162
+ ...summarize(args, res.failedRegions, entries, total),
163
+ page: {
164
+ limit,
165
+ offset,
166
+ fetched,
167
+ ...(args.eventsOnly ? { keptWithEvent: entries.length } : {}),
168
+ hasMore: total === undefined
169
+ ? fetched === limit
170
+ : offset + fetched < total,
171
+ nextOffset: offset + fetched,
172
+ },
173
+ entries: compact(entries),
174
+ };
175
+ }
176
+ /** Every match, paged out to an NDJSON file. */
177
+ async function dump(client, args, query, saveTo) {
178
+ const maxEntries = Math.max(args.maxEntries ?? DEFAULT_MAX_ENTRIES, 1);
179
+ const all = [];
180
+ const failedRegions = new Set();
181
+ let total;
182
+ let offset = 0;
183
+ while (all.length < maxEntries) {
184
+ const res = await client.getSessionLogs(args.sessionId, {
185
+ ...query,
186
+ limit: Math.min(MAX_PAGE, maxEntries - all.length),
187
+ offset,
188
+ // Only the first page pays for the count; it does not move.
189
+ includeCount: offset === 0,
190
+ });
191
+ for (const region of res.failedRegions ?? []) {
192
+ failedRegions.add(region);
193
+ }
194
+ if (offset === 0) {
195
+ total = res.totalCount;
196
+ }
197
+ all.push(...res.data);
198
+ offset += res.data.length;
199
+ // A short page means the match set is exhausted.
200
+ if (!res.data.length || res.data.length < MAX_PAGE) {
201
+ break;
202
+ }
203
+ if (total !== undefined && offset >= total) {
204
+ break;
205
+ }
206
+ }
207
+ all.reverse();
208
+ const entries = args.eventsOnly ? all.filter(hasEvent) : all;
209
+ const path = isAbsolute(saveTo)
210
+ ? saveTo
211
+ : join(findProjectRoot(process.cwd()), saveTo);
212
+ mkdirSync(dirname(path), { recursive: true });
213
+ writeFileSync(path, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
214
+ return {
215
+ ...summarize(args, [...failedRegions], entries, total),
216
+ file: {
217
+ path,
218
+ format: 'ndjson',
219
+ entries: entries.length,
220
+ order: 'oldest first',
221
+ note: 'Each line holds one raw log entry with all of its columns: '
222
+ + 'timestamp, log_level, event_type, log_type, log_line, '
223
+ + 'session_id, app_id, provider_id, device_id, source, and '
224
+ + 'metadata.',
225
+ truncated: entries.length >= maxEntries
226
+ ? `Stopped at maxEntries=${maxEntries}. Narrow the filters, or `
227
+ + 'raise the limit.'
228
+ : undefined,
229
+ },
230
+ };
231
+ }
232
+ /** The header both modes share: what was asked for, what came back, and the
233
+ * event-type histogram — usually enough on its own to see where a session
234
+ * died. */
235
+ function summarize(args, failedRegions, entries, total) {
236
+ const events = {};
237
+ for (const entry of entries) {
238
+ if (entry.event_type) {
239
+ events[entry.event_type] = (events[entry.event_type] ?? 0) + 1;
240
+ }
241
+ }
242
+ return {
243
+ sessionId: args.sessionId,
244
+ context: contextOf(entries),
245
+ totalMatching: total,
246
+ span: entries.length
247
+ ? {
248
+ from: entries[0].timestamp,
249
+ to: entries[entries.length - 1].timestamp,
250
+ }
251
+ : undefined,
252
+ eventTypes: Object.keys(events).length ? events : undefined,
253
+ ...(failedRegions?.length
254
+ ? {
255
+ warning: 'The log-stream query failed in region '
256
+ + `${failedRegions.join(', ')}, so this result is incomplete.`,
257
+ }
258
+ : {}),
259
+ ...(entries.length
260
+ ? {}
261
+ : {
262
+ hint: 'No entries matched. Check these causes in order: the '
263
+ + 'search window defaults to the last 3 days, so pass startTime '
264
+ + 'and endTime for an older session; the backend deletes entries '
265
+ + 'after 30 days; the backend picks which regions to query from '
266
+ + 'the analytics table, so a session that never recorded an '
267
+ + 'analytics event reads as empty here even when entries exist; '
268
+ + 'or the filters are too narrow. Cross-check with '
269
+ + 'session_analytics_logs.',
270
+ }),
271
+ };
272
+ }
273
+ /** Fields that are identical on every entry, hoisted out so they are not
274
+ * repeated hundreds of times. */
275
+ function contextOf(entries) {
276
+ const uniq = (pick) => {
277
+ const set = new Set(entries.map(pick).filter(Boolean));
278
+ return [...set];
279
+ };
280
+ const one = (values) => (values.length === 1 ? values[0] : values);
281
+ return entries.length
282
+ ? {
283
+ appId: one(uniq(e => e.app_id)),
284
+ providerId: one(uniq(e => e.provider_id)),
285
+ deviceId: one(uniq(e => e.device_id)),
286
+ source: one(uniq(e => e.source)),
287
+ }
288
+ : undefined;
289
+ }
290
+ function compact(entries) {
291
+ const devices = new Set(entries.map(e => e.device_id));
292
+ const sources = new Set(entries.map(e => e.source));
293
+ return entries.map(e => ({
294
+ ts: e.timestamp,
295
+ level: e.log_level,
296
+ ...(e.event_type ? { event: e.event_type } : {}),
297
+ logger: e.log_type,
298
+ line: e.log_line,
299
+ // Only worth repeating per entry once the page actually mixes them.
300
+ ...(devices.size > 1 ? { device: e.device_id } : {}),
301
+ ...(sources.size > 1 ? { source: e.source } : {}),
302
+ ...(e.metadata && e.metadata !== '{}' ? { metadata: e.metadata } : {}),
303
+ }));
304
+ }
305
+ function hasEvent(entry) {
306
+ return !!entry.event_type;
307
+ }
308
+ function clampLimit(limit) {
309
+ return Math.min(Math.max(Math.trunc(limit), 1), MAX_PAGE);
310
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/agent",
3
3
  "title": "Reclaim Agent",
4
- "version": "0.1.25",
4
+ "version": "0.1.27",
5
5
  "description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -83,7 +83,7 @@
83
83
  "@modelcontextprotocol/sdk": "^1.29.0",
84
84
  "@noble/hashes": "^2.2.0",
85
85
  "@reclaimprotocol/attestor-core": "^5.0.8",
86
- "@reclaimprotocol/client": "0.1.25",
86
+ "@reclaimprotocol/client": "0.1.27",
87
87
  "@reclaimprotocol/tls": "^0.1.3",
88
88
  "chrome-remote-interface": "^0.33.0",
89
89
  "domhandler": "^6.0.0",
@@ -49,6 +49,22 @@ immediately with a `code` and `attachUrl` — it does NOT poll.
49
49
  The 2-day token is cached in `~/.reclaim/config.json` and reused on later runs
50
50
  (`RECLAIM_API_TOKEN` in the server env overrides it).
51
51
 
52
+ <!-- topic: devtools-migration -->
53
+ ## Import providers from devtools
54
+
55
+ Use these operations only when the developer is migrating an existing legacy
56
+ integration. Prefer Builder for new integrations.
57
+
58
+ ### Import from devtools
59
+
60
+ 1. Get the target `orgId` with `list_orgs`.
61
+ 2. Ask the developer for the legacy provider UUID.
62
+ 3. Call `import_devtools_provider(orgId, providerId)`.
63
+ 4. Use the returned provider UUID for all Builder operations. Import preserves
64
+ the UUID and latest legacy semantic version. It claims an anonymous catalog
65
+ seed idempotently, but never moves a provider from another Builder org. The
66
+ server reads the public devtools API; no legacy token is required.
67
+
52
68
  <!-- topic: browser -->
53
69
  <!-- common: browser -->
54
70
 
@@ -37,8 +37,9 @@ workflow — the narrow `authenticate_builder` / `list_builder_organizations`
37
37
  exception exists only to allocate Builder-hosted authoring browsers.
38
38
  `get_me_providers` is the only provider-listing tool here.
39
39
 
40
- **Diagnosing a session:** `session_analytics_logs` fetches a verification
41
- session's analytics logs by id (matches, claims, failures).
40
+ **Diagnosing a session:** `session_analytics_logs` returns a session's
41
+ milestone events; `session_logs` returns the log entries the SDK itself
42
+ emitted. Read both from topic `troubleshooting`.
42
43
 
43
44
  <!-- topic: auth -->
44
45
  ## Getting authenticated
@@ -357,6 +358,31 @@ breaks the page, and it's the smallest change. Only if that doesn't help, turn
357
358
  interception off entirely (`injectionType: NONE`, or omit
358
359
  `interceptorOptions`). Full checklist and remedies: topic `interception`.
359
360
 
361
+ **A session stalled, matched nothing, or produced no claim.** Read it with
362
+ two tools, coarse to fine. `session_analytics_logs` returns the milestone
363
+ events for a session id — SESSION_STARTED, PROOF_GENERATION_FAILED, and so on
364
+ — which tells you how far the session got. `session_logs` returns the log
365
+ entries the SDK emitted, each with its event type, log level, and logger name,
366
+ which tells you why it stopped: the request it intercepted, the match or
367
+ redaction that failed, and what the attestor answered. Work through it in this
368
+ order:
369
+
370
+ 1. Call `session_analytics_logs({ sessionId })` to place the failure on the
371
+ timeline.
372
+ 2. Call `session_logs({ sessionId, eventsOnly: true })` for the same timeline
373
+ at entry level, then drop `eventsOnly` and add `contains` or `eventType` to
374
+ read around the entry that failed.
375
+ 3. To compare a working run against a broken one, filter by `source` — it
376
+ carries the SDK, platform, and version, which is usually the variable that
377
+ differs.
378
+
379
+ Two defaults decide whether you see anything: the backend searches only the
380
+ last 3 days unless you pass `startTime` and `endTime`, and it deletes entries
381
+ after 30 days. An empty result is more often one of those than a genuinely
382
+ silent session, so read the `hint` the tool returns before concluding
383
+ anything. When a session is too large to read inline, pass `saveTo` to write
384
+ every entry to an NDJSON file and grep that file instead.
385
+
360
386
  **Proof failures / attestor errors** → topic `prove`.
361
387
  **Browser won't launch or attach** → topic `browser`.
362
388
  **Missing signing key** → topic `credentials`.
@@ -9,9 +9,9 @@ call a provider a "verification."**
9
9
 
10
10
  **Read the guide before authoring.** `how_it_works({ topic })` serves it, one
11
11
  topic at a time — call `how_it_works({})` for the topic index. Topics:
12
- `overview`, `auth`, `browser`, `capture`, `draft`, `params`, `prove`, `publish`,
13
- `discover`, `user-script`, `interception`, `hash-validation`, `credentials`,
14
- `never-do`, `troubleshooting`.
12
+ `overview`, `auth`, `devtools-migration`, `browser`, `capture`, `draft`,
13
+ `params`, `prove`, `publish`, `discover`, `user-script`, `interception`,
14
+ `hash-validation`, `credentials`, `never-do`, `troubleshooting`.
15
15
 
16
16
  **Authentication:** try `get_me` first. On 401, `authenticate({})` returns a
17
17
  device-pairing `code` + `attachUrl` — give the user the URL, then poll
package/skill/old-mode.md CHANGED
@@ -52,8 +52,10 @@ and the proof binds to nobody — find an auth-bound request instead.
52
52
  Values the CONSUMER supplies at verification time use `{{context_<name>}}` —
53
53
  this backend does NOT support the builder's dotted `{{context.<name>}}` form.
54
54
 
55
- **Diagnosing a session:** `session_analytics_logs` fetches a verification
56
- session's analytics logs by id (matches, claims, failures).
55
+ **Diagnosing a session:** `session_analytics_logs` returns a session's
56
+ milestone events; `session_logs` returns the log entries the SDK emitted, each
57
+ with its event type — that is where a failed match, redaction, or claim shows
58
+ up. Both take a `sessionId`; see topic `troubleshooting` for how to read them.
57
59
 
58
60
  **Backend mode:** the BUILDER backend (generated API tools, orgs, verification
59
61
  sessions, public review) is one switch away. `get_devtools_mode` reports the
@@ -26,40 +26,21 @@ import { type CloudflaredBinary, resolveCloudflared } from './cloudflared.ts'
26
26
  const URL_TIMEOUT_MS = 30_000
27
27
  /** After the hostname is printed, Cloudflare needs a moment before the edge
28
28
  * routes to it — measured at ~3s. Wait for it so the caller never hands out a
29
- * URL that errors, but don't fail the share if it's slow. */
29
+ * URL that errors. */
30
30
  const READY_TIMEOUT_MS = 20_000
31
31
  const READY_POLL_MS = 1_000
32
+ const STOP_TIMEOUT_MS = 5_000
32
33
 
33
34
  /** `https://<something>.trycloudflare.com`, as printed on cloudflared's log
34
35
  * stream. Anchored to the scheme so a hostname inside prose can't match. */
35
36
  const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
36
37
 
37
- /**
38
- * cloudflared says this once per edge connection it establishes, and until it
39
- * does, the hostname resolves but routes nowhere.
40
- *
41
- * This is the signal to trust, because it is LOCAL. Asking whether we can fetch
42
- * the public URL ourselves conflates two questions — is the tunnel up, and does
43
- * this machine have egress to Cloudflare's edge — and reports "not ready" when
44
- * only the second fails. Observed: a tunnel printed its hostname and registered
45
- * nothing for 12s, which is indistinguishable from a blocked fetch unless you
46
- * read cloudflared's own account of it.
47
- */
48
- const REGISTERED = /Registered tunnel connection|connIndex=/i
49
-
50
38
  /** Tail of cloudflared's log kept for error messages. */
51
39
  const LOG_TAIL_BYTES = 4096
52
40
 
53
41
  export interface Tunnel {
54
42
  /** The public HTTPS origin Cloudflare assigned. */
55
43
  url: string
56
- /** Whether the tunnel was carrying traffic before {@link READY_TIMEOUT_MS}
57
- * elapsed. False means it may still be propagating rather than broken — say
58
- * so rather than treating the share as failed. */
59
- ready: boolean
60
- /** cloudflared's own last words, for when `ready` is false and someone has
61
- * to work out why. Absent when the tunnel came up normally. */
62
- diagnostics?: string
63
44
  /** Terminate the tunnel. Safe to call more than once. */
64
45
  stop: () => Promise<void>
65
46
  /** Which cloudflared ran, and whether we had to fetch it. */
@@ -69,37 +50,43 @@ export interface Tunnel {
69
50
  /**
70
51
  * Wait until the tunnel is actually carrying traffic.
71
52
  *
72
- * Two independent signals, either of which is proof:
73
- * - cloudflared reported an edge registration (see {@link REGISTERED}). Local,
74
- * so it works regardless of this machine's own egress.
75
- * - the public origin answered. ANY HTTP response counts, including a 404:
76
- * the live-view server 404s unknown paths, so a status code at all proves
77
- * the hop is live. Kept as a second opinion in case a cloudflared release
78
- * rewords its log.
53
+ * A cloudflared "Registered tunnel connection" log is not enough: Cloudflare
54
+ * can report one while the assigned hostname still returns DNS NXDOMAIN. The
55
+ * public viewer itself must return a successful response.
79
56
  */
80
57
  async function waitUntilRouted(
81
58
  url: string,
82
- isRegistered: () => boolean,
59
+ {
60
+ isRunning,
61
+ pollMs,
62
+ timeoutMs,
63
+ }: {
64
+ isRunning: () => boolean
65
+ pollMs: number
66
+ timeoutMs: number
67
+ },
83
68
  ): Promise<boolean> {
84
- const deadline = Date.now() + READY_TIMEOUT_MS
69
+ const deadline = Date.now() + timeoutMs
85
70
  while(Date.now() < deadline) {
86
- if(isRegistered()) {
87
- return true
71
+ if(!isRunning()) {
72
+ return false
88
73
  }
89
74
 
90
75
  try {
91
- await fetch(url, {
76
+ const response = await fetch(url, {
92
77
  method: 'GET',
93
78
  redirect: 'manual',
94
- signal: AbortSignal.timeout(READY_POLL_MS * 2),
79
+ signal: AbortSignal.timeout(pollMs * 2),
95
80
  })
96
- return true
97
- } catch{
98
- await new Promise((r) => setTimeout(r, READY_POLL_MS))
99
- }
81
+ if(response.ok) {
82
+ return true
83
+ }
84
+ } catch{}
85
+
86
+ await new Promise((r) => setTimeout(r, pollMs))
100
87
  }
101
88
 
102
- return isRegistered()
89
+ return false
103
90
  }
104
91
 
105
92
  /**
@@ -111,7 +98,20 @@ async function waitUntilRouted(
111
98
  */
112
99
  export async function startTunnel(
113
100
  port: number,
114
- { autoInstall = true }: { autoInstall?: boolean } = {},
101
+ {
102
+ autoInstall = true,
103
+ probePath = '/',
104
+ readyPollMs = READY_POLL_MS,
105
+ readyTimeoutMs = READY_TIMEOUT_MS,
106
+ }: {
107
+ autoInstall?: boolean
108
+ /** Public path that must answer before the tunnel is returned. */
109
+ probePath?: string
110
+ /** Test-only timing override. */
111
+ readyPollMs?: number
112
+ /** Test-only timing override. */
113
+ readyTimeoutMs?: number
114
+ } = {},
115
115
  ): Promise<Tunnel> {
116
116
  // Resolves an existing cloudflared, or fetches one on first use. Throws
117
117
  // with an actionable message when neither is possible.
@@ -121,25 +121,14 @@ export async function startTunnel(
121
121
  '--no-autoupdate',
122
122
  '--url', `http://127.0.0.1:${port}`,
123
123
  ], { stdio: ['ignore', 'pipe', 'pipe'] })
124
+ const exited = new Promise<void>((resolve) => {
125
+ child.once('exit', () => resolve())
126
+ })
124
127
 
125
- const stop = async() => {
126
- if(!child.killed) {
127
- child.kill('SIGTERM')
128
- }
129
- }
128
+ let stopPromise: Promise<void> | undefined
129
+ const stop = () => stopPromise ??= stopChild()
130
130
 
131
131
  return new Promise<Tunnel>((resolve, reject) => {
132
- const timer = setTimeout(() => {
133
- void stop()
134
- reject(new Error(
135
- 'cloudflared did not report a tunnel hostname within '
136
- + `${URL_TIMEOUT_MS / 1000}s. Check network egress to Cloudflare, `
137
- + 'or run `cloudflared tunnel --url http://127.0.0.1:'
138
- + `${port}` + '` by hand to see its output.',
139
- ))
140
- }, URL_TIMEOUT_MS)
141
-
142
- let registered = false
143
132
  let tail = ''
144
133
  let settled = false
145
134
  const finish = (fn: () => void) => {
@@ -152,30 +141,50 @@ export async function startTunnel(
152
141
  fn()
153
142
  }
154
143
 
144
+ const timer = setTimeout(() => {
145
+ finish(() => {
146
+ void stop().then(() => reject(new Error(
147
+ 'cloudflared did not report a tunnel hostname within '
148
+ + `${URL_TIMEOUT_MS / 1000}s. Check network egress to `
149
+ + 'Cloudflare, or run `cloudflared tunnel --url '
150
+ + `http://127.0.0.1:${port}` + '` by hand to see its output.',
151
+ )))
152
+ })
153
+ }, URL_TIMEOUT_MS)
154
+
155
155
  // cloudflared logs the assigned hostname to stderr, not stdout. Watch
156
156
  // both so a future version that switches streams still works.
157
157
  const onChunk = (chunk: Buffer) => {
158
158
  const text = chunk.toString()
159
- // Keep watching after the hostname: registration lands a beat later, and
160
- // on a bad run it never lands at all, which is the case worth reporting.
159
+ // Keep watching after the hostname so a readiness failure can include
160
+ // cloudflared's account of what happened.
161
161
  tail = (tail + text).slice(-LOG_TAIL_BYTES)
162
- if(REGISTERED.test(text)) {
163
- registered = true
164
- }
165
162
 
166
163
  const match = QUICK_TUNNEL_URL.exec(text)
167
164
  if(match) {
168
165
  const url = match[0]
169
166
  finish(() => {
170
- void waitUntilRouted(url, () => registered).then((ready) => {
167
+ const probeUrl = new URL(probePath, `${url}/`).toString()
168
+ void waitUntilRouted(probeUrl, {
169
+ isRunning: () => child.exitCode === null
170
+ && child.signalCode === null,
171
+ pollMs: readyPollMs,
172
+ timeoutMs: readyTimeoutMs,
173
+ }).then(async(ready) => {
171
174
  if(ready) {
172
- resolve({ url, ready, stop, binary })
175
+ resolve({ url, stop, binary })
173
176
  return
174
177
  }
175
178
 
179
+ await stop()
176
180
  LOGGER.warn({ url, tail },
177
- 'tunnel never registered with an edge; returning anyway')
178
- resolve({ url, ready, stop, binary, diagnostics: tail })
181
+ 'tunnel public URL never became reachable')
182
+ reject(new Error(
183
+ 'cloudflared created a hostname, but the public viewer did '
184
+ + `not become reachable within ${readyTimeoutMs / 1000}s. `
185
+ + 'Retry share_browser_view. cloudflared output:\n'
186
+ + tail,
187
+ ))
179
188
  })
180
189
  })
181
190
  }
@@ -205,4 +214,27 @@ export async function startTunnel(
205
214
  )))
206
215
  })
207
216
  })
217
+
218
+ async function stopChild() {
219
+ if(child.exitCode !== null || child.signalCode !== null) {
220
+ return
221
+ }
222
+
223
+ let timeout: NodeJS.Timeout | undefined
224
+ const timedOut = new Promise<void>((resolve) => {
225
+ timeout = setTimeout(resolve, STOP_TIMEOUT_MS)
226
+ })
227
+ child.kill('SIGTERM')
228
+ await Promise.race([
229
+ exited,
230
+ timedOut,
231
+ ])
232
+ clearTimeout(timeout)
233
+ if(child.exitCode !== null || child.signalCode !== null) {
234
+ return
235
+ }
236
+
237
+ child.kill('SIGKILL')
238
+ await exited
239
+ }
208
240
  }