@reclaimprotocol/agent 0.1.23 → 0.1.25
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.
- package/README.md +56 -20
- package/lib/authoring/backend.d.ts +5 -4
- package/lib/authoring/tools.js +30 -30
- package/lib/cdp/capture-engine.js +8 -7
- package/lib/cdp/claims.d.ts +21 -0
- package/lib/cdp/claims.js +129 -0
- package/lib/cdp/container.d.ts +85 -0
- package/lib/cdp/container.js +481 -0
- package/lib/cdp/geo.d.ts +10 -0
- package/lib/cdp/geo.js +36 -0
- package/lib/cdp/index.d.ts +1 -0
- package/lib/cdp/index.js +1 -0
- package/lib/live/cloudflared.d.ts +15 -0
- package/lib/live/cloudflared.js +268 -0
- package/lib/live/tunnel.d.ts +26 -0
- package/lib/live/tunnel.js +160 -0
- package/lib/mcp/api/operations.gen.js +34 -26
- package/lib/mcp/api/to-zod.d.ts +1 -1
- package/lib/mcp/api/to-zod.js +1 -1
- package/lib/mcp/api/tools.js +1 -1
- package/lib/mcp/instructions.d.ts +15 -2
- package/lib/mcp/instructions.js +102 -38
- package/lib/mcp/start.js +62 -6
- package/lib/mcp/tools/agent/analyze.js +5 -1
- package/lib/mcp/tools/agent/attach.d.ts +29 -2
- package/lib/mcp/tools/agent/attach.js +310 -24
- package/lib/mcp/tools/agent/build.d.ts +18 -8
- package/lib/mcp/tools/agent/build.js +30 -12
- package/lib/mcp/tools/agent/capture.js +5 -1
- package/lib/mcp/tools/agent/eval.js +3 -1
- package/lib/mcp/tools/agent/find.js +6 -5
- package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
- package/lib/mcp/tools/agent/how-it-works.js +57 -17
- package/lib/mcp/tools/agent/index.d.ts +1 -2
- package/lib/mcp/tools/agent/index.js +1 -1
- package/lib/mcp/tools/agent/live-view.d.ts +17 -0
- package/lib/mcp/tools/agent/live-view.js +146 -0
- package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
- package/lib/mcp/tools/agent/pod-proof.js +64 -0
- package/lib/mcp/tools/agent/propose.js +4 -2
- package/lib/mcp/tools/agent/publish.js +47 -77
- package/lib/mcp/tools/agent/session.d.ts +16 -1
- package/lib/mcp/tools/agent/session.js +32 -7
- package/lib/mcp/tools/authenticate/authenticate.js +26 -13
- package/lib/mcp/tools/mode/mode.d.ts +1 -1
- package/lib/mcp/tools/mode/mode.js +10 -22
- package/lib/old/client.d.ts +1 -1
- package/lib/old/client.js +1 -1
- package/lib/old/instructions.d.ts +9 -1
- package/lib/old/instructions.js +4 -6
- package/lib/old/login-cdp.d.ts +2 -2
- package/lib/old/login-cdp.js +46 -6
- package/lib/old/to-register.d.ts +3 -2
- package/lib/old/to-register.js +1 -1
- package/lib/old/tools/authenticate.js +9 -1
- package/lib/old/tools/get-provider-info.js +6 -5
- package/lib/old/tools/issue-app-credentials.js +3 -2
- package/lib/old/tools/publish.d.ts +1 -1
- package/lib/old/tools/publish.js +90 -131
- package/lib/old/tools/update-provider-metadata.d.ts +1 -1
- package/lib/old/tools/update-provider-metadata.js +1 -1
- package/lib/old/tools/verify-sdk-setup.js +1 -1
- package/lib/proof/attestor.d.ts +3 -3
- package/lib/proof/debug-response-dump.js +4 -4
- package/lib/proof/index.d.ts +1 -0
- package/lib/proof/index.js +1 -0
- package/lib/proof/page-replay.d.ts +1 -1
- package/lib/proof/page-replay.js +1 -1
- package/lib/proof/pod-prover.d.ts +47 -0
- package/lib/proof/pod-prover.js +155 -0
- package/lib/provider/dom-xpath.js +11 -9
- package/lib/provider/draft.d.ts +10 -7
- package/lib/provider/draft.js +11 -9
- package/lib/provider/injection-doc.d.ts +23 -1
- package/lib/provider/injection-doc.js +34 -21
- package/lib/provider/match-redaction-warning.d.ts +2 -2
- package/lib/provider/match-redaction-warning.js +3 -3
- package/lib/provider/matcher.js +13 -12
- package/lib/provider/secrets.d.ts +3 -3
- package/lib/provider/secrets.js +3 -3
- package/lib/provider/to-version.d.ts +7 -6
- package/lib/provider/to-version.js +3 -3
- package/lib/tools/registry.d.ts +5 -5
- package/lib/tools/registry.js +5 -5
- package/package.json +3 -3
- package/samples/demo-server/README.md +7 -6
- package/skill/SKILL.builder.md +356 -189
- package/skill/SKILL.common.md +400 -97
- package/skill/SKILL.old.md +340 -155
- package/skill/builder-mode.md +60 -17
- package/skill/hash-validation.md +170 -0
- package/skill/interception.md +236 -0
- package/skill/js-sdk-integration.md +13 -7
- package/skill/old-mode.md +62 -21
- package/skill/user-script.md +61 -0
- package/src/authoring/backend.ts +5 -4
- package/src/authoring/tools.ts +31 -30
- package/src/cdp/capture-engine.ts +8 -7
- package/src/cdp/claims.ts +153 -0
- package/src/cdp/container.ts +593 -0
- package/src/cdp/geo.ts +52 -0
- package/src/cdp/index.ts +1 -0
- package/src/consts.ts +1 -0
- package/src/live/cloudflared.ts +348 -0
- package/src/live/tunnel.ts +208 -0
- package/src/mcp/api/operations.gen.ts +34 -26
- package/src/mcp/api/to-zod.ts +1 -1
- package/src/mcp/api/tools.ts +1 -1
- package/src/mcp/instructions.ts +122 -48
- package/src/mcp/start.ts +69 -5
- package/src/mcp/tools/agent/analyze.ts +5 -1
- package/src/mcp/tools/agent/attach.ts +367 -26
- package/src/mcp/tools/agent/build.ts +35 -16
- package/src/mcp/tools/agent/capture.ts +5 -1
- package/src/mcp/tools/agent/eval.ts +4 -1
- package/src/mcp/tools/agent/find.ts +6 -5
- package/src/mcp/tools/agent/how-it-works.ts +62 -21
- package/src/mcp/tools/agent/index.ts +6 -2
- package/src/mcp/tools/agent/live-view.ts +182 -0
- package/src/mcp/tools/agent/pod-proof.ts +90 -0
- package/src/mcp/tools/agent/propose.ts +4 -2
- package/src/mcp/tools/agent/publish.ts +52 -77
- package/src/mcp/tools/agent/session.ts +33 -6
- package/src/mcp/tools/authenticate/authenticate.ts +28 -13
- package/src/mcp/tools/mode/mode.ts +10 -22
- package/src/old/client.ts +2 -2
- package/src/old/instructions.ts +4 -10
- package/src/old/login-cdp.ts +50 -6
- package/src/old/to-register.ts +3 -2
- package/src/old/tools/authenticate.ts +9 -1
- package/src/old/tools/get-provider-info.ts +6 -5
- package/src/old/tools/issue-app-credentials.ts +3 -2
- package/src/old/tools/publish.ts +95 -131
- package/src/old/tools/update-provider-metadata.ts +1 -1
- package/src/old/tools/verify-sdk-setup.ts +1 -1
- package/src/proof/attestor.ts +3 -3
- package/src/proof/debug-response-dump.ts +4 -4
- package/src/proof/index.ts +8 -0
- package/src/proof/page-replay.ts +1 -1
- package/src/proof/pod-prover.ts +231 -0
- package/src/provider/dom-xpath.ts +11 -9
- package/src/provider/draft.ts +13 -10
- package/src/provider/injection-doc.ts +37 -24
- package/src/provider/match-redaction-warning.ts +3 -3
- package/src/provider/matcher.ts +13 -12
- package/src/provider/secrets.ts +3 -3
- package/src/provider/to-version.ts +11 -9
- package/src/tools/registry.ts +5 -5
- package/skill/injection-api.md +0 -364
|
@@ -0,0 +1,348 @@
|
|
|
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 {
|
|
34
|
+
chmod,
|
|
35
|
+
mkdir,
|
|
36
|
+
rename,
|
|
37
|
+
rm,
|
|
38
|
+
stat,
|
|
39
|
+
writeFile
|
|
40
|
+
} from 'node:fs/promises'
|
|
41
|
+
import { arch, platform } from 'node:os'
|
|
42
|
+
import { join } from 'node:path'
|
|
43
|
+
import { promisify } from 'node:util'
|
|
44
|
+
import { gunzipSync } from 'node:zlib'
|
|
45
|
+
import { LOGGER } from '../logger.ts'
|
|
46
|
+
import { reclaimHomeDirPath } from '../paths.ts'
|
|
47
|
+
|
|
48
|
+
const execFileAsync = promisify(execFile)
|
|
49
|
+
|
|
50
|
+
/** Release metadata: the tag to pin to, and the checksum table in its notes. */
|
|
51
|
+
const LATEST_RELEASE_API =
|
|
52
|
+
'https://api.github.com/repos/cloudflare/cloudflared/releases/latest'
|
|
53
|
+
/** Asset downloads, keyed by the tag we read the checksums from. */
|
|
54
|
+
function releaseDownloadUrl(tag: string, asset: string): string {
|
|
55
|
+
return 'https://github.com/cloudflare/cloudflared/releases/download'
|
|
56
|
+
+ `/${tag}/${asset}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Give up on a slow or stalled download rather than hanging the tool. */
|
|
60
|
+
const DOWNLOAD_TIMEOUT_MS = 180_000
|
|
61
|
+
|
|
62
|
+
export interface CloudflaredBinary {
|
|
63
|
+
/** Path to run. */
|
|
64
|
+
path: string
|
|
65
|
+
/** How it was found — surfaced so the caller can tell the developer when a
|
|
66
|
+
* download just happened on their machine. */
|
|
67
|
+
source: 'env' | 'path' | 'cached' | 'downloaded'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The release asset for this machine, or `undefined` when Cloudflare ships no
|
|
72
|
+
* build for it. macOS assets are gzipped tarballs; everything else is the bare
|
|
73
|
+
* executable.
|
|
74
|
+
*/
|
|
75
|
+
function assetFor(
|
|
76
|
+
os: string,
|
|
77
|
+
cpu: string,
|
|
78
|
+
): { name: string, tarball: boolean } | undefined {
|
|
79
|
+
if(os === 'darwin') {
|
|
80
|
+
const suffix = cpu === 'arm64' ? 'arm64' : 'amd64'
|
|
81
|
+
return { name: `cloudflared-darwin-${suffix}.tgz`, tarball: true }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if(os === 'linux') {
|
|
85
|
+
const map: Record<string, string> = {
|
|
86
|
+
x64: 'amd64',
|
|
87
|
+
arm64: 'arm64',
|
|
88
|
+
arm: 'arm',
|
|
89
|
+
ia32: '386',
|
|
90
|
+
}
|
|
91
|
+
const suffix = map[cpu]
|
|
92
|
+
return suffix
|
|
93
|
+
? { name: `cloudflared-linux-${suffix}`, tarball: false }
|
|
94
|
+
: undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if(os === 'win32') {
|
|
98
|
+
const suffix = cpu === 'ia32' ? '386' : 'amd64'
|
|
99
|
+
return { name: `cloudflared-windows-${suffix}.exe`, tarball: false }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Our install location. Kept beside the other `~/.reclaim` caches. */
|
|
106
|
+
function cachedBinaryPath(): string {
|
|
107
|
+
const name = platform() === 'win32' ? 'cloudflared.exe' : 'cloudflared'
|
|
108
|
+
return join(reclaimHomeDirPath(), 'bin', name)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether `command` runs. Used both for a PATH probe and to smoke-test a
|
|
112
|
+
* freshly downloaded binary before we hand it back. */
|
|
113
|
+
async function runsOk(command: string): Promise<boolean> {
|
|
114
|
+
try {
|
|
115
|
+
await execFileAsync(command, ['--version'], { timeout: 15_000 })
|
|
116
|
+
return true
|
|
117
|
+
} catch{
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function exists(path: string): Promise<boolean> {
|
|
123
|
+
try {
|
|
124
|
+
await stat(path)
|
|
125
|
+
return true
|
|
126
|
+
} catch{
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve a runnable cloudflared, downloading one only if `autoInstall` allows
|
|
133
|
+
* it and nothing else turned up. Throws with an actionable message when this
|
|
134
|
+
* platform has no build, or the download fails.
|
|
135
|
+
*/
|
|
136
|
+
export async function resolveCloudflared(
|
|
137
|
+
{ autoInstall = true }: { autoInstall?: boolean } = {},
|
|
138
|
+
): Promise<CloudflaredBinary> {
|
|
139
|
+
const override = process.env.RECLAIM_CLOUDFLARED_PATH?.trim()
|
|
140
|
+
if(override) {
|
|
141
|
+
if(!await runsOk(override)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`RECLAIM_CLOUDFLARED_PATH is set to "${override}", but that did not `
|
|
144
|
+
+ 'run. Point it at a working cloudflared binary, or unset it to let '
|
|
145
|
+
+ 'the agent find one.',
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { path: override, source: 'env' }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if(await runsOk('cloudflared')) {
|
|
153
|
+
return { path: 'cloudflared', source: 'path' }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cached = cachedBinaryPath()
|
|
157
|
+
if(await exists(cached) && await runsOk(cached)) {
|
|
158
|
+
return { path: cached, source: 'cached' }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if(!autoInstall) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
'cloudflared is not installed. Install it (macOS: `brew install '
|
|
164
|
+
+ 'cloudflared`; other platforms: https://developers.cloudflare.com/'
|
|
165
|
+
+ 'cloudflare-one/connections/connect-networks/downloads/), or allow '
|
|
166
|
+
+ 'the agent to fetch it.',
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await install(cached)
|
|
171
|
+
return { path: cached, source: 'downloaded' }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `cloudflared-linux-amd64: <64 hex>` lines inside the notes' fenced block. */
|
|
175
|
+
const CHECKSUM_LINE = /^\s*(\S+):\s*([a-f0-9]{64})\s*$/gim
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The current release's tag and its published SHA-256 table.
|
|
179
|
+
*
|
|
180
|
+
* Both come from one response on purpose: pinning the download to this tag is
|
|
181
|
+
* what makes the digest meaningful.
|
|
182
|
+
*/
|
|
183
|
+
async function fetchRelease(): Promise<{
|
|
184
|
+
tag: string
|
|
185
|
+
checksums: Map<string, string>
|
|
186
|
+
}> {
|
|
187
|
+
const res = await fetch(LATEST_RELEASE_API, {
|
|
188
|
+
headers: {
|
|
189
|
+
accept: 'application/vnd.github+json',
|
|
190
|
+
'user-agent': '@reclaimprotocol/agent',
|
|
191
|
+
},
|
|
192
|
+
signal: AbortSignal.timeout(30_000),
|
|
193
|
+
})
|
|
194
|
+
if(!res.ok) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Could not read cloudflared release metadata: ${res.status} `
|
|
197
|
+
+ `${res.statusText}. Needed to verify the download's checksum. `
|
|
198
|
+
+ 'Install cloudflared yourself, or set RECLAIM_CLOUDFLARED_PATH.',
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const release = await res.json() as { tag_name?: string, body?: string }
|
|
203
|
+
const tag = release.tag_name
|
|
204
|
+
if(!tag) {
|
|
205
|
+
throw new Error('cloudflared release metadata carried no tag.')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const checksums = new Map<string, string>()
|
|
209
|
+
for(const match of (release.body ?? '').matchAll(CHECKSUM_LINE)) {
|
|
210
|
+
checksums.set(match[1], match[2].toLowerCase())
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { tag, checksums }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Download the release asset for this machine to `target`, verified. */
|
|
217
|
+
async function install(target: string): Promise<void> {
|
|
218
|
+
const asset = assetFor(platform(), arch())
|
|
219
|
+
if(!asset) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Cloudflare publishes no cloudflared build for ${platform()}/${arch()}. `
|
|
222
|
+
+ 'Install it another way and set RECLAIM_CLOUDFLARED_PATH, or use '
|
|
223
|
+
+ '`attach_browser` in builder mode, whose Popcorn browser returns a '
|
|
224
|
+
+ 'hosted liveViewUrl and needs no tunnel.',
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Read the checksum table BEFORE downloading, and pin the download to the
|
|
229
|
+
// tag it came from.
|
|
230
|
+
const { tag, checksums } = await fetchRelease()
|
|
231
|
+
const expected = checksums.get(asset.name)
|
|
232
|
+
if(!expected) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`cloudflared release ${tag} publishes no SHA-256 for ${asset.name}, so `
|
|
235
|
+
+ 'the download cannot be verified. Refusing to install an unverified '
|
|
236
|
+
+ 'binary. Install cloudflared yourself, or set '
|
|
237
|
+
+ 'RECLAIM_CLOUDFLARED_PATH.',
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const url = releaseDownloadUrl(tag, asset.name)
|
|
242
|
+
LOGGER.info({ url, target, tag }, 'cloudflared: downloading (first use)')
|
|
243
|
+
const res = await fetch(url, {
|
|
244
|
+
redirect: 'follow',
|
|
245
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
246
|
+
})
|
|
247
|
+
if(!res.ok) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Downloading cloudflared failed: ${res.status} ${res.statusText} `
|
|
250
|
+
+ `from ${url}. Install it manually and retry, or set `
|
|
251
|
+
+ 'RECLAIM_CLOUDFLARED_PATH.',
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const downloaded = Buffer.from(await res.arrayBuffer())
|
|
256
|
+
|
|
257
|
+
// The macOS assets are tarballs, so unpack before hashing. Unpacked
|
|
258
|
+
// in-process rather than by shelling out to `tar`: that would add a
|
|
259
|
+
// dependency on something being on PATH, which is exactly the assumption
|
|
260
|
+
// this module exists to stop making.
|
|
261
|
+
//
|
|
262
|
+
// This ordering is not incidental. Cloudflare's table lists a digest for
|
|
263
|
+
// `cloudflared-darwin-arm64.tgz`, but that digest is of the BINARY INSIDE,
|
|
264
|
+
// not of the tarball (verified against a real release: the tarball hashes
|
|
265
|
+
// to something else entirely, and the binary self-reports the published
|
|
266
|
+
// value as its own checksum). The linux and windows entries, which ship a
|
|
267
|
+
// bare executable, do match the downloaded file. Hashing the payload we are
|
|
268
|
+
// about to write covers both, and is the stronger invariant anyway: what
|
|
269
|
+
// gets verified is what gets executed.
|
|
270
|
+
const payload = asset.tarball
|
|
271
|
+
? readTarEntry(gunzipSync(downloaded), 'cloudflared')
|
|
272
|
+
: downloaded
|
|
273
|
+
|
|
274
|
+
const actual = createHash('sha256').update(payload).digest('hex')
|
|
275
|
+
if(actual !== expected) {
|
|
276
|
+
// Nothing is written to disk on a mismatch — not even a partial file.
|
|
277
|
+
throw new Error(
|
|
278
|
+
`cloudflared checksum mismatch for ${asset.name} (release ${tag}). `
|
|
279
|
+
+ `Expected ${expected}, got ${actual}. The download was discarded. `
|
|
280
|
+
+ 'Retry; if it persists, install cloudflared yourself and set '
|
|
281
|
+
+ 'RECLAIM_CLOUDFLARED_PATH.',
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
LOGGER.info({ asset: asset.name, tag }, 'cloudflared: checksum verified')
|
|
286
|
+
|
|
287
|
+
// Write beside the target, then rename: a half-written or unverified
|
|
288
|
+
// binary is never left at a path a later run would trust.
|
|
289
|
+
await mkdir(join(target, '..'), { recursive: true })
|
|
290
|
+
const partial = `${target}.download`
|
|
291
|
+
await writeFile(partial, payload)
|
|
292
|
+
await rename(partial, target)
|
|
293
|
+
|
|
294
|
+
if(platform() !== 'win32') {
|
|
295
|
+
await chmod(target, 0o755)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if(!await runsOk(target)) {
|
|
299
|
+
await rm(target, { force: true })
|
|
300
|
+
throw new Error(
|
|
301
|
+
'Downloaded cloudflared, but it did not run. Removed it. Install '
|
|
302
|
+
+ 'cloudflared manually and retry.',
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
LOGGER.info({ target }, 'cloudflared: installed')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A tar header block is 512 bytes, and so is every data block. */
|
|
310
|
+
const TAR_BLOCK = 512
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Pull one file out of an uncompressed tar archive.
|
|
314
|
+
*
|
|
315
|
+
* Only as much of the format as this needs: walk the 512-byte headers, read
|
|
316
|
+
* each entry's name and octal size, and return the payload of the first
|
|
317
|
+
* regular file whose basename matches. Cloudflare's macOS tarball holds a
|
|
318
|
+
* single binary, so there is nothing more to handle.
|
|
319
|
+
*/
|
|
320
|
+
function readTarEntry(tar: Buffer, wanted: string): Buffer {
|
|
321
|
+
let offset = 0
|
|
322
|
+
while(offset + TAR_BLOCK <= tar.length) {
|
|
323
|
+
const header = tar.subarray(offset, offset + TAR_BLOCK)
|
|
324
|
+
const name = header.subarray(0, 100).toString('utf8').replace(/\0.*$/, '')
|
|
325
|
+
if(!name) {
|
|
326
|
+
break // two zero blocks mark the end of the archive
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const size = parseInt(
|
|
330
|
+
header.subarray(124, 136).toString('utf8').replace(/\0.*$/, '').trim(),
|
|
331
|
+
8,
|
|
332
|
+
) || 0
|
|
333
|
+
const type = header.subarray(156, 157).toString('utf8')
|
|
334
|
+
const dataStart = offset + TAR_BLOCK
|
|
335
|
+
if((type === '0' || type === '\0') && name.split('/').pop() === wanted) {
|
|
336
|
+
return tar.subarray(dataStart, dataStart + size)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Data is padded up to the next block boundary.
|
|
340
|
+
offset = dataStart + Math.ceil(size / TAR_BLOCK) * TAR_BLOCK
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
throw new Error(
|
|
344
|
+
`The cloudflared archive did not contain "${wanted}". Cloudflare may `
|
|
345
|
+
+ 'have changed its release layout — install cloudflared manually and '
|
|
346
|
+
+ 'set RECLAIM_CLOUDFLARED_PATH.',
|
|
347
|
+
)
|
|
348
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public HTTPS front door for a local port, via a `cloudflared` quick tunnel.
|
|
3
|
+
*
|
|
4
|
+
* A quick tunnel needs no Cloudflare account and no DNS: `cloudflared` dials
|
|
5
|
+
* out, Cloudflare hands back a throwaway `*.trycloudflare.com` hostname, and
|
|
6
|
+
* traffic arrives on the loopback port we pass in. That is the whole reason it
|
|
7
|
+
* is used here — the developer's machine keeps no inbound port open.
|
|
8
|
+
*
|
|
9
|
+
* **The hostname IS the secret, and it is the only one.** The tunnel carries no
|
|
10
|
+
* authorization of its own, and the browser runtime's viewer behind it has no
|
|
11
|
+
* login either — so whoever learns the URL has the browser. Cloudflare's
|
|
12
|
+
* hostnames are randomly generated, which is what makes that tolerable for a
|
|
13
|
+
* short, deliberate hand-off, and `stop_browser_view` is how it ends.
|
|
14
|
+
*
|
|
15
|
+
* That is weaker than what this file used to front. The agent's own screencast
|
|
16
|
+
* viewer added a 24-byte token to every path, so the hostname alone was not
|
|
17
|
+
* enough; the container's viewer has no equivalent. Callers must say plainly
|
|
18
|
+
* what the URL grants, and it must never front the container's CDP port, which
|
|
19
|
+
* is unauthenticated total control of the browser.
|
|
20
|
+
*/
|
|
21
|
+
import { spawn } from 'node:child_process'
|
|
22
|
+
import { LOGGER } from '../logger.ts'
|
|
23
|
+
import { type CloudflaredBinary, resolveCloudflared } from './cloudflared.ts'
|
|
24
|
+
|
|
25
|
+
/** How long to wait for cloudflared to report its hostname before giving up. */
|
|
26
|
+
const URL_TIMEOUT_MS = 30_000
|
|
27
|
+
/** After the hostname is printed, Cloudflare needs a moment before the edge
|
|
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. */
|
|
30
|
+
const READY_TIMEOUT_MS = 20_000
|
|
31
|
+
const READY_POLL_MS = 1_000
|
|
32
|
+
|
|
33
|
+
/** `https://<something>.trycloudflare.com`, as printed on cloudflared's log
|
|
34
|
+
* stream. Anchored to the scheme so a hostname inside prose can't match. */
|
|
35
|
+
const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
36
|
+
|
|
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
|
+
/** Tail of cloudflared's log kept for error messages. */
|
|
51
|
+
const LOG_TAIL_BYTES = 4096
|
|
52
|
+
|
|
53
|
+
export interface Tunnel {
|
|
54
|
+
/** The public HTTPS origin Cloudflare assigned. */
|
|
55
|
+
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
|
+
/** Terminate the tunnel. Safe to call more than once. */
|
|
64
|
+
stop: () => Promise<void>
|
|
65
|
+
/** Which cloudflared ran, and whether we had to fetch it. */
|
|
66
|
+
binary: CloudflaredBinary
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wait until the tunnel is actually carrying traffic.
|
|
71
|
+
*
|
|
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.
|
|
79
|
+
*/
|
|
80
|
+
async function waitUntilRouted(
|
|
81
|
+
url: string,
|
|
82
|
+
isRegistered: () => boolean,
|
|
83
|
+
): Promise<boolean> {
|
|
84
|
+
const deadline = Date.now() + READY_TIMEOUT_MS
|
|
85
|
+
while(Date.now() < deadline) {
|
|
86
|
+
if(isRegistered()) {
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await fetch(url, {
|
|
92
|
+
method: 'GET',
|
|
93
|
+
redirect: 'manual',
|
|
94
|
+
signal: AbortSignal.timeout(READY_POLL_MS * 2),
|
|
95
|
+
})
|
|
96
|
+
return true
|
|
97
|
+
} catch{
|
|
98
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return isRegistered()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Open a quick tunnel to `http://127.0.0.1:<port>` and resolve once
|
|
107
|
+
* Cloudflare has assigned a hostname.
|
|
108
|
+
*
|
|
109
|
+
* Rejects with an actionable message when `cloudflared` is not installed,
|
|
110
|
+
* exits early, or never reports a hostname.
|
|
111
|
+
*/
|
|
112
|
+
export async function startTunnel(
|
|
113
|
+
port: number,
|
|
114
|
+
{ autoInstall = true }: { autoInstall?: boolean } = {},
|
|
115
|
+
): Promise<Tunnel> {
|
|
116
|
+
// Resolves an existing cloudflared, or fetches one on first use. Throws
|
|
117
|
+
// with an actionable message when neither is possible.
|
|
118
|
+
const binary = await resolveCloudflared({ autoInstall })
|
|
119
|
+
const child = spawn(binary.path, [
|
|
120
|
+
'tunnel',
|
|
121
|
+
'--no-autoupdate',
|
|
122
|
+
'--url', `http://127.0.0.1:${port}`,
|
|
123
|
+
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
124
|
+
|
|
125
|
+
const stop = async() => {
|
|
126
|
+
if(!child.killed) {
|
|
127
|
+
child.kill('SIGTERM')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
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
|
+
let tail = ''
|
|
144
|
+
let settled = false
|
|
145
|
+
const finish = (fn: () => void) => {
|
|
146
|
+
if(settled) {
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
settled = true
|
|
151
|
+
clearTimeout(timer)
|
|
152
|
+
fn()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// cloudflared logs the assigned hostname to stderr, not stdout. Watch
|
|
156
|
+
// both so a future version that switches streams still works.
|
|
157
|
+
const onChunk = (chunk: Buffer) => {
|
|
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.
|
|
161
|
+
tail = (tail + text).slice(-LOG_TAIL_BYTES)
|
|
162
|
+
if(REGISTERED.test(text)) {
|
|
163
|
+
registered = true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const match = QUICK_TUNNEL_URL.exec(text)
|
|
167
|
+
if(match) {
|
|
168
|
+
const url = match[0]
|
|
169
|
+
finish(() => {
|
|
170
|
+
void waitUntilRouted(url, () => registered).then((ready) => {
|
|
171
|
+
if(ready) {
|
|
172
|
+
resolve({ url, ready, stop, binary })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
LOGGER.warn({ url, tail },
|
|
177
|
+
'tunnel never registered with an edge; returning anyway')
|
|
178
|
+
resolve({ url, ready, stop, binary, diagnostics: tail })
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
child.stdout?.on('data', onChunk)
|
|
185
|
+
child.stderr?.on('data', onChunk)
|
|
186
|
+
|
|
187
|
+
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
188
|
+
// `resolveCloudflared` already proved the binary runs, so an ENOENT
|
|
189
|
+
// here means it vanished between resolving and spawning.
|
|
190
|
+
finish(() => reject(
|
|
191
|
+
err.code === 'ENOENT'
|
|
192
|
+
? new Error(
|
|
193
|
+
`cloudflared disappeared from ${binary.path} before it could `
|
|
194
|
+
+ 'start. Retry — the agent will fetch it again if needed.',
|
|
195
|
+
)
|
|
196
|
+
: err,
|
|
197
|
+
))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
child.on('exit', (code) => {
|
|
201
|
+
LOGGER.debug({ code }, 'cloudflared exited')
|
|
202
|
+
finish(() => reject(new Error(
|
|
203
|
+
`cloudflared exited with code ${code} before reporting a `
|
|
204
|
+
+ 'hostname.',
|
|
205
|
+
)))
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
}
|