@cjhyy/code-shell-server 0.8.0

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 (70) hide show
  1. package/README.md +77 -0
  2. package/dist/attachment-service.d.ts +80 -0
  3. package/dist/attachment-service.js +587 -0
  4. package/dist/bin/code-shell-serve.d.ts +2 -0
  5. package/dist/bin/code-shell-serve.js +7 -0
  6. package/dist/client-message-id.d.ts +1 -0
  7. package/dist/client-message-id.js +8 -0
  8. package/dist/image-byte-probe.d.ts +17 -0
  9. package/dist/image-byte-probe.js +330 -0
  10. package/dist/index.d.ts +10 -0
  11. package/dist/index.js +10 -0
  12. package/dist/index.mobile-remote.d.ts +21 -0
  13. package/dist/index.mobile-remote.js +21 -0
  14. package/dist/index.serve.d.ts +3 -0
  15. package/dist/index.serve.js +3 -0
  16. package/dist/index.storage.d.ts +10 -0
  17. package/dist/index.storage.js +10 -0
  18. package/dist/index.worker.d.ts +2 -0
  19. package/dist/index.worker.js +2 -0
  20. package/dist/mobile-remote/access-passcode.d.ts +80 -0
  21. package/dist/mobile-remote/access-passcode.js +354 -0
  22. package/dist/mobile-remote/cloudflared-binary.d.ts +76 -0
  23. package/dist/mobile-remote/cloudflared-binary.js +242 -0
  24. package/dist/mobile-remote/codex-parse.d.ts +34 -0
  25. package/dist/mobile-remote/codex-parse.js +131 -0
  26. package/dist/mobile-remote/codex-room-agent.d.ts +94 -0
  27. package/dist/mobile-remote/codex-room-agent.js +213 -0
  28. package/dist/mobile-remote/mobile-attachments.d.ts +21 -0
  29. package/dist/mobile-remote/mobile-attachments.js +119 -0
  30. package/dist/mobile-remote/mobile-chat-turn.d.ts +32 -0
  31. package/dist/mobile-remote/mobile-chat-turn.js +67 -0
  32. package/dist/mobile-remote/mobile-history.d.ts +22 -0
  33. package/dist/mobile-remote/mobile-history.js +27 -0
  34. package/dist/mobile-remote/mobile-run-dispatch.d.ts +17 -0
  35. package/dist/mobile-remote/mobile-run-dispatch.js +56 -0
  36. package/dist/mobile-remote/mobile-static.d.ts +43 -0
  37. package/dist/mobile-remote/mobile-static.js +154 -0
  38. package/dist/mobile-remote/mobile-upload-service.d.ts +57 -0
  39. package/dist/mobile-remote/mobile-upload-service.js +370 -0
  40. package/dist/mobile-remote/pairing.d.ts +8 -0
  41. package/dist/mobile-remote/pairing.js +23 -0
  42. package/dist/mobile-remote/path-bins.d.ts +7 -0
  43. package/dist/mobile-remote/path-bins.js +16 -0
  44. package/dist/mobile-remote/pending-approvals.d.ts +14 -0
  45. package/dist/mobile-remote/pending-approvals.js +54 -0
  46. package/dist/mobile-remote/remote-host-manager.d.ts +117 -0
  47. package/dist/mobile-remote/remote-host-manager.js +345 -0
  48. package/dist/mobile-remote/resident-agent.d.ts +98 -0
  49. package/dist/mobile-remote/resident-agent.js +216 -0
  50. package/dist/mobile-remote/room-manager.d.ts +286 -0
  51. package/dist/mobile-remote/room-manager.js +765 -0
  52. package/dist/mobile-remote/trusted-device-store.d.ts +19 -0
  53. package/dist/mobile-remote/trusted-device-store.js +118 -0
  54. package/dist/mobile-remote/tunnel-manager.d.ts +105 -0
  55. package/dist/mobile-remote/tunnel-manager.js +375 -0
  56. package/dist/mobile-remote/types.d.ts +1 -0
  57. package/dist/mobile-remote/types.js +1 -0
  58. package/dist/mobile-remote/viewer-identity.d.ts +5 -0
  59. package/dist/mobile-remote/viewer-identity.js +5 -0
  60. package/dist/serve/cli.d.ts +15 -0
  61. package/dist/serve/cli.js +115 -0
  62. package/dist/serve/headless-server.d.ts +40 -0
  63. package/dist/serve/headless-server.js +276 -0
  64. package/dist/sessions-service.d.ts +73 -0
  65. package/dist/sessions-service.js +303 -0
  66. package/dist/static-files.d.ts +1 -0
  67. package/dist/static-files.js +25 -0
  68. package/dist/worker-bridge-core.d.ts +154 -0
  69. package/dist/worker-bridge-core.js +315 -0
  70. package/package.json +63 -0
@@ -0,0 +1,354 @@
1
+ import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ const DEFAULT_MAX_ATTEMPTS = 5;
5
+ const DEFAULT_LOCKOUT_MS = 60_000;
6
+ const DEFAULT_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
7
+ const COOKIE_NAME = "cs_access";
8
+ const SCRYPT_KEYLEN = 32;
9
+ /**
10
+ * The public-tunnel access gate. A single shared passcode (set by the desktop
11
+ * user) guards the tunnel; a phone that enters it correctly receives a signed
12
+ * "remember" token stored in a cookie so it is not re-challenged. Security
13
+ * properties this unit guarantees:
14
+ * - the passcode is only ever stored as a salted scrypt hash (no plaintext);
15
+ * - remember-tokens are HMACs over a per-passcode secret, so rotating the
16
+ * passcode invalidates every outstanding token;
17
+ * - brute-force is rate-limited: N consecutive wrong attempts lock the gate
18
+ * for a window, during which even the correct passcode is refused.
19
+ * The trusted-device + pairing layers still sit behind this (defense in depth).
20
+ */
21
+ export class AccessPasscode {
22
+ filePath;
23
+ now;
24
+ maxAttempts;
25
+ lockoutMs;
26
+ tokenMaxAgeMs;
27
+ failures = 0;
28
+ lockedUntil = 0;
29
+ constructor(opts) {
30
+ this.filePath = opts.filePath;
31
+ this.now = opts.now ?? Date.now;
32
+ this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
33
+ this.lockoutMs = opts.lockoutMs ?? DEFAULT_LOCKOUT_MS;
34
+ this.tokenMaxAgeMs = opts.tokenMaxAgeMs ?? DEFAULT_TOKEN_MAX_AGE_MS;
35
+ }
36
+ isSet() {
37
+ return this.read() !== undefined;
38
+ }
39
+ /** Set (or rotate) the passcode. Rotating `secret` invalidates old tokens. */
40
+ set(passcode) {
41
+ const salt = randomBytes(16).toString("hex");
42
+ const hash = this.hashPasscode(passcode, salt);
43
+ const secret = randomBytes(32).toString("hex");
44
+ this.write({ hash, salt, secret });
45
+ this.failures = 0;
46
+ this.lockedUntil = 0;
47
+ }
48
+ /**
49
+ * Verify a passcode. On success returns a fresh remember-token and resets the
50
+ * failure counter; on failure (or while rate-limited) returns null and counts
51
+ * a failure. While locked out, even a correct passcode returns null.
52
+ */
53
+ verify(passcode) {
54
+ const record = this.read();
55
+ if (!record)
56
+ return null;
57
+ if (this.isLocked())
58
+ return null;
59
+ const candidate = this.hashPasscode(passcode, record.salt);
60
+ if (!safeEqualHex(candidate, record.hash)) {
61
+ this.failures++;
62
+ if (this.failures >= this.maxAttempts) {
63
+ this.lockedUntil = this.now() + this.lockoutMs;
64
+ }
65
+ return null;
66
+ }
67
+ this.failures = 0;
68
+ this.lockedUntil = 0;
69
+ return this.issueToken(record);
70
+ }
71
+ /** Validate a remember-token: HMAC signature must match AND be over the
72
+ * current secret (so it dies when the passcode is rotated), AND the signed
73
+ * issue timestamp must be within the validity window (so a stolen/synced
74
+ * cookie cannot grant access indefinitely). */
75
+ verifyToken(token) {
76
+ const record = this.read();
77
+ if (!record)
78
+ return false;
79
+ const dot = token.lastIndexOf(".");
80
+ if (dot <= 0)
81
+ return false;
82
+ const payload = token.slice(0, dot);
83
+ const sig = token.slice(dot + 1);
84
+ const expected = this.sign(payload, record.secret);
85
+ if (!safeEqualHex(sig, expected))
86
+ return false;
87
+ // The timestamp is covered by the HMAC, so it is trustworthy once the
88
+ // signature checks out.
89
+ const issuedAt = Number(payload.slice(payload.indexOf(".") + 1));
90
+ if (!Number.isFinite(issuedAt))
91
+ return false;
92
+ return this.now() - issuedAt <= this.tokenMaxAgeMs;
93
+ }
94
+ /**
95
+ * HTTP gate. Returns true (allow) when the request carries a valid remember
96
+ * token (cookie) or the correct passcode (query/header); on a fresh correct
97
+ * passcode it also sets the remember cookie so the phone is not re-challenged.
98
+ * Returns false after writing either a redirect or a 401 challenge — the
99
+ * caller must stop in both cases.
100
+ */
101
+ gate(req, res) {
102
+ if (!this.isSet()) {
103
+ // No passcode configured → tunnel must not be reachable. Refuse.
104
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
105
+ res.end("access passcode not configured");
106
+ return false;
107
+ }
108
+ const cookieToken = readCookie(req.headers.cookie, COOKIE_NAME);
109
+ if (cookieToken && this.verifyToken(cookieToken))
110
+ return true;
111
+ const supplied = readPasscodeParam(req);
112
+ let wrongAttempt = false;
113
+ if (supplied) {
114
+ const token = this.verify(supplied);
115
+ if (token) {
116
+ res.setHeader("Set-Cookie",
117
+ // SameSite=Lax (not Strict): the phone arrives via a QR-scan-launched
118
+ // top-level navigation into trycloudflare.com, which Strict treats as
119
+ // cross-site and refuses to send the cookie on — re-challenging the
120
+ // user every visit. Lax sends it on top-level navigations. Secure
121
+ // because the tunnel terminates TLS (always https).
122
+ `${COOKIE_NAME}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${Math.floor(this.tokenMaxAgeMs / 1000)}`);
123
+ const cleanLocation = acceptsHtml(req) ? locationWithoutPasscode(req.url) : undefined;
124
+ if (cleanLocation) {
125
+ // The HTML challenge has a no-query JavaScript path below, but keep a
126
+ // server-side redirect as a no-JS fallback. Never render the app while
127
+ // the plaintext passcode is still visible in the navigation URL.
128
+ res.writeHead(303, {
129
+ Location: cleanLocation,
130
+ "Cache-Control": "no-store",
131
+ });
132
+ res.end();
133
+ return false;
134
+ }
135
+ return true;
136
+ }
137
+ wrongAttempt = true;
138
+ }
139
+ // A browser navigating to a page needs a form to type the passcode into —
140
+ // a bare text/plain 401 was a dead end (the user saw the error with no way
141
+ // to enter the passcode). Non-navigation requests (WS upgrade, fetch) keep
142
+ // the lightweight text 401.
143
+ if (acceptsHtml(req)) {
144
+ res.writeHead(401, { "content-type": "text/html; charset=utf-8" });
145
+ res.end(challengePage(req.url, wrongAttempt, this.isLocked()));
146
+ return false;
147
+ }
148
+ res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
149
+ res.end("访问口令无效或缺失");
150
+ return false;
151
+ }
152
+ /** True if a WS upgrade carrying these headers/url is allowed (no body to
153
+ * write). Used by the WS gate which cannot send a challenge page. */
154
+ allows(req) {
155
+ if (!this.isSet())
156
+ return false;
157
+ const cookieToken = readCookie(req.headers.cookie, COOKIE_NAME);
158
+ if (cookieToken && this.verifyToken(cookieToken))
159
+ return true;
160
+ const supplied = readPasscodeParam(req);
161
+ if (supplied)
162
+ return this.verify(supplied) !== null;
163
+ return false;
164
+ }
165
+ isLocked() {
166
+ if (this.lockedUntil === 0)
167
+ return false;
168
+ if (this.now() >= this.lockedUntil) {
169
+ this.lockedUntil = 0;
170
+ this.failures = 0;
171
+ return false;
172
+ }
173
+ return true;
174
+ }
175
+ issueToken(record) {
176
+ const payload = `${randomBytes(12).toString("hex")}.${this.now()}`;
177
+ const sig = this.sign(payload, record.secret);
178
+ return `${payload}.${sig}`;
179
+ }
180
+ sign(payload, secret) {
181
+ return createHmac("sha256", secret).update(payload).digest("hex");
182
+ }
183
+ hashPasscode(passcode, salt) {
184
+ return scryptSync(passcode, salt, SCRYPT_KEYLEN).toString("hex");
185
+ }
186
+ read() {
187
+ if (!existsSync(this.filePath))
188
+ return undefined;
189
+ try {
190
+ const parsed = JSON.parse(readFileSync(this.filePath, "utf-8"));
191
+ if (parsed.hash && parsed.salt && parsed.secret) {
192
+ return { hash: parsed.hash, salt: parsed.salt, secret: parsed.secret };
193
+ }
194
+ return undefined;
195
+ }
196
+ catch {
197
+ return undefined;
198
+ }
199
+ }
200
+ write(record) {
201
+ mkdirSync(dirname(this.filePath), { recursive: true });
202
+ writeFileSync(this.filePath, JSON.stringify(record, null, 2), "utf-8");
203
+ }
204
+ }
205
+ function readCookie(cookieHeader, name) {
206
+ if (!cookieHeader)
207
+ return undefined;
208
+ const header = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
209
+ for (const part of header.split(/;\s*/)) {
210
+ const eq = part.indexOf("=");
211
+ if (eq <= 0)
212
+ continue;
213
+ if (part.slice(0, eq) === name) {
214
+ const raw = part.slice(eq + 1);
215
+ // The Cookie header is attacker-controlled on the mobile-remote boundary.
216
+ // decodeURIComponent THROWS URIError on a malformed %-sequence (e.g.
217
+ // `cs_access=%ZZ`); unguarded it escapes gate() and crashes the request
218
+ // handler (DoS). Fall back to the raw value like browsers do for bad cookies.
219
+ try {
220
+ return decodeURIComponent(raw);
221
+ }
222
+ catch {
223
+ return raw;
224
+ }
225
+ }
226
+ }
227
+ return undefined;
228
+ }
229
+ function readPasscodeParam(req) {
230
+ // Header takes precedence (used by the WS/fetch path), then ?passcode= query.
231
+ // Node represents duplicate headers as string[]; handle that like readCookie
232
+ // does (take the first value) instead of silently falling through to query.
233
+ const rawHeader = req.headers["x-access-passcode"];
234
+ const headerVal = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
235
+ if (typeof headerVal === "string" && headerVal)
236
+ return headerVal;
237
+ if (!req.url)
238
+ return undefined;
239
+ const qIdx = req.url.indexOf("?");
240
+ if (qIdx < 0)
241
+ return undefined;
242
+ const params = new URLSearchParams(req.url.slice(qIdx + 1));
243
+ return params.get("passcode") ?? undefined;
244
+ }
245
+ function acceptsHtml(req) {
246
+ const accept = req.headers.accept;
247
+ const value = Array.isArray(accept) ? accept.join(",") : accept;
248
+ return typeof value === "string" && value.includes("text/html");
249
+ }
250
+ function locationWithoutPasscode(url) {
251
+ if (!url)
252
+ return undefined;
253
+ const qIdx = url.indexOf("?");
254
+ if (qIdx < 0)
255
+ return undefined;
256
+ const path = url.slice(0, qIdx) || "/";
257
+ const params = new URLSearchParams(url.slice(qIdx + 1));
258
+ if (!params.has("passcode"))
259
+ return undefined;
260
+ params.delete("passcode");
261
+ const query = params.toString();
262
+ return query ? `${path}?${query}` : path;
263
+ }
264
+ function escapeHtml(s) {
265
+ return s
266
+ .replace(/&/g, "&amp;")
267
+ .replace(/</g, "&lt;")
268
+ .replace(/>/g, "&gt;")
269
+ .replace(/"/g, "&quot;");
270
+ }
271
+ /**
272
+ * The passcode challenge page. Submits via GET back to the SAME path so any
273
+ * existing query (notably `?pairing=<token>`) is preserved — the form re-adds
274
+ * `passcode`, and on success gate() sets the remember cookie and the next load
275
+ * serves the real /mobile page with the pairing token intact.
276
+ */
277
+ function challengePage(url, wrong, locked) {
278
+ // Carry forward existing params except a stale passcode.
279
+ const qIdx = (url ?? "").indexOf("?");
280
+ const path = qIdx >= 0 ? url.slice(0, qIdx) : (url ?? "/mobile");
281
+ const params = new URLSearchParams(qIdx >= 0 ? url.slice(qIdx + 1) : "");
282
+ params.delete("passcode");
283
+ const hidden = [...params.entries()]
284
+ .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`)
285
+ .join("");
286
+ const note = locked
287
+ ? "尝试过多,已临时锁定,请稍后再试。"
288
+ : wrong
289
+ ? "口令不正确,请重试。"
290
+ : "请输入访问口令以继续。";
291
+ return `<!doctype html>
292
+ <html lang="zh"><head><meta charset="utf-8">
293
+ <meta name="viewport" content="width=device-width, initial-scale=1">
294
+ <title>访问口令</title>
295
+ <style>
296
+ :root{color-scheme:light dark}
297
+ body{font-family:-apple-system,system-ui,sans-serif;margin:0;min-height:100vh;
298
+ display:flex;align-items:center;justify-content:center;background:#0b0b0c;color:#e7e7e9}
299
+ .card{width:min(92vw,360px);padding:28px 24px;border-radius:16px;background:#19191c;
300
+ box-shadow:0 8px 40px rgba(0,0,0,.4)}
301
+ h1{font-size:18px;margin:0 0 6px}
302
+ p{font-size:13px;color:#a1a1aa;margin:0 0 18px}
303
+ p.err{color:#f87171}
304
+ input[type=password]{width:100%;box-sizing:border-box;padding:12px 14px;font-size:16px;
305
+ border-radius:10px;border:1px solid #333;background:#0f0f11;color:#fff;margin-bottom:14px}
306
+ button{width:100%;padding:12px;font-size:15px;border:0;border-radius:10px;
307
+ background:#3b82f6;color:#fff;font-weight:600}
308
+ button:disabled{opacity:.5}
309
+ </style></head>
310
+ <body><form class="card" method="GET" action="${escapeHtml(path)}">
311
+ <h1>手机遥控</h1>
312
+ <p class="${wrong || locked ? "err" : ""}">${note}</p>
313
+ ${hidden}
314
+ <input id="passcode" type="password" name="passcode" placeholder="访问口令" autofocus required ${locked ? "disabled" : ""}>
315
+ <button type="submit" ${locked ? "disabled" : ""}>进入</button>
316
+ </form>
317
+ <script>
318
+ const form = document.querySelector("form");
319
+ const input = document.querySelector("#passcode");
320
+ const note = document.querySelector("p");
321
+ form?.addEventListener("submit", async (event) => {
322
+ event.preventDefault();
323
+ const value = input?.value ?? "";
324
+ if (!value) return;
325
+ try {
326
+ const response = await fetch(window.location.href, {
327
+ headers: { "X-Access-Passcode": value, Accept: "application/json" },
328
+ credentials: "same-origin",
329
+ });
330
+ if (!response.ok) {
331
+ if (note) note.textContent = "口令不正确或尝试过多,请稍后重试。";
332
+ if (note) note.className = "err";
333
+ if (input) input.value = "";
334
+ return;
335
+ }
336
+ window.location.replace(${JSON.stringify(path + (params.size ? `?${params.toString()}` : ""))});
337
+ } catch {
338
+ if (note) note.textContent = "连接失败,请重试。";
339
+ if (note) note.className = "err";
340
+ }
341
+ });
342
+ </script></body></html>`;
343
+ }
344
+ /** Constant-time hex comparison that tolerates length mismatch. */
345
+ function safeEqualHex(a, b) {
346
+ if (a.length !== b.length)
347
+ return false;
348
+ try {
349
+ return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
350
+ }
351
+ catch {
352
+ return false;
353
+ }
354
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Pinned cloudflared release. We do NOT track `latest`: the downloaded asset is
3
+ * chmod +x'd and executed with the desktop user's privileges, so the bytes must
4
+ * be verifiable against a known SHA-256. `latest` would move under us and make
5
+ * any embedded digest wrong on the next release (breaking installs) or, worse,
6
+ * silently accept whatever `latest` now points at. Bump this tag AND the digests
7
+ * below together (see scripts note in the PR / CLAUDE memory).
8
+ */
9
+ export declare const CLOUDFLARED_VERSION = "2026.6.1";
10
+ /**
11
+ * SHA-256 of each release asset we download, for {@link CLOUDFLARED_VERSION}.
12
+ * Keyed by the asset FILENAME. For macOS this is the `.tgz` archive (verified
13
+ * before extraction); for Linux/Windows it's the raw binary. Regenerate on every
14
+ * version bump:
15
+ * for a in cloudflared-darwin-amd64.tgz cloudflared-darwin-arm64.tgz \
16
+ * cloudflared-linux-amd64 cloudflared-linux-arm64 \
17
+ * cloudflared-windows-amd64.exe; do
18
+ * curl -sSL "https://github.com/cloudflare/cloudflared/releases/download/$V/$a" | shasum -a 256
19
+ * done
20
+ */
21
+ export declare const CLOUDFLARED_SHA256: Record<string, string>;
22
+ /** The filename portion of an asset URL — the key into {@link CLOUDFLARED_SHA256}. */
23
+ export declare function cloudflaredAssetName(arch: string, platform?: NodeJS.Platform): string;
24
+ /**
25
+ * Verify `path`'s SHA-256 against the pinned digest for `assetName`. Throws (with
26
+ * the file left for the caller to clean up) on an unknown asset or a mismatch —
27
+ * never chmod/execute unverified bytes.
28
+ */
29
+ export declare function verifyAssetDigest(path: string, assetName: string): void;
30
+ /**
31
+ * Map a Node `process.platform` + `process.arch` to the official cloudflared
32
+ * release asset URL.
33
+ *
34
+ * - macOS: `cloudflared-darwin-<arch>.tgz` (a tarball with one binary).
35
+ * - Windows: `cloudflared-windows-<arch>.exe` (a RAW .exe, no extraction).
36
+ * - Linux: `cloudflared-linux-<arch>` (a raw binary).
37
+ *
38
+ * Pure + injectable so tests pin the URL without touching the network. Unknown
39
+ * arch falls back to amd64.
40
+ */
41
+ export declare function cloudflaredDownloadUrl(arch: string, platform?: NodeJS.Platform): string;
42
+ /** Injectable download function. Writes the fetched bytes to `dest`. */
43
+ export type DownloadFn = (url: string, dest: string, onProgress?: (pct: number) => void) => Promise<void>;
44
+ export interface CloudflaredBinaryOptions {
45
+ /** Base dir, typically `<userData>/mobile-remote`. */
46
+ baseDir: string;
47
+ /** Override `process.arch` for tests. */
48
+ arch?: string;
49
+ /** Override the network download for tests. */
50
+ download?: DownloadFn;
51
+ }
52
+ /**
53
+ * Manages the cloudflared binary on disk: existence/executable check and a
54
+ * download-once-then-atomic-rename install. No process spawning, no network
55
+ * service — that lives in tunnel-manager. Network + arch are injected so the
56
+ * unit is fully deterministic in tests.
57
+ */
58
+ export declare class CloudflaredBinary {
59
+ private readonly baseDir;
60
+ private readonly arch;
61
+ private readonly download;
62
+ constructor(opts: CloudflaredBinaryOptions);
63
+ /** `<baseDir>/bin/cloudflared` (`.exe` on Windows). */
64
+ binaryPath(): string;
65
+ /** True when the binary exists (and, on POSIX, has the owner-exec bit set).
66
+ * Windows has no exec bit, so existence is the only meaningful check. */
67
+ isInstalled(): boolean;
68
+ /**
69
+ * Ensure the binary is present and executable, returning its path. If already
70
+ * installed, skips the download. Otherwise downloads to a `.download` temp
71
+ * name, chmods it executable, and atomically renames to the final path — so a
72
+ * failed/partial download never leaves a half-written binary in place. On any
73
+ * failure the temp file is removed and the error rethrown.
74
+ */
75
+ ensureBinary(onProgress?: (pct: number) => void): Promise<string>;
76
+ }
@@ -0,0 +1,242 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, } from "node:fs";
4
+ import { get as httpsGet } from "node:https";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ /**
8
+ * Pinned cloudflared release. We do NOT track `latest`: the downloaded asset is
9
+ * chmod +x'd and executed with the desktop user's privileges, so the bytes must
10
+ * be verifiable against a known SHA-256. `latest` would move under us and make
11
+ * any embedded digest wrong on the next release (breaking installs) or, worse,
12
+ * silently accept whatever `latest` now points at. Bump this tag AND the digests
13
+ * below together (see scripts note in the PR / CLAUDE memory).
14
+ */
15
+ export const CLOUDFLARED_VERSION = "2026.6.1";
16
+ /**
17
+ * SHA-256 of each release asset we download, for {@link CLOUDFLARED_VERSION}.
18
+ * Keyed by the asset FILENAME. For macOS this is the `.tgz` archive (verified
19
+ * before extraction); for Linux/Windows it's the raw binary. Regenerate on every
20
+ * version bump:
21
+ * for a in cloudflared-darwin-amd64.tgz cloudflared-darwin-arm64.tgz \
22
+ * cloudflared-linux-amd64 cloudflared-linux-arm64 \
23
+ * cloudflared-windows-amd64.exe; do
24
+ * curl -sSL "https://github.com/cloudflare/cloudflared/releases/download/$V/$a" | shasum -a 256
25
+ * done
26
+ */
27
+ export const CLOUDFLARED_SHA256 = {
28
+ "cloudflared-darwin-amd64.tgz": "d7a66b525fe76820da6e5406611b61e48b40de682368ac00454d9158f085be4b",
29
+ "cloudflared-darwin-arm64.tgz": "f6d4c439c6c782b83264951d327989ce5e23373acc5942b872411601fedb020d",
30
+ "cloudflared-linux-amd64": "5861a10a438fe8ddcfebb3b830f83966cbf193edafce0fe2eeb198fbae1f7a22",
31
+ "cloudflared-linux-arm64": "59816ce9b16db71f5bc2a86d59b3632a96c8c3ee934bde2bc8641ee83a6070eb",
32
+ "cloudflared-windows-amd64.exe": "5253e66f1f493c4e13539749f1aa86fd0c61e3072900fec29a44ba046a6d97e2",
33
+ };
34
+ /** The filename portion of an asset URL — the key into {@link CLOUDFLARED_SHA256}. */
35
+ export function cloudflaredAssetName(arch, platform = process.platform) {
36
+ const a = arch === "arm64" ? "arm64" : "amd64";
37
+ if (platform === "win32")
38
+ return "cloudflared-windows-amd64.exe";
39
+ if (platform === "linux")
40
+ return `cloudflared-linux-${a}`;
41
+ return `cloudflared-darwin-${a}.tgz`;
42
+ }
43
+ /** SHA-256 of a file on disk, lowercase hex. */
44
+ function sha256File(path) {
45
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
46
+ }
47
+ /**
48
+ * Verify `path`'s SHA-256 against the pinned digest for `assetName`. Throws (with
49
+ * the file left for the caller to clean up) on an unknown asset or a mismatch —
50
+ * never chmod/execute unverified bytes.
51
+ */
52
+ export function verifyAssetDigest(path, assetName) {
53
+ const expected = CLOUDFLARED_SHA256[assetName];
54
+ if (!expected) {
55
+ throw new Error(`cloudflared 资产无内置校验值: ${assetName}`);
56
+ }
57
+ const actual = sha256File(path);
58
+ if (actual !== expected) {
59
+ throw new Error(`cloudflared 校验失败(${assetName}): 期望 ${expected}, 实际 ${actual}`);
60
+ }
61
+ }
62
+ /**
63
+ * Map a Node `process.platform` + `process.arch` to the official cloudflared
64
+ * release asset URL.
65
+ *
66
+ * - macOS: `cloudflared-darwin-<arch>.tgz` (a tarball with one binary).
67
+ * - Windows: `cloudflared-windows-<arch>.exe` (a RAW .exe, no extraction).
68
+ * - Linux: `cloudflared-linux-<arch>` (a raw binary).
69
+ *
70
+ * Pure + injectable so tests pin the URL without touching the network. Unknown
71
+ * arch falls back to amd64.
72
+ */
73
+ export function cloudflaredDownloadUrl(arch, platform = process.platform) {
74
+ // Pinned version (NOT /latest/) so the asset matches an embedded SHA-256.
75
+ const base = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`;
76
+ return `${base}/${cloudflaredAssetName(arch, platform)}`;
77
+ }
78
+ /** True when the release asset is a tarball needing extraction (macOS only). */
79
+ function assetIsTarball(platform = process.platform) {
80
+ return platform === "darwin";
81
+ }
82
+ /**
83
+ * Manages the cloudflared binary on disk: existence/executable check and a
84
+ * download-once-then-atomic-rename install. No process spawning, no network
85
+ * service — that lives in tunnel-manager. Network + arch are injected so the
86
+ * unit is fully deterministic in tests.
87
+ */
88
+ export class CloudflaredBinary {
89
+ baseDir;
90
+ arch;
91
+ download;
92
+ constructor(opts) {
93
+ this.baseDir = opts.baseDir;
94
+ this.arch = opts.arch ?? process.arch;
95
+ this.download = opts.download ?? defaultDownload;
96
+ }
97
+ /** `<baseDir>/bin/cloudflared` (`.exe` on Windows). */
98
+ binaryPath() {
99
+ const name = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
100
+ return join(this.baseDir, "bin", name);
101
+ }
102
+ /** True when the binary exists (and, on POSIX, has the owner-exec bit set).
103
+ * Windows has no exec bit, so existence is the only meaningful check. */
104
+ isInstalled() {
105
+ const p = this.binaryPath();
106
+ if (!existsSync(p))
107
+ return false;
108
+ if (process.platform === "win32")
109
+ return true;
110
+ try {
111
+ return (statSync(p).mode & 0o100) === 0o100;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ /**
118
+ * Ensure the binary is present and executable, returning its path. If already
119
+ * installed, skips the download. Otherwise downloads to a `.download` temp
120
+ * name, chmods it executable, and atomically renames to the final path — so a
121
+ * failed/partial download never leaves a half-written binary in place. On any
122
+ * failure the temp file is removed and the error rethrown.
123
+ */
124
+ async ensureBinary(onProgress) {
125
+ const finalPath = this.binaryPath();
126
+ if (this.isInstalled())
127
+ return finalPath;
128
+ const tmpPath = `${finalPath}.download`;
129
+ mkdirSync(join(this.baseDir, "bin"), { recursive: true });
130
+ try {
131
+ const url = cloudflaredDownloadUrl(this.arch);
132
+ await this.download(url, tmpPath, onProgress);
133
+ if (!existsSync(tmpPath) || statSync(tmpPath).size === 0) {
134
+ throw new Error("下载的 cloudflared 为空");
135
+ }
136
+ chmodSync(tmpPath, 0o755);
137
+ renameSync(tmpPath, finalPath);
138
+ return finalPath;
139
+ }
140
+ catch (err) {
141
+ rmSync(tmpPath, { force: true });
142
+ throw err;
143
+ }
144
+ }
145
+ }
146
+ /**
147
+ * Default download: the official macOS asset is a `.tgz` tarball containing a
148
+ * single `cloudflared` binary, so we fetch the tarball to a scratch dir, extract
149
+ * it with the system `tar`, then place the extracted binary at `dest`. The
150
+ * caller (ensureBinary) chmods + atomically renames `dest` into place, so a
151
+ * failed extract never yields a runnable-but-broken binary. Network + extract
152
+ * scratch is fully cleaned up.
153
+ */
154
+ const defaultDownload = async (url, dest, onProgress) => {
155
+ // The asset filename is the key into the embedded SHA-256 table.
156
+ const assetName = cloudflaredAssetName(process.arch);
157
+ // Windows/Linux assets are RAW binaries — stream straight to dest, no tar.
158
+ if (!assetIsTarball()) {
159
+ await httpsDownloadTo(url, dest, onProgress);
160
+ if (!existsSync(dest) || statSync(dest).size === 0) {
161
+ throw new Error("下载的 cloudflared 为空");
162
+ }
163
+ // Verify BEFORE the caller chmods +x and renames into place. A mismatch
164
+ // throws; ensureBinary removes the temp file so nothing runnable survives.
165
+ verifyAssetDigest(dest, assetName);
166
+ return;
167
+ }
168
+ // macOS asset is a .tgz with a single `cloudflared` binary → extract.
169
+ const scratch = mkdtempSync(join(tmpdir(), "cloudflared-dl-"));
170
+ const tgzPath = join(scratch, "cloudflared.tgz");
171
+ try {
172
+ await httpsDownloadTo(url, tgzPath, onProgress);
173
+ if (!existsSync(tgzPath) || statSync(tgzPath).size === 0) {
174
+ throw new Error("下载的 cloudflared 归档为空");
175
+ }
176
+ // Verify the ARCHIVE against its embedded digest before extracting — never
177
+ // hand attacker-controlled bytes to `tar`.
178
+ verifyAssetDigest(tgzPath, assetName);
179
+ await runTarExtract(tgzPath, scratch);
180
+ const extracted = join(scratch, "cloudflared");
181
+ if (!existsSync(extracted)) {
182
+ throw new Error("归档中未找到 cloudflared 二进制");
183
+ }
184
+ copyFileSync(extracted, dest);
185
+ }
186
+ finally {
187
+ rmSync(scratch, { recursive: true, force: true });
188
+ }
189
+ };
190
+ /** Stream an https GET (following GitHub→CDN redirects) to a file with coarse
191
+ * progress. */
192
+ function httpsDownloadTo(url, dest, onProgress) {
193
+ return new Promise((resolve, reject) => {
194
+ const request = (target, redirects) => {
195
+ if (redirects > 5) {
196
+ reject(new Error("下载重定向过多"));
197
+ return;
198
+ }
199
+ const req = httpsGet(target, (res) => {
200
+ const status = res.statusCode ?? 0;
201
+ if (status >= 300 && status < 400 && res.headers.location) {
202
+ res.resume();
203
+ request(res.headers.location, redirects + 1);
204
+ return;
205
+ }
206
+ if (status !== 200) {
207
+ res.resume();
208
+ reject(new Error(`下载失败,HTTP ${status}`));
209
+ return;
210
+ }
211
+ const total = Number(res.headers["content-length"] ?? 0);
212
+ let received = 0;
213
+ const out = createWriteStream(dest);
214
+ res.on("data", (chunk) => {
215
+ received += chunk.length;
216
+ if (total > 0 && onProgress) {
217
+ onProgress(Math.min(100, Math.round((received / total) * 100)));
218
+ }
219
+ });
220
+ res.pipe(out);
221
+ out.on("finish", () => out.close(() => resolve()));
222
+ out.on("error", reject);
223
+ res.on("error", reject);
224
+ });
225
+ req.on("error", reject);
226
+ };
227
+ request(url, 0);
228
+ });
229
+ }
230
+ /** Extract a `.tgz` into `cwd` using the system tar (always present on macOS). */
231
+ function runTarExtract(tgzPath, cwd) {
232
+ return new Promise((resolve, reject) => {
233
+ const child = spawn("tar", ["-xzf", tgzPath, "-C", cwd]);
234
+ child.on("error", reject);
235
+ child.on("exit", (code) => {
236
+ if (code === 0)
237
+ resolve();
238
+ else
239
+ reject(new Error(`tar 解压失败(code=${code ?? "?"})`));
240
+ });
241
+ });
242
+ }