@reclaimprotocol/agent 0.1.22 → 0.1.24

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 (163) hide show
  1. package/README.md +55 -20
  2. package/lib/authoring/backend.d.ts +13 -10
  3. package/lib/authoring/tools.js +106 -9
  4. package/lib/cdp/browser-proxy.d.ts +78 -0
  5. package/lib/cdp/browser-proxy.js +186 -0
  6. package/lib/cdp/capture-engine.js +8 -7
  7. package/lib/cdp/client.d.ts +10 -0
  8. package/lib/cdp/geo.d.ts +10 -0
  9. package/lib/cdp/geo.js +36 -0
  10. package/lib/cdp/index.d.ts +1 -0
  11. package/lib/cdp/index.js +1 -0
  12. package/lib/live/cloudflared.d.ts +15 -0
  13. package/lib/live/cloudflared.js +268 -0
  14. package/lib/live/page.d.ts +20 -0
  15. package/lib/live/page.js +134 -0
  16. package/lib/live/tunnel.d.ts +23 -0
  17. package/lib/live/tunnel.js +120 -0
  18. package/lib/live/viewer.d.ts +28 -0
  19. package/lib/live/viewer.js +232 -0
  20. package/lib/mcp/api/operations.gen.js +34 -26
  21. package/lib/mcp/api/to-zod.d.ts +1 -1
  22. package/lib/mcp/api/to-zod.js +1 -1
  23. package/lib/mcp/api/tools.js +1 -1
  24. package/lib/mcp/instructions.d.ts +15 -2
  25. package/lib/mcp/instructions.js +101 -38
  26. package/lib/mcp/start.js +4 -3
  27. package/lib/mcp/tools/agent/analyze.js +5 -1
  28. package/lib/mcp/tools/agent/attach.d.ts +8 -1
  29. package/lib/mcp/tools/agent/attach.js +166 -22
  30. package/lib/mcp/tools/agent/build.d.ts +13 -8
  31. package/lib/mcp/tools/agent/build.js +22 -11
  32. package/lib/mcp/tools/agent/capture.js +5 -1
  33. package/lib/mcp/tools/agent/eval.js +3 -1
  34. package/lib/mcp/tools/agent/find.js +6 -5
  35. package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
  36. package/lib/mcp/tools/agent/how-it-works.js +57 -17
  37. package/lib/mcp/tools/agent/live-view.d.ts +21 -0
  38. package/lib/mcp/tools/agent/live-view.js +153 -0
  39. package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
  40. package/lib/mcp/tools/agent/pod-proof.js +64 -0
  41. package/lib/mcp/tools/agent/propose.js +29 -3
  42. package/lib/mcp/tools/agent/proxy.d.ts +3 -0
  43. package/lib/mcp/tools/agent/proxy.js +199 -0
  44. package/lib/mcp/tools/agent/publish.js +49 -77
  45. package/lib/mcp/tools/agent/session.d.ts +2 -1
  46. package/lib/mcp/tools/agent/session.js +26 -7
  47. package/lib/mcp/tools/agent/shared.js +17 -8
  48. package/lib/mcp/tools/authenticate/authenticate.d.ts +11 -0
  49. package/lib/mcp/tools/authenticate/authenticate.js +143 -121
  50. package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -3
  51. package/lib/mcp/tools/authenticate/builder-access.js +8 -3
  52. package/lib/mcp/tools/mode/mode.d.ts +1 -1
  53. package/lib/mcp/tools/mode/mode.js +26 -7
  54. package/lib/old/client.d.ts +9 -0
  55. package/lib/old/client.js +19 -1
  56. package/lib/old/instructions.d.ts +9 -1
  57. package/lib/old/instructions.js +4 -6
  58. package/lib/old/login-cdp.d.ts +2 -2
  59. package/lib/old/login-cdp.js +46 -6
  60. package/lib/old/to-register.d.ts +20 -8
  61. package/lib/old/to-register.js +7 -1
  62. package/lib/old/tools/authenticate.js +9 -1
  63. package/lib/old/tools/get-provider-info.js +6 -5
  64. package/lib/old/tools/issue-app-credentials.js +3 -2
  65. package/lib/old/tools/me-providers.js +3 -1
  66. package/lib/old/tools/publish.d.ts +1 -1
  67. package/lib/old/tools/publish.js +132 -129
  68. package/lib/old/tools/update-provider-metadata.d.ts +1 -1
  69. package/lib/old/tools/update-provider-metadata.js +1 -1
  70. package/lib/old/tools/verify-sdk-setup.js +1 -1
  71. package/lib/proof/attestor.d.ts +3 -3
  72. package/lib/proof/debug-response-dump.js +4 -4
  73. package/lib/proof/index.d.ts +1 -0
  74. package/lib/proof/index.js +1 -0
  75. package/lib/proof/page-replay.d.ts +36 -0
  76. package/lib/proof/page-replay.js +76 -0
  77. package/lib/proof/pod-prover.d.ts +47 -0
  78. package/lib/proof/pod-prover.js +155 -0
  79. package/lib/proof/replay.d.ts +28 -0
  80. package/lib/proof/replay.js +39 -18
  81. package/lib/provider/dom-xpath.js +11 -9
  82. package/lib/provider/draft.d.ts +13 -8
  83. package/lib/provider/draft.js +47 -13
  84. package/lib/provider/injection-doc.d.ts +23 -1
  85. package/lib/provider/injection-doc.js +34 -21
  86. package/lib/provider/match-redaction-warning.d.ts +2 -2
  87. package/lib/provider/match-redaction-warning.js +3 -3
  88. package/lib/provider/matcher.js +122 -12
  89. package/lib/provider/secrets.d.ts +3 -3
  90. package/lib/provider/secrets.js +3 -3
  91. package/lib/provider/to-version.d.ts +12 -9
  92. package/lib/provider/to-version.js +16 -4
  93. package/lib/tools/registry.d.ts +5 -5
  94. package/lib/tools/registry.js +5 -5
  95. package/package.json +4 -3
  96. package/samples/demo-server/README.md +7 -6
  97. package/skill/SKILL.builder.md +356 -189
  98. package/skill/SKILL.common.md +314 -79
  99. package/skill/SKILL.old.md +340 -155
  100. package/skill/builder-mode.md +58 -17
  101. package/skill/hash-validation.md +170 -0
  102. package/skill/interception.md +235 -0
  103. package/skill/js-sdk-integration.md +13 -7
  104. package/skill/old-mode.md +53 -12
  105. package/skill/user-script.md +61 -0
  106. package/src/authoring/backend.ts +13 -10
  107. package/src/authoring/tools.ts +128 -9
  108. package/src/cdp/capture-engine.ts +8 -7
  109. package/src/cdp/client.ts +8 -0
  110. package/src/cdp/geo.ts +52 -0
  111. package/src/cdp/index.ts +1 -0
  112. package/src/consts.ts +1 -0
  113. package/src/live/cloudflared.ts +348 -0
  114. package/src/live/page.ts +145 -0
  115. package/src/live/tunnel.ts +157 -0
  116. package/src/live/viewer.ts +293 -0
  117. package/src/mcp/api/operations.gen.ts +34 -26
  118. package/src/mcp/api/to-zod.ts +1 -1
  119. package/src/mcp/api/tools.ts +1 -1
  120. package/src/mcp/instructions.ts +121 -48
  121. package/src/mcp/start.ts +4 -3
  122. package/src/mcp/tools/agent/analyze.ts +5 -1
  123. package/src/mcp/tools/agent/attach.ts +194 -22
  124. package/src/mcp/tools/agent/build.ts +26 -15
  125. package/src/mcp/tools/agent/capture.ts +5 -1
  126. package/src/mcp/tools/agent/eval.ts +4 -1
  127. package/src/mcp/tools/agent/find.ts +6 -5
  128. package/src/mcp/tools/agent/how-it-works.ts +62 -21
  129. package/src/mcp/tools/agent/live-view.ts +198 -0
  130. package/src/mcp/tools/agent/pod-proof.ts +90 -0
  131. package/src/mcp/tools/agent/propose.ts +31 -3
  132. package/src/mcp/tools/agent/publish.ts +54 -78
  133. package/src/mcp/tools/agent/session.ts +26 -5
  134. package/src/mcp/tools/agent/shared.ts +19 -8
  135. package/src/mcp/tools/authenticate/authenticate.ts +167 -165
  136. package/src/mcp/tools/authenticate/builder-access.ts +8 -3
  137. package/src/mcp/tools/mode/mode.ts +28 -7
  138. package/src/old/client.ts +30 -1
  139. package/src/old/instructions.ts +4 -10
  140. package/src/old/login-cdp.ts +50 -6
  141. package/src/old/to-register.ts +28 -8
  142. package/src/old/tools/authenticate.ts +9 -1
  143. package/src/old/tools/get-provider-info.ts +6 -5
  144. package/src/old/tools/issue-app-credentials.ts +3 -2
  145. package/src/old/tools/me-providers.ts +3 -1
  146. package/src/old/tools/publish.ts +140 -130
  147. package/src/old/tools/update-provider-metadata.ts +1 -1
  148. package/src/old/tools/verify-sdk-setup.ts +1 -1
  149. package/src/proof/attestor.ts +3 -3
  150. package/src/proof/debug-response-dump.ts +4 -4
  151. package/src/proof/index.ts +8 -0
  152. package/src/proof/page-replay.ts +131 -0
  153. package/src/proof/pod-prover.ts +231 -0
  154. package/src/proof/replay.ts +60 -19
  155. package/src/provider/dom-xpath.ts +11 -9
  156. package/src/provider/draft.ts +56 -13
  157. package/src/provider/injection-doc.ts +37 -24
  158. package/src/provider/match-redaction-warning.ts +3 -3
  159. package/src/provider/matcher.ts +151 -10
  160. package/src/provider/secrets.ts +3 -3
  161. package/src/provider/to-version.ts +30 -13
  162. package/src/tools/registry.ts +5 -5
  163. package/skill/injection-api.md +0 -335
@@ -56,9 +56,10 @@ export function isStaticAsset(url) {
56
56
  return STATIC_ASSET_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
57
57
  }
58
58
  /** Response Content-Types for static assets. The URL-extension filter misses
59
- * extension-less bundler/CDN URLs (e.g. `/assets/3b8f1c`), so we also drop by
60
- * MIME at response time. `text/html`, `application/json`, and `text/plain`
61
- * are NOT here — those are the document/API shapes recipes match. */
59
+ * extension-less bundler/CDN URLs (for example, `/assets/3b8f1c`), so we also
60
+ * drop by MIME at response time. `text/html`, `application/json`, and
61
+ * `text/plain` are NOT here — those are the document/API shapes recipes
62
+ * match. */
62
63
  const STATIC_ASSET_MIME_EXACT = new Set([
63
64
  'text/css',
64
65
  'application/javascript', 'text/javascript', 'application/x-javascript',
@@ -165,9 +166,9 @@ export function createCaptureEngine(opts) {
165
166
  function onResponseReceived(p) {
166
167
  const f = inFlight.get(p.requestId);
167
168
  if (!f) {
168
- // No in-flight entry → the request was skipped (e.g. a static asset)
169
- // or never seen. Discard any buffered response extra-info so a skipped
170
- // asset can't leave a dangling entry.
169
+ // No in-flight entry → the request was skipped (for example, a static
170
+ // asset) or never seen. Discard any buffered response extra-info so a
171
+ // skipped asset can't leave a dangling entry.
171
172
  pendingResponseExtra.delete(p.requestId);
172
173
  return;
173
174
  }
@@ -225,7 +226,7 @@ export function createCaptureEngine(opts) {
225
226
  }
226
227
  catch {
227
228
  // Body might be unavailable for
228
- // some content-types (e.g. websocket). Skip.
229
+ // some content-types (for example, websocket). Skip.
229
230
  }
230
231
  }
231
232
  }
@@ -19,6 +19,16 @@ interface CdpTabEventMap {
19
19
  'Network.loadingFinished': {
20
20
  requestId: string;
21
21
  };
22
+ /** Screencast frames, for the live view (`live/viewer.ts`). Each carries a
23
+ * `sessionId` that MUST be acked with `Page.screencastFrameAck`, or CDP
24
+ * stops sending. */
25
+ 'Page.screencastFrame': {
26
+ data: string;
27
+ sessionId: number;
28
+ metadata?: {
29
+ timestamp?: number;
30
+ };
31
+ };
22
32
  }
23
33
  export interface CdpTabClient {
24
34
  send: (method: string, params?: object) => Promise<unknown>;
@@ -0,0 +1,10 @@
1
+ export interface IpGeolocation {
2
+ countryCode: string;
3
+ country?: string;
4
+ region?: string;
5
+ city?: string;
6
+ }
7
+ /** Resolve the calling machine's own public-IP country. Never throws — a
8
+ * lookup failure just loses the "best guess" convenience, falling back to
9
+ * {@link FALLBACK_COUNTRY_CODE} rather than blocking applying a proxy. */
10
+ export declare function suggestCountryFromIp(fetchImpl?: typeof fetch): Promise<IpGeolocation>;
package/lib/cdp/geo.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Country-code suggestion for a country-routed Popcorn browser session, when
3
+ * the caller doesn't name one explicitly. `attach_browser` returns this as the
4
+ * suggestion to confirm with the developer — the calling machine's own
5
+ * public-IP country, resolved the same way reclaim-portal's `ip-geolocation`
6
+ * util does.
7
+ */
8
+ import { LOGGER } from "../logger.js";
9
+ const IPINFO_URL = 'https://ipinfo.io/json';
10
+ const FALLBACK_COUNTRY_CODE = 'US';
11
+ /** Resolve the calling machine's own public-IP country. Never throws — a
12
+ * lookup failure just loses the "best guess" convenience, falling back to
13
+ * {@link FALLBACK_COUNTRY_CODE} rather than blocking applying a proxy. */
14
+ export async function suggestCountryFromIp(fetchImpl = fetch) {
15
+ 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
+ };
31
+ }
32
+ catch (err) {
33
+ LOGGER.warn({ err }, 'suggestCountryFromIp failed; using fallback');
34
+ return { countryCode: FALLBACK_COUNTRY_CODE };
35
+ }
36
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './capture-engine.ts';
2
2
  export { startCapture } from './capture.ts';
3
3
  export { BodyStore } from './eviction.ts';
4
+ export * from './geo.ts';
package/lib/cdp/index.js CHANGED
@@ -4,3 +4,4 @@
4
4
  export * from "./capture-engine.js";
5
5
  export { startCapture } from "./capture.js";
6
6
  export { BodyStore } from "./eviction.js";
7
+ export * from "./geo.js";
@@ -0,0 +1,15 @@
1
+ export interface CloudflaredBinary {
2
+ /** Path to run. */
3
+ path: string;
4
+ /** How it was found — surfaced so the caller can tell the developer when a
5
+ * download just happened on their machine. */
6
+ source: 'env' | 'path' | 'cached' | 'downloaded';
7
+ }
8
+ /**
9
+ * Resolve a runnable cloudflared, downloading one only if `autoInstall` allows
10
+ * it and nothing else turned up. Throws with an actionable message when this
11
+ * platform has no build, or the download fails.
12
+ */
13
+ export declare function resolveCloudflared({ autoInstall }?: {
14
+ autoInstall?: boolean;
15
+ }): Promise<CloudflaredBinary>;
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Finding — and if necessary fetching — the `cloudflared` binary the live view
3
+ * tunnels through.
4
+ *
5
+ * Resolution order, first hit wins:
6
+ * 1. `RECLAIM_CLOUDFLARED_PATH`, for an air-gapped box or a corporate mirror.
7
+ * 2. `cloudflared` on PATH — someone who installed it with a package manager
8
+ * keeps their own copy, updated their way.
9
+ * 3. `~/.reclaim/bin/cloudflared`, ours from a previous run.
10
+ * 4. Download it from Cloudflare's GitHub releases into `~/.reclaim/bin`.
11
+ *
12
+ * Step 4 happens on FIRST USE, never at install time. The npm `cloudflared`
13
+ * package would have done this for us, but its postinstall pulls a ~38 MB
14
+ * binary on every `npm install` of this package — a cost paid by everyone who
15
+ * never shares a live view. `run_proof`'s ZK circuits are deferred for exactly
16
+ * the same reason.
17
+ *
18
+ * Every download is SHA-256 verified. Cloudflare publishes a checksum table in
19
+ * the release notes (not as a release asset), so we read the release metadata
20
+ * first, take the digest for our asset, and refuse to install anything that
21
+ * doesn't match. The release TAG from that same response is used to build the
22
+ * download URL, so the checksum and the bytes always come from one release —
23
+ * `/latest/download/` could otherwise hand us a newer build than the table we
24
+ * just read.
25
+ *
26
+ * Verification is not optional: this writes an executable to the developer's
27
+ * machine. If the checksum can't be established, we fail rather than install.
28
+ * The escape hatch is to install cloudflared yourself — step 2 then wins and
29
+ * nothing is downloaded.
30
+ */
31
+ import { execFile } from 'node:child_process';
32
+ import { createHash } from 'node:crypto';
33
+ import { chmod, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
34
+ import { arch, platform } from 'node:os';
35
+ import { join } from 'node:path';
36
+ import { promisify } from 'node:util';
37
+ import { gunzipSync } from 'node:zlib';
38
+ import { LOGGER } from "../logger.js";
39
+ import { reclaimHomeDirPath } from "../paths.js";
40
+ const execFileAsync = promisify(execFile);
41
+ /** Release metadata: the tag to pin to, and the checksum table in its notes. */
42
+ const LATEST_RELEASE_API = 'https://api.github.com/repos/cloudflare/cloudflared/releases/latest';
43
+ /** Asset downloads, keyed by the tag we read the checksums from. */
44
+ function releaseDownloadUrl(tag, asset) {
45
+ return 'https://github.com/cloudflare/cloudflared/releases/download'
46
+ + `/${tag}/${asset}`;
47
+ }
48
+ /** Give up on a slow or stalled download rather than hanging the tool. */
49
+ const DOWNLOAD_TIMEOUT_MS = 180000;
50
+ /**
51
+ * The release asset for this machine, or `undefined` when Cloudflare ships no
52
+ * build for it. macOS assets are gzipped tarballs; everything else is the bare
53
+ * executable.
54
+ */
55
+ function assetFor(os, cpu) {
56
+ if (os === 'darwin') {
57
+ const suffix = cpu === 'arm64' ? 'arm64' : 'amd64';
58
+ return { name: `cloudflared-darwin-${suffix}.tgz`, tarball: true };
59
+ }
60
+ if (os === 'linux') {
61
+ const map = {
62
+ x64: 'amd64',
63
+ arm64: 'arm64',
64
+ arm: 'arm',
65
+ ia32: '386',
66
+ };
67
+ const suffix = map[cpu];
68
+ return suffix
69
+ ? { name: `cloudflared-linux-${suffix}`, tarball: false }
70
+ : undefined;
71
+ }
72
+ if (os === 'win32') {
73
+ const suffix = cpu === 'ia32' ? '386' : 'amd64';
74
+ return { name: `cloudflared-windows-${suffix}.exe`, tarball: false };
75
+ }
76
+ return undefined;
77
+ }
78
+ /** Our install location. Kept beside the other `~/.reclaim` caches. */
79
+ function cachedBinaryPath() {
80
+ const name = platform() === 'win32' ? 'cloudflared.exe' : 'cloudflared';
81
+ return join(reclaimHomeDirPath(), 'bin', name);
82
+ }
83
+ /** Whether `command` runs. Used both for a PATH probe and to smoke-test a
84
+ * freshly downloaded binary before we hand it back. */
85
+ async function runsOk(command) {
86
+ try {
87
+ await execFileAsync(command, ['--version'], { timeout: 15000 });
88
+ return true;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ async function exists(path) {
95
+ try {
96
+ await stat(path);
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ /**
104
+ * Resolve a runnable cloudflared, downloading one only if `autoInstall` allows
105
+ * it and nothing else turned up. Throws with an actionable message when this
106
+ * platform has no build, or the download fails.
107
+ */
108
+ export async function resolveCloudflared({ autoInstall = true } = {}) {
109
+ const override = process.env.RECLAIM_CLOUDFLARED_PATH?.trim();
110
+ if (override) {
111
+ if (!await runsOk(override)) {
112
+ throw new Error(`RECLAIM_CLOUDFLARED_PATH is set to "${override}", but that did not `
113
+ + 'run. Point it at a working cloudflared binary, or unset it to let '
114
+ + 'the agent find one.');
115
+ }
116
+ return { path: override, source: 'env' };
117
+ }
118
+ if (await runsOk('cloudflared')) {
119
+ return { path: 'cloudflared', source: 'path' };
120
+ }
121
+ const cached = cachedBinaryPath();
122
+ if (await exists(cached) && await runsOk(cached)) {
123
+ return { path: cached, source: 'cached' };
124
+ }
125
+ if (!autoInstall) {
126
+ throw new Error('cloudflared is not installed. Install it (macOS: `brew install '
127
+ + 'cloudflared`; other platforms: https://developers.cloudflare.com/'
128
+ + 'cloudflare-one/connections/connect-networks/downloads/), or allow '
129
+ + 'the agent to fetch it.');
130
+ }
131
+ await install(cached);
132
+ return { path: cached, source: 'downloaded' };
133
+ }
134
+ /** `cloudflared-linux-amd64: <64 hex>` lines inside the notes' fenced block. */
135
+ const CHECKSUM_LINE = /^\s*(\S+):\s*([a-f0-9]{64})\s*$/gim;
136
+ /**
137
+ * The current release's tag and its published SHA-256 table.
138
+ *
139
+ * Both come from one response on purpose: pinning the download to this tag is
140
+ * what makes the digest meaningful.
141
+ */
142
+ async function fetchRelease() {
143
+ const res = await fetch(LATEST_RELEASE_API, {
144
+ headers: {
145
+ accept: 'application/vnd.github+json',
146
+ 'user-agent': '@reclaimprotocol/agent',
147
+ },
148
+ signal: AbortSignal.timeout(30000),
149
+ });
150
+ if (!res.ok) {
151
+ throw new Error(`Could not read cloudflared release metadata: ${res.status} `
152
+ + `${res.statusText}. Needed to verify the download's checksum. `
153
+ + 'Install cloudflared yourself, or set RECLAIM_CLOUDFLARED_PATH.');
154
+ }
155
+ const release = await res.json();
156
+ const tag = release.tag_name;
157
+ if (!tag) {
158
+ throw new Error('cloudflared release metadata carried no tag.');
159
+ }
160
+ const checksums = new Map();
161
+ for (const match of (release.body ?? '').matchAll(CHECKSUM_LINE)) {
162
+ checksums.set(match[1], match[2].toLowerCase());
163
+ }
164
+ return { tag, checksums };
165
+ }
166
+ /** Download the release asset for this machine to `target`, verified. */
167
+ async function install(target) {
168
+ const asset = assetFor(platform(), arch());
169
+ if (!asset) {
170
+ throw new Error(`Cloudflare publishes no cloudflared build for ${platform()}/${arch()}. `
171
+ + 'Install it another way and set RECLAIM_CLOUDFLARED_PATH, or use '
172
+ + '`attach_browser` in builder mode, whose Popcorn browser returns a '
173
+ + 'hosted liveViewUrl and needs no tunnel.');
174
+ }
175
+ // Read the checksum table BEFORE downloading, and pin the download to the
176
+ // tag it came from.
177
+ const { tag, checksums } = await fetchRelease();
178
+ const expected = checksums.get(asset.name);
179
+ if (!expected) {
180
+ throw new Error(`cloudflared release ${tag} publishes no SHA-256 for ${asset.name}, so `
181
+ + 'the download cannot be verified. Refusing to install an unverified '
182
+ + 'binary. Install cloudflared yourself, or set '
183
+ + 'RECLAIM_CLOUDFLARED_PATH.');
184
+ }
185
+ const url = releaseDownloadUrl(tag, asset.name);
186
+ LOGGER.info({ url, target, tag }, 'cloudflared: downloading (first use)');
187
+ const res = await fetch(url, {
188
+ redirect: 'follow',
189
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
190
+ });
191
+ if (!res.ok) {
192
+ throw new Error(`Downloading cloudflared failed: ${res.status} ${res.statusText} `
193
+ + `from ${url}. Install it manually and retry, or set `
194
+ + 'RECLAIM_CLOUDFLARED_PATH.');
195
+ }
196
+ const downloaded = Buffer.from(await res.arrayBuffer());
197
+ // The macOS assets are tarballs, so unpack before hashing. Unpacked
198
+ // in-process rather than by shelling out to `tar`: that would add a
199
+ // dependency on something being on PATH, which is exactly the assumption
200
+ // this module exists to stop making.
201
+ //
202
+ // This ordering is not incidental. Cloudflare's table lists a digest for
203
+ // `cloudflared-darwin-arm64.tgz`, but that digest is of the BINARY INSIDE,
204
+ // not of the tarball (verified against a real release: the tarball hashes
205
+ // to something else entirely, and the binary self-reports the published
206
+ // value as its own checksum). The linux and windows entries, which ship a
207
+ // bare executable, do match the downloaded file. Hashing the payload we are
208
+ // about to write covers both, and is the stronger invariant anyway: what
209
+ // gets verified is what gets executed.
210
+ const payload = asset.tarball
211
+ ? readTarEntry(gunzipSync(downloaded), 'cloudflared')
212
+ : downloaded;
213
+ const actual = createHash('sha256').update(payload).digest('hex');
214
+ if (actual !== expected) {
215
+ // Nothing is written to disk on a mismatch — not even a partial file.
216
+ throw new Error(`cloudflared checksum mismatch for ${asset.name} (release ${tag}). `
217
+ + `Expected ${expected}, got ${actual}. The download was discarded. `
218
+ + 'Retry; if it persists, install cloudflared yourself and set '
219
+ + 'RECLAIM_CLOUDFLARED_PATH.');
220
+ }
221
+ LOGGER.info({ asset: asset.name, tag }, 'cloudflared: checksum verified');
222
+ // Write beside the target, then rename: a half-written or unverified
223
+ // binary is never left at a path a later run would trust.
224
+ await mkdir(join(target, '..'), { recursive: true });
225
+ const partial = `${target}.download`;
226
+ await writeFile(partial, payload);
227
+ await rename(partial, target);
228
+ if (platform() !== 'win32') {
229
+ await chmod(target, 0o755);
230
+ }
231
+ if (!await runsOk(target)) {
232
+ await rm(target, { force: true });
233
+ throw new Error('Downloaded cloudflared, but it did not run. Removed it. Install '
234
+ + 'cloudflared manually and retry.');
235
+ }
236
+ LOGGER.info({ target }, 'cloudflared: installed');
237
+ }
238
+ /** A tar header block is 512 bytes, and so is every data block. */
239
+ const TAR_BLOCK = 512;
240
+ /**
241
+ * Pull one file out of an uncompressed tar archive.
242
+ *
243
+ * Only as much of the format as this needs: walk the 512-byte headers, read
244
+ * each entry's name and octal size, and return the payload of the first
245
+ * regular file whose basename matches. Cloudflare's macOS tarball holds a
246
+ * single binary, so there is nothing more to handle.
247
+ */
248
+ function readTarEntry(tar, wanted) {
249
+ let offset = 0;
250
+ while (offset + TAR_BLOCK <= tar.length) {
251
+ const header = tar.subarray(offset, offset + TAR_BLOCK);
252
+ const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '');
253
+ if (!name) {
254
+ break; // two zero blocks mark the end of the archive
255
+ }
256
+ const size = parseInt(header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim(), 8) || 0;
257
+ const type = header.subarray(156, 157).toString('utf8');
258
+ const dataStart = offset + TAR_BLOCK;
259
+ if ((type === '0' || type === '\0') && name.split('/').pop() === wanted) {
260
+ return tar.subarray(dataStart, dataStart + size);
261
+ }
262
+ // Data is padded up to the next block boundary.
263
+ offset = dataStart + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK;
264
+ }
265
+ throw new Error(`The cloudflared archive did not contain "${wanted}". Cloudflare may `
266
+ + 'have changed its release layout — install cloudflared manually and '
267
+ + 'set RECLAIM_CLOUDFLARED_PATH.');
268
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The live-view page served to whoever opens the tunnel URL.
3
+ *
4
+ * Deliberately dependency-free and inline: it is delivered through a throwaway
5
+ * tunnel, so it cannot rely on a CDN, a bundler, or anything cached.
6
+ *
7
+ * Frames arrive as base64 JPEG over Server-Sent Events rather than a
8
+ * WebSocket. SSE is one plain GET, so it survives tunnels and proxies that
9
+ * mishandle upgrades, and it reconnects on its own. Input goes the other way
10
+ * as small POSTs.
11
+ */
12
+ export interface PageOptions {
13
+ /** Path prefix that carries the access token, for example `/v/<token>`. */
14
+ base: string;
15
+ /** Whether to wire up mouse and keyboard forwarding. */
16
+ interactive: boolean;
17
+ /** Shown in the header so a viewer knows which tab they are watching. */
18
+ title: string;
19
+ }
20
+ export declare function renderViewerPage(opts: PageOptions): string;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The live-view page served to whoever opens the tunnel URL.
3
+ *
4
+ * Deliberately dependency-free and inline: it is delivered through a throwaway
5
+ * tunnel, so it cannot rely on a CDN, a bundler, or anything cached.
6
+ *
7
+ * Frames arrive as base64 JPEG over Server-Sent Events rather than a
8
+ * WebSocket. SSE is one plain GET, so it survives tunnels and proxies that
9
+ * mishandle upgrades, and it reconnects on its own. Input goes the other way
10
+ * as small POSTs.
11
+ */
12
+ /** Escape a value for embedding in a single-quoted JS string literal. */
13
+ function js(value) {
14
+ return value
15
+ .replace(/\\/g, '\\\\')
16
+ .replace(/'/g, "\\'")
17
+ .replace(/</g, '\\x3c');
18
+ }
19
+ export function renderViewerPage(opts) {
20
+ return `<!doctype html>
21
+ <html lang="en">
22
+ <head>
23
+ <meta charset="utf-8">
24
+ <meta name="viewport" content="width=device-width, initial-scale=1">
25
+ <meta name="robots" content="noindex, nofollow">
26
+ <title>Reclaim live view</title>
27
+ <style>
28
+ :root { color-scheme: dark; }
29
+ * { box-sizing: border-box; }
30
+ body { margin: 0; background: #0b0b0c; color: #e7e7ea; display: flex;
31
+ flex-direction: column; height: 100vh;
32
+ font: 14px/1.4 ui-sans-serif, system-ui, sans-serif; }
33
+ header { display: flex; align-items: center; gap: .75rem;
34
+ padding: .5rem .75rem; border-bottom: 1px solid #26262b; }
35
+ .dot { width: .5rem; height: .5rem; border-radius: 50%; background: #f5a524; }
36
+ .dot.live { background: #17c964; }
37
+ .title { color: #9a9aa4; overflow: hidden; text-overflow: ellipsis;
38
+ white-space: nowrap; }
39
+ .badge { margin-left: auto; font-size: 12px; padding: .15rem .4rem;
40
+ border: 1px solid #26262b; border-radius: .25rem; color: #9a9aa4; }
41
+ main { flex: 1; min-height: 0; display: grid; place-items: center;
42
+ padding: .75rem; }
43
+ img { max-width: 100%; max-height: 100%; display: block;
44
+ background: #fff; border-radius: .25rem; }
45
+ img.interactive { cursor: crosshair; }
46
+ .msg { color: #9a9aa4; }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <header>
51
+ <span class="dot" id="dot"></span>
52
+ <span class="title" id="title">${js(opts.title)}</span>
53
+ <span class="badge">${opts.interactive ? 'interactive' : 'view only'}</span>
54
+ </header>
55
+ <main><img id="screen" alt="Live view of the browser tab"><p class="msg"
56
+ id="msg">Connecting…</p></main>
57
+ <script>
58
+ (function () {
59
+ var BASE = '${js(opts.base)}';
60
+ var INTERACTIVE = ${opts.interactive ? 'true' : 'false'};
61
+ var screen = document.getElementById('screen');
62
+ var msg = document.getElementById('msg');
63
+ var dot = document.getElementById('dot');
64
+ var title = document.getElementById('title');
65
+
66
+ var es = new EventSource(BASE + '/stream');
67
+ es.addEventListener('frame', function (e) {
68
+ var payload = JSON.parse(e.data);
69
+ screen.src = 'data:image/jpeg;base64,' + payload.data;
70
+ if (payload.title) { title.textContent = payload.title; }
71
+ msg.style.display = 'none';
72
+ dot.className = 'dot live';
73
+ });
74
+ es.onerror = function () {
75
+ dot.className = 'dot';
76
+ msg.style.display = '';
77
+ msg.textContent = 'Reconnecting…';
78
+ };
79
+
80
+ if (!INTERACTIVE) { return; }
81
+ screen.className = 'interactive';
82
+
83
+ // The <img> is letterboxed by object-fit, so a click at some point in the
84
+ // element maps to page coordinates by the ratio of natural to rendered size.
85
+ function toPage(ev) {
86
+ var r = screen.getBoundingClientRect();
87
+ if (!screen.naturalWidth || !r.width) { return null; }
88
+ return {
89
+ x: Math.round((ev.clientX - r.left) * (screen.naturalWidth / r.width)),
90
+ y: Math.round((ev.clientY - r.top) * (screen.naturalHeight / r.height))
91
+ };
92
+ }
93
+
94
+ function post(body) {
95
+ return fetch(BASE + '/input', {
96
+ method: 'POST',
97
+ headers: { 'content-type': 'application/json' },
98
+ body: JSON.stringify(body)
99
+ }).catch(function () {});
100
+ }
101
+
102
+ screen.addEventListener('mousedown', function (ev) {
103
+ var p = toPage(ev); if (!p) { return; }
104
+ ev.preventDefault();
105
+ post({ kind: 'mouse', type: 'mousePressed', x: p.x, y: p.y,
106
+ button: 'left', clickCount: 1 });
107
+ });
108
+ screen.addEventListener('mouseup', function (ev) {
109
+ var p = toPage(ev); if (!p) { return; }
110
+ post({ kind: 'mouse', type: 'mouseReleased', x: p.x, y: p.y,
111
+ button: 'left', clickCount: 1 });
112
+ });
113
+ screen.addEventListener('wheel', function (ev) {
114
+ var p = toPage(ev); if (!p) { return; }
115
+ ev.preventDefault();
116
+ post({ kind: 'mouse', type: 'mouseWheel', x: p.x, y: p.y,
117
+ deltaX: ev.deltaX, deltaY: ev.deltaY });
118
+ }, { passive: false });
119
+
120
+ // Typing goes to the page, so the viewer never needs to focus anything.
121
+ window.addEventListener('keydown', function (ev) {
122
+ if (ev.metaKey || ev.ctrlKey) { return; }
123
+ ev.preventDefault();
124
+ post({ kind: 'key', type: 'keyDown', key: ev.key, code: ev.code });
125
+ });
126
+ window.addEventListener('keyup', function (ev) {
127
+ if (ev.metaKey || ev.ctrlKey) { return; }
128
+ post({ kind: 'key', type: 'keyUp', key: ev.key, code: ev.code });
129
+ });
130
+ })();
131
+ </script>
132
+ </body>
133
+ </html>`;
134
+ }
@@ -0,0 +1,23 @@
1
+ import { type CloudflaredBinary } from './cloudflared.ts';
2
+ export interface Tunnel {
3
+ /** The public HTTPS origin Cloudflare assigned. */
4
+ url: string;
5
+ /** Whether the edge answered before {@link READY_TIMEOUT_MS} elapsed. False
6
+ * means the tunnel is probably still propagating rather than broken — tell
7
+ * the viewer to retry rather than treating the share as failed. */
8
+ ready: boolean;
9
+ /** Terminate the tunnel. Safe to call more than once. */
10
+ stop: () => Promise<void>;
11
+ /** Which cloudflared ran, and whether we had to fetch it. */
12
+ binary: CloudflaredBinary;
13
+ }
14
+ /**
15
+ * Open a quick tunnel to `http://127.0.0.1:<port>` and resolve once
16
+ * Cloudflare has assigned a hostname.
17
+ *
18
+ * Rejects with an actionable message when `cloudflared` is not installed,
19
+ * exits early, or never reports a hostname.
20
+ */
21
+ export declare function startTunnel(port: number, { autoInstall }?: {
22
+ autoInstall?: boolean;
23
+ }): Promise<Tunnel>;