@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,19 @@
1
+ import type { TrustedDevicePublic } from "./types.js";
2
+ export declare class TrustedDeviceStore {
3
+ private readonly filePath;
4
+ constructor(filePath: string);
5
+ addDevice(input: {
6
+ name: string;
7
+ secretHash: string;
8
+ }): TrustedDevicePublic;
9
+ listDevices(): TrustedDevicePublic[];
10
+ authenticate(id: string, secretHash: string): TrustedDevicePublic | undefined;
11
+ revoke(id: string): boolean;
12
+ /** Hard-delete a device row entirely (no revoked zombie left behind). */
13
+ remove(id: string): boolean;
14
+ /** Rename a device's display label. Rejects blank names and unknown ids. */
15
+ rename(id: string, name: string): boolean;
16
+ private readAll;
17
+ private writeAll;
18
+ private toPublic;
19
+ }
@@ -0,0 +1,118 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { randomUUID, timingSafeEqual } from "node:crypto";
4
+ /**
5
+ * Constant-time compare for a device's bearer credential (secretHash), so
6
+ * authentication doesn't leak hash bytes through compare-timing (Y-3).
7
+ * `timingSafeEqual` requires equal-length buffers and throws otherwise — a
8
+ * length mismatch is itself a non-match, so we short-circuit to false. The
9
+ * length check isn't constant-time, but it only reveals the (fixed, public)
10
+ * length of a correct hash, not its content.
11
+ */
12
+ function secretHashEquals(a, b) {
13
+ const bufA = Buffer.from(a, "utf-8");
14
+ const bufB = Buffer.from(b, "utf-8");
15
+ if (bufA.length !== bufB.length)
16
+ return false;
17
+ return timingSafeEqual(bufA, bufB);
18
+ }
19
+ export class TrustedDeviceStore {
20
+ filePath;
21
+ constructor(filePath) {
22
+ this.filePath = filePath;
23
+ }
24
+ addDevice(input) {
25
+ const devices = this.readAll();
26
+ // Get-or-create by secretHash. A phone's secret is stable per browser
27
+ // (persisted in localStorage as cs.deviceSecret), so re-scanning the QR
28
+ // code — which the client always prefers over re-auth when the URL carries
29
+ // a fresh pairing token — used to mint a BRAND-NEW device every time, piling
30
+ // up trusted devices unboundedly. Reuse the existing row instead, refreshing
31
+ // its name + lastSeenAt. (mobile-remote device accumulation)
32
+ const existing = devices.find((d) => d.secretHash === input.secretHash && !d.revokedAt);
33
+ if (existing) {
34
+ existing.name = input.name;
35
+ existing.lastSeenAt = Date.now();
36
+ this.writeAll(devices);
37
+ return this.toPublic(existing);
38
+ }
39
+ const device = {
40
+ id: randomUUID(),
41
+ name: input.name,
42
+ secretHash: input.secretHash,
43
+ createdAt: Date.now(),
44
+ };
45
+ devices.push(device);
46
+ this.writeAll(devices);
47
+ return this.toPublic(device);
48
+ }
49
+ listDevices() {
50
+ return this.readAll().map((device) => this.toPublic(device));
51
+ }
52
+ authenticate(id, secretHash) {
53
+ const devices = this.readAll();
54
+ const device = devices.find((item) => item.id === id && !item.revokedAt && secretHashEquals(item.secretHash, secretHash));
55
+ if (!device)
56
+ return undefined;
57
+ device.lastSeenAt = Date.now();
58
+ this.writeAll(devices);
59
+ return this.toPublic(device);
60
+ }
61
+ revoke(id) {
62
+ const devices = this.readAll();
63
+ const device = devices.find((item) => item.id === id && !item.revokedAt);
64
+ if (!device)
65
+ return false;
66
+ device.revokedAt = Date.now();
67
+ this.writeAll(devices);
68
+ return true;
69
+ }
70
+ /** Hard-delete a device row entirely (no revoked zombie left behind). */
71
+ remove(id) {
72
+ const devices = this.readAll();
73
+ const next = devices.filter((item) => item.id !== id);
74
+ if (next.length === devices.length)
75
+ return false;
76
+ this.writeAll(next);
77
+ return true;
78
+ }
79
+ /** Rename a device's display label. Rejects blank names and unknown ids. */
80
+ rename(id, name) {
81
+ const trimmed = name.trim();
82
+ if (!trimmed)
83
+ return false;
84
+ const devices = this.readAll();
85
+ const device = devices.find((item) => item.id === id);
86
+ if (!device)
87
+ return false;
88
+ device.name = trimmed;
89
+ this.writeAll(devices);
90
+ return true;
91
+ }
92
+ readAll() {
93
+ if (!existsSync(this.filePath))
94
+ return [];
95
+ return JSON.parse(readFileSync(this.filePath, "utf-8"));
96
+ }
97
+ writeAll(devices) {
98
+ mkdirSync(dirname(this.filePath), { recursive: true });
99
+ // 0o600: devices.json holds each device's secretHash (its bearer credential).
100
+ // Owner-only, same hardening as settings.json/credentials.json/cookie leases.
101
+ // (Y-3 partial: this closes the world-readable file; the non-timing-safe `===`
102
+ // compare stays in the §2.4 mobile-remote-hardening bucket for the user.)
103
+ writeFileSync(this.filePath, JSON.stringify(devices, null, 2), { encoding: "utf-8", mode: 0o600 });
104
+ try {
105
+ chmodSync(this.filePath, 0o600);
106
+ }
107
+ catch { /* best-effort: tighten an existing file */ }
108
+ }
109
+ toPublic(device) {
110
+ return {
111
+ id: device.id,
112
+ name: device.name,
113
+ createdAt: device.createdAt,
114
+ lastSeenAt: device.lastSeenAt,
115
+ revokedAt: device.revokedAt,
116
+ };
117
+ }
118
+ }
@@ -0,0 +1,105 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ export type TunnelStatus = "connected" | "disconnected" | "error";
4
+ export type SpawnFn = (command: string, args: string[]) => ChildProcess;
5
+ /** Probe cloudflared's local metrics `/ready` endpoint; true once the edge
6
+ * connection count is > 0. Default implementation fetches over loopback. */
7
+ export type CheckReadyFn = (metricsPort: number) => Promise<boolean>;
8
+ export interface TunnelManagerOptions {
9
+ /** Path to the cloudflared binary (from CloudflaredBinary.binaryPath()). */
10
+ binaryPath: () => string;
11
+ /** Injectable spawn for tests. */
12
+ spawn?: SpawnFn;
13
+ /** How long to wait for the tunnel URL before failing. Default 15s. */
14
+ timeoutMs?: number;
15
+ /** Local metrics port cloudflared exposes (we poll its /ready). */
16
+ metricsPort?: number;
17
+ /** Injectable readiness probe for tests. */
18
+ checkReady?: CheckReadyFn;
19
+ /** How long to wait for the edge connection to become ready. Default 20s. */
20
+ readyTimeoutMs?: number;
21
+ /** Poll interval while waiting for readiness. Default 500ms. */
22
+ readyIntervalMs?: number;
23
+ /** Poll interval after connect; set <= 0 to disable runtime health checks. */
24
+ healthCheckIntervalMs?: number;
25
+ /** Consecutive failed readiness probes before reporting a disconnect. */
26
+ healthFailureThreshold?: number;
27
+ /** Grace after SIGTERM before escalating to SIGKILL when tearing a child down. */
28
+ killGraceMs?: number;
29
+ /** Absolute cap on waiting for a killed child to exit (SIGKILL already sent). */
30
+ killHardTimeoutMs?: number;
31
+ }
32
+ /**
33
+ * Owns the cloudflared *process* only. `start` spawns a temporary tunnel and
34
+ * resolves once the `https://*.trycloudflare.com` URL appears on stdout/stderr;
35
+ * if no URL arrives within the timeout it kills the child and rejects. After a
36
+ * successful start, an unsolicited child exit (crash) emits a `disconnected`
37
+ * status — we deliberately do NOT auto-restart (spec decision: a silent
38
+ * address change without a refreshed QR is worse than a visible disconnect).
39
+ * `stop()` kills the child and suppresses the disconnected event.
40
+ */
41
+ export declare class TunnelManager extends EventEmitter {
42
+ private readonly getBinaryPath;
43
+ private readonly spawn;
44
+ private readonly timeoutMs;
45
+ private readonly metricsPort;
46
+ private readonly checkReady;
47
+ private readonly readyTimeoutMs;
48
+ private readonly readyIntervalMs;
49
+ private readonly healthCheckIntervalMs;
50
+ private readonly healthFailureThreshold;
51
+ private readonly killGraceMs;
52
+ private readonly killHardTimeoutMs;
53
+ private child?;
54
+ /** In-flight teardown of a previous child. Any start() awaits this first so a
55
+ * new cloudflared never spawns while the old one still owns the metrics port. */
56
+ private pendingTeardown?;
57
+ private stopping;
58
+ private connected;
59
+ private currentUrl?;
60
+ private healthTimer?;
61
+ private healthCheckInFlight;
62
+ private healthGeneration;
63
+ private consecutiveHealthFailures;
64
+ constructor(opts: TunnelManagerOptions);
65
+ start(port: number): Promise<{
66
+ url: string;
67
+ }>;
68
+ /**
69
+ * Poll cloudflared's /ready until the edge connection is up, or reject after
70
+ * `readyTimeoutMs`. This is what distinguishes a working tunnel from one that
71
+ * registered a connection but can't actually serve traffic (error 1033).
72
+ */
73
+ private waitForReady;
74
+ private startHealthMonitor;
75
+ private stopHealthMonitor;
76
+ private pollHealth;
77
+ /**
78
+ * Tear the current child down and remember the wait as `pendingTeardown` so a
79
+ * subsequent start() blocks until the process has really exited (freeing the
80
+ * metrics port). Shared by stop(), startup-failure, and restart so there is a
81
+ * single teardown path — the missing shared wait was the stop()→start() race.
82
+ * Idempotent: with no live child it resolves immediately.
83
+ */
84
+ private beginTeardown;
85
+ /**
86
+ * SIGTERM the child, and if it hasn't exited within `killGraceMs`, escalate to
87
+ * SIGKILL — cloudflared's SIGTERM is a graceful drain (default ~30s) that
88
+ * would otherwise keep the metrics port bound long past a re-open. Normally
89
+ * resolves on the process's real `exit`; `killHardTimeoutMs` is a last-resort
90
+ * cap so a child that never reports exit can't hang teardown forever. That cap
91
+ * is reached only AFTER SIGKILL was already sent, so on macOS/Linux the port is
92
+ * in practice released by then — it is a safety valve, not the happy path.
93
+ */
94
+ private killAndWait;
95
+ /**
96
+ * Kill the cloudflared child and suppress the disconnect event. Returns a
97
+ * promise that resolves once the process has actually exited so callers that
98
+ * immediately restart don't race the port. Fire-and-forget callers may ignore
99
+ * it (kept sync-compatible: existing `tunnelManager.stop()` sites work).
100
+ */
101
+ stop(): Promise<void>;
102
+ isRunning(): boolean;
103
+ isConnected(): boolean;
104
+ publicUrl(): string | undefined;
105
+ }
@@ -0,0 +1,375 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ const TRYCLOUDFLARE_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
4
+ const DEFAULT_TIMEOUT_MS = 15_000;
5
+ const DEFAULT_READY_TIMEOUT_MS = 20_000;
6
+ const DEFAULT_READY_INTERVAL_MS = 500;
7
+ const DEFAULT_METRICS_PORT = 20741;
8
+ const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 5_000;
9
+ const DEFAULT_HEALTH_FAILURE_THRESHOLD = 3;
10
+ // cloudflared handles SIGTERM but does NOT exit promptly (graceful drain,
11
+ // default ~30s per cloudflared docs). Give it a short grace, then SIGKILL so
12
+ // the fixed metrics port is released before we re-spawn — otherwise the new
13
+ // process can't bind 127.0.0.1:20741 and exits code=1.
14
+ const DEFAULT_KILL_GRACE_MS = 2_000;
15
+ const DEFAULT_KILL_HARD_TIMEOUT_MS = 3_000;
16
+ async function defaultCheckReady(metricsPort) {
17
+ try {
18
+ const res = await fetch(`http://127.0.0.1:${metricsPort}/ready`, {
19
+ signal: AbortSignal.timeout(2_000),
20
+ });
21
+ if (!res.ok)
22
+ return false;
23
+ const body = (await res.json());
24
+ return (body.readyConnections ?? 0) > 0;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /**
31
+ * Owns the cloudflared *process* only. `start` spawns a temporary tunnel and
32
+ * resolves once the `https://*.trycloudflare.com` URL appears on stdout/stderr;
33
+ * if no URL arrives within the timeout it kills the child and rejects. After a
34
+ * successful start, an unsolicited child exit (crash) emits a `disconnected`
35
+ * status — we deliberately do NOT auto-restart (spec decision: a silent
36
+ * address change without a refreshed QR is worse than a visible disconnect).
37
+ * `stop()` kills the child and suppresses the disconnected event.
38
+ */
39
+ export class TunnelManager extends EventEmitter {
40
+ getBinaryPath;
41
+ spawn;
42
+ timeoutMs;
43
+ metricsPort;
44
+ checkReady;
45
+ readyTimeoutMs;
46
+ readyIntervalMs;
47
+ healthCheckIntervalMs;
48
+ healthFailureThreshold;
49
+ killGraceMs;
50
+ killHardTimeoutMs;
51
+ child;
52
+ /** In-flight teardown of a previous child. Any start() awaits this first so a
53
+ * new cloudflared never spawns while the old one still owns the metrics port. */
54
+ pendingTeardown;
55
+ stopping = false;
56
+ connected = false;
57
+ currentUrl;
58
+ healthTimer;
59
+ healthCheckInFlight = false;
60
+ healthGeneration = 0;
61
+ consecutiveHealthFailures = 0;
62
+ constructor(opts) {
63
+ super();
64
+ this.getBinaryPath = opts.binaryPath;
65
+ this.spawn = opts.spawn ?? nodeSpawn;
66
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
67
+ this.metricsPort = opts.metricsPort ?? DEFAULT_METRICS_PORT;
68
+ this.checkReady = opts.checkReady ?? defaultCheckReady;
69
+ this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
70
+ this.readyIntervalMs = opts.readyIntervalMs ?? DEFAULT_READY_INTERVAL_MS;
71
+ this.healthCheckIntervalMs = opts.healthCheckIntervalMs ?? DEFAULT_HEALTH_CHECK_INTERVAL_MS;
72
+ this.healthFailureThreshold = Math.max(1, opts.healthFailureThreshold ?? DEFAULT_HEALTH_FAILURE_THRESHOLD);
73
+ this.killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
74
+ this.killHardTimeoutMs = opts.killHardTimeoutMs ?? DEFAULT_KILL_HARD_TIMEOUT_MS;
75
+ }
76
+ async start(port) {
77
+ // A previous cloudflared may still be around: a *soft* disconnect (edge
78
+ // /ready lost) deliberately keeps the child alive so it can auto-recover,
79
+ // so `this.child` can be set here even though the UI shows "disconnected".
80
+ // Blindly throwing "隧道已在运行" (or spawning immediately) is exactly the
81
+ // bug that forced a full app restart: the old process still holds the fixed
82
+ // metrics port, so the fresh cloudflared can't bind it and exits code=1.
83
+ // Tear the stale child down and WAIT for it to actually exit (freeing the
84
+ // port) before starting fresh, so re-opening always works.
85
+ // Tear down a still-live child (soft disconnect keeps it alive) AND await
86
+ // any teardown already in flight from a prior stop()/failure — the old
87
+ // cloudflared must fully exit and release 127.0.0.1:20741 before we spawn,
88
+ // or the new one fails to bind and exits code=1 (the app-restart bug).
89
+ if (this.child) {
90
+ await this.beginTeardown();
91
+ }
92
+ else if (this.pendingTeardown) {
93
+ await this.pendingTeardown;
94
+ }
95
+ this.stopping = false;
96
+ this.connected = false;
97
+ this.currentUrl = undefined;
98
+ this.stopHealthMonitor();
99
+ const child = this.spawn(this.getBinaryPath(), [
100
+ "tunnel",
101
+ "--no-autoupdate",
102
+ // Force http2 (TCP). cloudflared defaults to QUIC (UDP/7844), which is
103
+ // blocked on many networks (corp Wi-Fi, some carriers) — the connection
104
+ // object registers but never becomes ready, so the phone hits Cloudflare
105
+ // error 1033. http2 falls back to TCP/443 and reliably connects.
106
+ "--protocol",
107
+ "http2",
108
+ // Fixed local metrics endpoint so we can poll /ready for edge readiness.
109
+ "--metrics",
110
+ `127.0.0.1:${this.metricsPort}`,
111
+ "--url",
112
+ `http://127.0.0.1:${port}`,
113
+ ]);
114
+ this.child = child;
115
+ return new Promise((resolve, reject) => {
116
+ let settled = false;
117
+ const finishReject = (err) => {
118
+ if (settled)
119
+ return;
120
+ settled = true;
121
+ clearTimeout(timer);
122
+ // Route through the shared teardown so a retried start() awaits this
123
+ // child's real exit (releasing the metrics port) before re-spawning.
124
+ void this.beginTeardown();
125
+ reject(err);
126
+ };
127
+ const timer = setTimeout(() => {
128
+ finishReject(new Error("隧道启动失败:15s 内未取得公网地址(超时)"));
129
+ }, this.timeoutMs);
130
+ const scan = (buf) => {
131
+ if (settled)
132
+ return;
133
+ const match = TRYCLOUDFLARE_RE.exec(String(buf));
134
+ if (!match)
135
+ return;
136
+ // URL captured, but the edge connection is NOT necessarily ready yet —
137
+ // resolving here was the 1033 bug (handing back a dead QR). Stop the
138
+ // URL scanners and wait for /ready before declaring connected.
139
+ child.stdout?.off("data", scan);
140
+ child.stderr?.off("data", scan);
141
+ clearTimeout(timer);
142
+ const url = match[0];
143
+ this.waitForReady()
144
+ .then(() => {
145
+ if (settled)
146
+ return;
147
+ settled = true;
148
+ this.connected = true;
149
+ this.currentUrl = url;
150
+ this.emit("status", "connected", url);
151
+ this.startHealthMonitor();
152
+ resolve({ url });
153
+ })
154
+ .catch((err) => finishReject(err));
155
+ };
156
+ child.stdout?.on("data", scan);
157
+ child.stderr?.on("data", scan);
158
+ child.on("error", (err) => {
159
+ // A superseded child (torn down by a restart / stop-during-start) must
160
+ // NOT touch shared state — killAndWait owns its lifecycle. Match the exit
161
+ // guard exactly: identity alone, regardless of whether this child's start
162
+ // promise ever settled (stop() can supersede an unsettled child).
163
+ if (this.child !== child)
164
+ return;
165
+ if (!settled) {
166
+ settled = true;
167
+ clearTimeout(timer);
168
+ this.child = undefined;
169
+ this.connected = false;
170
+ this.currentUrl = undefined;
171
+ this.stopHealthMonitor();
172
+ this.emit("status", "error", err);
173
+ reject(err);
174
+ }
175
+ else if (!this.stopping) {
176
+ this.emit("status", "error", err);
177
+ }
178
+ });
179
+ child.on("exit", (code, signal) => {
180
+ // A superseded child (torn down by stop()/restart) exiting must NOT
181
+ // clear this.child / connected / health — those now belong to the new
182
+ // tunnel. killAndWait already awaits this exit; ignore it here.
183
+ if (this.child !== child)
184
+ return;
185
+ const wasConnected = this.connected;
186
+ this.child = undefined;
187
+ this.connected = false;
188
+ this.currentUrl = undefined;
189
+ this.stopHealthMonitor();
190
+ if (this.stopping)
191
+ return;
192
+ if (wasConnected) {
193
+ this.emit("status", "disconnected", { code, signal });
194
+ }
195
+ else if (!settled) {
196
+ settled = true;
197
+ clearTimeout(timer);
198
+ reject(new Error(`cloudflared 退出(code=${code ?? "?"} signal=${signal ?? "?"})`));
199
+ }
200
+ });
201
+ });
202
+ }
203
+ /**
204
+ * Poll cloudflared's /ready until the edge connection is up, or reject after
205
+ * `readyTimeoutMs`. This is what distinguishes a working tunnel from one that
206
+ * registered a connection but can't actually serve traffic (error 1033).
207
+ */
208
+ async waitForReady() {
209
+ const deadline = Date.now() + this.readyTimeoutMs;
210
+ for (;;) {
211
+ if (this.child && (await this.checkReady(this.metricsPort)))
212
+ return;
213
+ if (Date.now() >= deadline) {
214
+ throw new Error("隧道注册失败:公网边缘连接未就绪(可能网络限制,扫码会报 1033)");
215
+ }
216
+ await new Promise((r) => setTimeout(r, this.readyIntervalMs));
217
+ }
218
+ }
219
+ startHealthMonitor() {
220
+ this.stopHealthMonitor();
221
+ if (this.healthCheckIntervalMs <= 0)
222
+ return;
223
+ this.consecutiveHealthFailures = 0;
224
+ const generation = ++this.healthGeneration;
225
+ const timer = setInterval(() => {
226
+ void this.pollHealth(generation);
227
+ }, this.healthCheckIntervalMs);
228
+ timer.unref?.();
229
+ this.healthTimer = timer;
230
+ }
231
+ stopHealthMonitor() {
232
+ if (this.healthTimer)
233
+ clearInterval(this.healthTimer);
234
+ this.healthTimer = undefined;
235
+ this.healthGeneration += 1;
236
+ this.healthCheckInFlight = false;
237
+ this.consecutiveHealthFailures = 0;
238
+ }
239
+ async pollHealth(generation) {
240
+ if (generation !== this.healthGeneration ||
241
+ this.healthCheckInFlight ||
242
+ !this.child ||
243
+ this.stopping) {
244
+ return;
245
+ }
246
+ this.healthCheckInFlight = true;
247
+ try {
248
+ const ready = await this.checkReady(this.metricsPort);
249
+ if (generation !== this.healthGeneration || !this.child || this.stopping)
250
+ return;
251
+ if (ready) {
252
+ this.consecutiveHealthFailures = 0;
253
+ if (!this.connected) {
254
+ this.connected = true;
255
+ this.emit("status", "connected", this.currentUrl);
256
+ }
257
+ return;
258
+ }
259
+ this.consecutiveHealthFailures += 1;
260
+ if (this.connected && this.consecutiveHealthFailures >= this.healthFailureThreshold) {
261
+ this.connected = false;
262
+ this.emit("status", "disconnected", {
263
+ reason: "ready-check-failed",
264
+ consecutiveFailures: this.consecutiveHealthFailures,
265
+ });
266
+ }
267
+ }
268
+ catch (err) {
269
+ if (generation !== this.healthGeneration || !this.child || this.stopping)
270
+ return;
271
+ this.emit("status", "error", err);
272
+ }
273
+ finally {
274
+ if (generation === this.healthGeneration) {
275
+ this.healthCheckInFlight = false;
276
+ }
277
+ }
278
+ }
279
+ /**
280
+ * Tear the current child down and remember the wait as `pendingTeardown` so a
281
+ * subsequent start() blocks until the process has really exited (freeing the
282
+ * metrics port). Shared by stop(), startup-failure, and restart so there is a
283
+ * single teardown path — the missing shared wait was the stop()→start() race.
284
+ * Idempotent: with no live child it resolves immediately.
285
+ */
286
+ beginTeardown() {
287
+ this.stopping = true;
288
+ this.connected = false;
289
+ this.currentUrl = undefined;
290
+ this.stopHealthMonitor();
291
+ const child = this.child;
292
+ this.child = undefined;
293
+ if (!child) {
294
+ // No live child, but a PRIOR teardown may still be draining (e.g.
295
+ // finishReject() started one, then a stop() re-enters here). Do NOT clear
296
+ // it — a retried start() must still await that exit before spawning, or it
297
+ // races the held metrics port. Return the in-flight wait (or resolved).
298
+ return this.pendingTeardown ?? Promise.resolve();
299
+ }
300
+ const wait = this.killAndWait(child).finally(() => {
301
+ // Only clear if no newer teardown superseded this one.
302
+ if (this.pendingTeardown === wait)
303
+ this.pendingTeardown = undefined;
304
+ });
305
+ this.pendingTeardown = wait;
306
+ return wait;
307
+ }
308
+ /**
309
+ * SIGTERM the child, and if it hasn't exited within `killGraceMs`, escalate to
310
+ * SIGKILL — cloudflared's SIGTERM is a graceful drain (default ~30s) that
311
+ * would otherwise keep the metrics port bound long past a re-open. Normally
312
+ * resolves on the process's real `exit`; `killHardTimeoutMs` is a last-resort
313
+ * cap so a child that never reports exit can't hang teardown forever. That cap
314
+ * is reached only AFTER SIGKILL was already sent, so on macOS/Linux the port is
315
+ * in practice released by then — it is a safety valve, not the happy path.
316
+ */
317
+ killAndWait(child) {
318
+ return new Promise((resolve) => {
319
+ let done = false;
320
+ // Declared before finish() so a synchronous exit (fake/edge case) or a
321
+ // throwing kill() can clear them without hitting the temporal dead zone.
322
+ let graceTimer;
323
+ let hardTimer;
324
+ const finish = () => {
325
+ if (done)
326
+ return;
327
+ done = true;
328
+ if (graceTimer)
329
+ clearTimeout(graceTimer);
330
+ if (hardTimer)
331
+ clearTimeout(hardTimer);
332
+ resolve();
333
+ };
334
+ child.once("exit", finish);
335
+ try {
336
+ child.kill("SIGTERM");
337
+ }
338
+ catch {
339
+ finish();
340
+ return;
341
+ }
342
+ if (done)
343
+ return; // exit fired synchronously during kill()
344
+ graceTimer = setTimeout(() => {
345
+ try {
346
+ child.kill("SIGKILL");
347
+ }
348
+ catch {
349
+ /* already gone */
350
+ }
351
+ }, this.killGraceMs);
352
+ hardTimer = setTimeout(finish, this.killHardTimeoutMs);
353
+ graceTimer.unref?.();
354
+ hardTimer.unref?.();
355
+ });
356
+ }
357
+ /**
358
+ * Kill the cloudflared child and suppress the disconnect event. Returns a
359
+ * promise that resolves once the process has actually exited so callers that
360
+ * immediately restart don't race the port. Fire-and-forget callers may ignore
361
+ * it (kept sync-compatible: existing `tunnelManager.stop()` sites work).
362
+ */
363
+ stop() {
364
+ return this.beginTeardown();
365
+ }
366
+ isRunning() {
367
+ return Boolean(this.child);
368
+ }
369
+ isConnected() {
370
+ return this.connected;
371
+ }
372
+ publicUrl() {
373
+ return this.currentUrl;
374
+ }
375
+ }
@@ -0,0 +1 @@
1
+ export type { TrustedDevice, TrustedDevicePublic, PairingToken, ApprovalScope, ApprovalPathScope, MobileRemotePermissionMode as PermissionMode, CcDiscoveredSession, CcHistoryMessage, CcApprovalDecision, MobilePermissionModeSnapshotEntry, MobileProjectMeta, MobileImageMime, MobileImageBase, MobileImageAttachment, MobileAttachmentSummary, MobileClientEvent, MobileServerEvent, RoomPublic, MobileSessionMeta, } from "@cjhyy/code-shell-core";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export interface MobileViewerIdentity {
2
+ deviceId: string;
3
+ viewerId: string;
4
+ }
5
+ export declare function mobileTranscriptSubscriberId(viewerId: string): string;
@@ -0,0 +1,5 @@
1
+ export function mobileTranscriptSubscriberId(viewerId) {
2
+ if (!viewerId)
3
+ throw new Error("authenticated mobile viewer id required");
4
+ return `mobile:${viewerId}`;
5
+ }
@@ -0,0 +1,15 @@
1
+ interface CliArgs {
2
+ cwd: string;
3
+ host: string;
4
+ port: number;
5
+ passcode?: string;
6
+ dataDir: string;
7
+ staticRootDir?: string;
8
+ }
9
+ export declare function parseServeArgs(argv: string[], env?: NodeJS.ProcessEnv): CliArgs;
10
+ /** Locate the stdio worker entry the same way desktop's AgentBridge does. */
11
+ export declare function resolveWorkerEntry(): string;
12
+ /** Locate the built browser app (packages/web `app` build), when present. */
13
+ export declare function resolveWebAppRoot(): string | undefined;
14
+ export declare function runServeCli(argv?: string[]): Promise<void>;
15
+ export {};