@cjhyy/code-shell-server 0.8.8 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ import { MAX_MOBILE_ATTACHMENTS, MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
2
+ const PERMISSION_MODES = new Set(["default", "acceptEdits", "bypassPermissions"]);
3
+ const KINDS = new Set(["claude-code", "codex"]);
4
+ const SCOPES = new Set(["once", "session", "project"]);
5
+ const PATH_SCOPES = new Set(["file", "dir", "tool"]);
6
+ const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
7
+ const MAX_ID_LENGTH = 512;
8
+ const MAX_NAME_LENGTH = 512;
9
+ const MAX_PATH_LENGTH = 32_768;
10
+ const MAX_MESSAGE_LENGTH = 512 * 1024;
11
+ const MAX_HISTORY_LIMIT = 500;
12
+ /** Runtime validation at the WebSocket boundary; TypeScript types do not validate JSON. */
13
+ export function parseMobileClientEvent(value) {
14
+ if (!isRecord(value) || typeof value.type !== "string")
15
+ return undefined;
16
+ const event = value;
17
+ let valid;
18
+ switch (event.type) {
19
+ case "auth.device":
20
+ valid = boundedString(event.deviceId, MAX_ID_LENGTH) && boundedString(event.secretHash, 4096);
21
+ break;
22
+ case "pair.complete":
23
+ valid =
24
+ boundedString(event.token, MAX_ID_LENGTH) &&
25
+ boundedTrimmedString(event.name, MAX_NAME_LENGTH) &&
26
+ boundedString(event.secretHash, 4096);
27
+ break;
28
+ case "chat.send":
29
+ valid =
30
+ boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
31
+ optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
32
+ optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
33
+ optionalAttachments(event.attachments);
34
+ break;
35
+ case "attachment.upload.begin":
36
+ valid =
37
+ boundedString(event.clientId, MAX_ID_LENGTH) &&
38
+ boundedString(event.name, MAX_NAME_LENGTH) &&
39
+ member(event.mime, IMAGE_MIMES) &&
40
+ isPositiveInteger(event.size) &&
41
+ event.size <= MAX_MOBILE_IMAGE_BYTES;
42
+ break;
43
+ case "session.select":
44
+ case "session.history":
45
+ valid = boundedString(event.sessionId, MAX_ID_LENGTH);
46
+ break;
47
+ case "session.create":
48
+ valid =
49
+ (event.cwd === undefined ||
50
+ event.cwd === null ||
51
+ boundedPath(event.cwd)) &&
52
+ optionalBoundedString(event.name, MAX_NAME_LENGTH);
53
+ break;
54
+ case "run.stop":
55
+ valid = optionalBoundedString(event.sessionId, MAX_ID_LENGTH);
56
+ break;
57
+ case "approval.respond":
58
+ valid =
59
+ boundedString(event.approvalId, MAX_ID_LENGTH) &&
60
+ (event.decision === "approve" || event.decision === "reject") &&
61
+ optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
62
+ optionalBoundedString(event.reason, MAX_MESSAGE_LENGTH) &&
63
+ optionalBoundedString(event.answer, MAX_MESSAGE_LENGTH) &&
64
+ optionalMember(event.scope, SCOPES) &&
65
+ optionalMember(event.pathScope, PATH_SCOPES);
66
+ break;
67
+ case "session.list":
68
+ case "room.list":
69
+ case "room.projects":
70
+ valid = true;
71
+ break;
72
+ case "session.sync":
73
+ valid =
74
+ boundedString(event.sessionId, MAX_ID_LENGTH) &&
75
+ optionalNonNegativeInteger(event.sinceSeq);
76
+ break;
77
+ case "permission.setMode":
78
+ valid =
79
+ optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
80
+ member(event.mode, PERMISSION_MODES);
81
+ break;
82
+ case "model.set":
83
+ valid = boundedString(event.model, MAX_ID_LENGTH);
84
+ break;
85
+ case "goal.extend":
86
+ valid =
87
+ boundedString(event.sessionId, MAX_ID_LENGTH) &&
88
+ optionalPositiveInteger(event.addTurns) &&
89
+ optionalPositiveInteger(event.addTokenBudget) &&
90
+ optionalPositiveInteger(event.addTimeBudgetMs) &&
91
+ optionalPositiveInteger(event.addStopBlocks) &&
92
+ [event.addTurns, event.addTokenBudget, event.addTimeBudgetMs, event.addStopBlocks].some((amount) => amount !== undefined);
93
+ break;
94
+ case "goal.clear":
95
+ valid = boundedString(event.sessionId, MAX_ID_LENGTH);
96
+ break;
97
+ case "room.create":
98
+ valid =
99
+ boundedPath(event.cwd) &&
100
+ optionalBoundedString(event.name, MAX_NAME_LENGTH) &&
101
+ optionalMember(event.kind, KINDS) &&
102
+ optionalMember(event.permissionMode, PERMISSION_MODES);
103
+ break;
104
+ case "room.open":
105
+ case "room.close":
106
+ valid = boundedString(event.roomId, MAX_ID_LENGTH);
107
+ break;
108
+ case "room.send":
109
+ valid =
110
+ boundedString(event.roomId, MAX_ID_LENGTH) &&
111
+ boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
112
+ optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
113
+ optionalAttachments(event.attachments);
114
+ break;
115
+ case "room.history":
116
+ valid =
117
+ boundedString(event.roomId, MAX_ID_LENGTH) &&
118
+ optionalNonNegativeInteger(event.sinceSeq);
119
+ break;
120
+ case "ccRoom.probe":
121
+ valid =
122
+ (event.force === undefined || typeof event.force === "boolean") &&
123
+ optionalMember(event.kind, KINDS);
124
+ break;
125
+ case "ccRoom.listSessions":
126
+ valid = boundedPath(event.cwd) && optionalMember(event.kind, KINDS);
127
+ break;
128
+ case "ccRoom.openSession":
129
+ valid =
130
+ boundedString(event.sessionId, MAX_ID_LENGTH) &&
131
+ boundedPath(event.cwd) &&
132
+ member(event.mode, PERMISSION_MODES) &&
133
+ optionalMember(event.kind, KINDS);
134
+ break;
135
+ case "ccRoom.subscribeTranscript":
136
+ valid =
137
+ boundedString(event.roomId, MAX_ID_LENGTH) &&
138
+ boundedString(event.sessionId, MAX_ID_LENGTH) &&
139
+ boundedPath(event.cwd) &&
140
+ isBoundedHistoryLimit(event.limit) &&
141
+ optionalMember(event.kind, KINDS);
142
+ break;
143
+ case "ccRoom.unsubscribeTranscript":
144
+ valid = boundedString(event.roomId, MAX_ID_LENGTH);
145
+ break;
146
+ case "ccRoom.readHistory":
147
+ valid =
148
+ boundedPath(event.cwd) &&
149
+ boundedString(event.sessionId, MAX_ID_LENGTH) &&
150
+ isBoundedHistoryLimit(event.limit) &&
151
+ optionalMember(event.kind, KINDS);
152
+ break;
153
+ case "ccRoom.respondApproval":
154
+ valid =
155
+ boundedString(event.roomId, MAX_ID_LENGTH) &&
156
+ boundedString(event.requestId, MAX_ID_LENGTH) &&
157
+ isApprovalDecision(event.decision);
158
+ break;
159
+ default:
160
+ valid = false;
161
+ }
162
+ return valid ? event : undefined;
163
+ }
164
+ function isRecord(value) {
165
+ return value !== null && typeof value === "object" && !Array.isArray(value);
166
+ }
167
+ function boundedString(value, maxLength) {
168
+ return typeof value === "string" && value.length > 0 && value.length <= maxLength;
169
+ }
170
+ function boundedOptionalEmptyString(value, maxLength) {
171
+ return typeof value === "string" && value.length <= maxLength;
172
+ }
173
+ function boundedTrimmedString(value, maxLength) {
174
+ return boundedString(value, maxLength) && value.trim().length > 0 && !value.includes("\0");
175
+ }
176
+ function boundedPath(value) {
177
+ return boundedString(value, MAX_PATH_LENGTH) && !value.includes("\0");
178
+ }
179
+ function optionalBoundedString(value, maxLength) {
180
+ return value === undefined || boundedString(value, maxLength);
181
+ }
182
+ function member(value, values) {
183
+ return typeof value === "string" && values.has(value);
184
+ }
185
+ function optionalMember(value, values) {
186
+ return value === undefined || member(value, values);
187
+ }
188
+ function isNonNegativeInteger(value) {
189
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
190
+ }
191
+ function isPositiveInteger(value) {
192
+ return isNonNegativeInteger(value) && value > 0;
193
+ }
194
+ function optionalNonNegativeInteger(value) {
195
+ return value === undefined || isNonNegativeInteger(value);
196
+ }
197
+ function optionalPositiveInteger(value) {
198
+ return value === undefined || isPositiveInteger(value);
199
+ }
200
+ function isBoundedHistoryLimit(value) {
201
+ return isPositiveInteger(value) && value <= MAX_HISTORY_LIMIT;
202
+ }
203
+ function optionalAttachments(value) {
204
+ return (value === undefined ||
205
+ (Array.isArray(value) &&
206
+ value.length <= MAX_MOBILE_ATTACHMENTS &&
207
+ value.every(isAttachment)));
208
+ }
209
+ function isAttachment(value) {
210
+ if (!isRecord(value) ||
211
+ !boundedString(value.clientId, MAX_ID_LENGTH) ||
212
+ !boundedString(value.name, MAX_NAME_LENGTH) ||
213
+ !member(value.mime, IMAGE_MIMES) ||
214
+ !isPositiveInteger(value.size) ||
215
+ value.size > MAX_MOBILE_IMAGE_BYTES) {
216
+ return false;
217
+ }
218
+ return value.transport === "inline"
219
+ ? boundedString(value.dataUrl, MAX_MESSAGE_LENGTH)
220
+ : value.transport === "upload" && boundedString(value.uploadId, MAX_ID_LENGTH);
221
+ }
222
+ function isApprovalDecision(value) {
223
+ if (!isRecord(value))
224
+ return false;
225
+ if (value.behavior === "allow") {
226
+ return ((value.answer === undefined || boundedOptionalEmptyString(value.answer, MAX_MESSAGE_LENGTH)) &&
227
+ (value.updatedInput === undefined || isRecord(value.updatedInput)));
228
+ }
229
+ return (value.behavior === "deny" && boundedOptionalEmptyString(value.message, MAX_MESSAGE_LENGTH));
230
+ }
@@ -0,0 +1,6 @@
1
+ /** Pure transport limits shared by validation, upload ticketing, and materialization. */
2
+ export declare const MAX_MOBILE_ATTACHMENTS = 4;
3
+ export declare const MAX_MOBILE_IMAGE_BYTES: number;
4
+ export declare const MAX_MOBILE_INLINE_IMAGE_BYTES: number;
5
+ export declare const MAX_MOBILE_INLINE_TOTAL_BYTES: number;
6
+ export declare const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES: number;
@@ -0,0 +1,6 @@
1
+ /** Pure transport limits shared by validation, upload ticketing, and materialization. */
2
+ export const MAX_MOBILE_ATTACHMENTS = 4;
3
+ export const MAX_MOBILE_IMAGE_BYTES = 10 * 1024 * 1024;
4
+ export const MAX_MOBILE_INLINE_IMAGE_BYTES = 256 * 1024;
5
+ export const MAX_MOBILE_INLINE_TOTAL_BYTES = 512 * 1024;
6
+ export const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024;
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { MobileImageBase } from "./types.js";
3
- export declare const MAX_MOBILE_IMAGE_BYTES: number;
3
+ export { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
4
4
  export declare const MOBILE_UPLOAD_TTL_MS: number;
5
5
  export declare const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
6
6
  export declare const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
@@ -53,5 +53,6 @@ export declare class MobileUploadService {
53
53
  private expire;
54
54
  private cleanupRecordFiles;
55
55
  private cleanupOrphanSpools;
56
+ private ensurePrivateRoot;
56
57
  private assertClaimOwner;
57
58
  }
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { createWriteStream } from "node:fs";
3
- import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
3
+ import { chmod, mkdir, readdir, rename, rm, stat } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { Transform } from "node:stream";
7
- export const MAX_MOBILE_IMAGE_BYTES = 10 * 1024 * 1024;
7
+ import { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
8
+ export { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
8
9
  export const MOBILE_UPLOAD_TTL_MS = 5 * 60 * 1000;
9
10
  export const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
10
11
  export const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
@@ -142,7 +143,7 @@ export class MobileUploadService {
142
143
  // receives the existing 409 above while this first request runs normally.
143
144
  record.status = "uploading";
144
145
  try {
145
- await mkdir(this.opts.rootDir, { recursive: true });
146
+ await this.ensurePrivateRoot();
146
147
  }
147
148
  catch {
148
149
  if (this.uploads.get(uploadId) === record && record.status === "uploading") {
@@ -173,7 +174,7 @@ export class MobileUploadService {
173
174
  controller.abort(new Error("upload ticket expired during transfer"));
174
175
  }, Math.max(0, record.expiresAt - this.now()));
175
176
  deadline.unref?.();
176
- const transfer = pipeline(req, counter, createWriteStream(partPath, { flags: "wx" }), {
177
+ const transfer = pipeline(req, counter, createWriteStream(partPath, { flags: "wx", mode: 0o600 }), {
177
178
  signal: controller.signal,
178
179
  });
179
180
  this.activeTransfers.set(uploadId, { controller, done: transfer });
@@ -332,7 +333,7 @@ export class MobileUploadService {
332
333
  }
333
334
  async cleanupOrphanSpools(now, removeAll) {
334
335
  try {
335
- await mkdir(this.opts.rootDir, { recursive: true });
336
+ await this.ensurePrivateRoot();
336
337
  }
337
338
  catch {
338
339
  return;
@@ -356,6 +357,11 @@ export class MobileUploadService {
356
357
  await rm(path, { force: true });
357
358
  }));
358
359
  }
360
+ async ensurePrivateRoot() {
361
+ await mkdir(this.opts.rootDir, { recursive: true, mode: 0o700 });
362
+ if (process.platform !== "win32")
363
+ await chmod(this.opts.rootDir, 0o700);
364
+ }
359
365
  assertClaimOwner(deviceId, uploadId, claimId) {
360
366
  const record = this.uploads.get(uploadId);
361
367
  if (!record)
@@ -1,8 +1,19 @@
1
1
  import type { PairingToken } from "./types.js";
2
+ export declare const MAX_PENDING_PAIRING_TOKENS = 256;
2
3
  export declare class PairingTokenManager {
3
4
  private readonly now;
4
5
  private tokens;
5
6
  constructor(now?: () => number);
6
7
  createToken(ttlMs?: number): PairingToken;
7
8
  consume(value: string): boolean;
9
+ /**
10
+ * Run a synchronous commit while the token is valid, consuming it only when
11
+ * that commit succeeds. The validity timestamp is sampled once, so a commit
12
+ * that crosses the expiry millisecond cannot create a device yet report a
13
+ * false pairing failure.
14
+ */
15
+ commit<T>(value: string, action: () => T): T | undefined;
16
+ /** Live token count for diagnostics/tests; expired entries are pruned on mint. */
17
+ get pendingCount(): number;
18
+ private prune;
8
19
  }
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ export const MAX_PENDING_PAIRING_TOKENS = 256;
2
3
  export class PairingTokenManager {
3
4
  now;
4
5
  tokens = new Map();
@@ -6,9 +7,23 @@ export class PairingTokenManager {
6
7
  this.now = now;
7
8
  }
8
9
  createToken(ttlMs = 10 * 60 * 1000) {
10
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
11
+ throw new Error("Pairing token TTL must be a positive safe integer");
12
+ }
13
+ const now = this.now();
14
+ if (!Number.isSafeInteger(now) || now < 0 || now > Number.MAX_SAFE_INTEGER - ttlMs) {
15
+ throw new Error("Pairing token clock is invalid");
16
+ }
17
+ this.prune(now);
18
+ while (this.tokens.size >= MAX_PENDING_PAIRING_TOKENS) {
19
+ const oldest = this.tokens.keys().next().value;
20
+ if (oldest === undefined)
21
+ break;
22
+ this.tokens.delete(oldest);
23
+ }
9
24
  const token = {
10
25
  value: randomBytes(32).toString("base64url"),
11
- expiresAt: this.now() + ttlMs,
26
+ expiresAt: now + ttlMs,
12
27
  };
13
28
  this.tokens.set(token.value, token);
14
29
  return token;
@@ -18,6 +33,36 @@ export class PairingTokenManager {
18
33
  if (!token)
19
34
  return false;
20
35
  this.tokens.delete(value);
21
- return token.expiresAt >= this.now();
36
+ const now = this.now();
37
+ return Number.isSafeInteger(now) && now >= 0 && token.expiresAt >= now;
38
+ }
39
+ /**
40
+ * Run a synchronous commit while the token is valid, consuming it only when
41
+ * that commit succeeds. The validity timestamp is sampled once, so a commit
42
+ * that crosses the expiry millisecond cannot create a device yet report a
43
+ * false pairing failure.
44
+ */
45
+ commit(value, action) {
46
+ const token = this.tokens.get(value);
47
+ if (!token)
48
+ return undefined;
49
+ const now = this.now();
50
+ if (!Number.isSafeInteger(now) || now < 0 || token.expiresAt < now) {
51
+ this.tokens.delete(value);
52
+ return undefined;
53
+ }
54
+ const result = action();
55
+ this.tokens.delete(value);
56
+ return result;
57
+ }
58
+ /** Live token count for diagnostics/tests; expired entries are pruned on mint. */
59
+ get pendingCount() {
60
+ return this.tokens.size;
61
+ }
62
+ prune(now) {
63
+ for (const [value, token] of this.tokens) {
64
+ if (token.expiresAt < now)
65
+ this.tokens.delete(value);
66
+ }
22
67
  }
23
68
  }
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
2
2
  import { type WebSocket } from "ws";
3
3
  import type { AccessPasscode } from "./access-passcode.js";
4
4
  import type { TrustedDeviceStore } from "./trusted-device-store.js";
5
- import type { MobileClientEvent, MobileServerEvent } from "./types.js";
5
+ import type { MobileServerEvent } from "./types.js";
6
6
  import type { MobileUploadService } from "./mobile-upload-service.js";
7
7
  /**
8
8
  * Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
@@ -33,7 +33,7 @@ export interface RemoteHostStarted {
33
33
  }
34
34
  export interface RemoteHostManagerOptions {
35
35
  devices: TrustedDeviceStore;
36
- onClientEvent: (event: unknown, ws: WebSocket) => void;
36
+ onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
37
37
  /**
38
38
  * Absolute path to the built mobile app (out/mobile). Defaults to the
39
39
  * sibling of the bundled main (out/main → ../mobile). Overridable for tests.
@@ -52,6 +52,7 @@ export declare class RemoteHostManager extends EventEmitter {
52
52
  private server?;
53
53
  private wss?;
54
54
  private started?;
55
+ private starting?;
55
56
  private pairing;
56
57
  /** ws → authenticated device id. A socket absent here is unauthenticated. */
57
58
  private authed;
@@ -79,6 +80,7 @@ export declare class RemoteHostManager extends EventEmitter {
79
80
  * socket goes away (online→offline). */
80
81
  markOffline(deviceId: string): void;
81
82
  start(options: RemoteHostStartOptions): Promise<RemoteHostStarted>;
83
+ private startOnce;
82
84
  /**
83
85
  * Override the base URL used to build pairing URLs (tunnel mode points this
84
86
  * at the `https://*.trycloudflare.com` domain so the QR encodes the public
@@ -97,7 +99,7 @@ export declare class RemoteHostManager extends EventEmitter {
97
99
  * trusted-device store (which rejects revoked devices), so the remote host
98
100
  * never executes tools itself — it only gates transport.
99
101
  */
100
- handleClientEvent(event: MobileClientEvent): MobileServerEvent | undefined;
102
+ handleClientEvent(input: unknown): MobileServerEvent | undefined;
101
103
  /** Broadcast a server event to every connected, authenticated mobile socket. */
102
104
  broadcast(event: MobileServerEvent): void;
103
105
  /** Send a server event to ONLY the sockets belonging to one device. Used for
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { WebSocketServer } from "ws";
7
7
  import { serveMobile } from "./mobile-static.js";
8
8
  import { PairingTokenManager } from "./pairing.js";
9
+ import { parseMobileClientEvent } from "./mobile-client-event-validator.js";
9
10
  /**
10
11
  * Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
11
12
  * remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
@@ -37,6 +38,7 @@ export class RemoteHostManager extends EventEmitter {
37
38
  server;
38
39
  wss;
39
40
  started;
41
+ starting;
40
42
  pairing = new PairingTokenManager();
41
43
  /** ws → authenticated device id. A socket absent here is unauthenticated. */
42
44
  authed = new WeakMap();
@@ -91,6 +93,19 @@ export class RemoteHostManager extends EventEmitter {
91
93
  async start(options) {
92
94
  if (this.started)
93
95
  return this.started;
96
+ if (this.starting)
97
+ return this.starting;
98
+ const attempt = this.startOnce(options);
99
+ this.starting = attempt;
100
+ try {
101
+ return await attempt;
102
+ }
103
+ finally {
104
+ if (this.starting === attempt)
105
+ this.starting = undefined;
106
+ }
107
+ }
108
+ async startOnce(options) {
94
109
  const tunnel = options.mode === "tunnel";
95
110
  this.passcode = tunnel ? options.passcode : undefined;
96
111
  const gate = this.passcode;
@@ -135,9 +150,19 @@ export class RemoteHostManager extends EventEmitter {
135
150
  this.wss = gate
136
151
  ? new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 })
137
152
  : new WebSocketServer({ server, path: "/ws", maxPayload: 1024 * 1024 });
153
+ // ws re-emits its HTTP server's bind errors. Without an error listener a
154
+ // handled EADDRINUSE rejection still becomes an uncaught EventEmitter error.
155
+ this.wss.on("error", (error) => this.emit("host-error", error));
138
156
  if (gate) {
139
157
  server.on("upgrade", (req, socket, head) => {
140
- if (!req.url?.startsWith("/ws")) {
158
+ let isWebSocketPath = false;
159
+ try {
160
+ isWebSocketPath = new URL(req.url ?? "", "http://localhost").pathname === "/ws";
161
+ }
162
+ catch {
163
+ // Invalid request targets are not valid WebSocket endpoints.
164
+ }
165
+ if (!isWebSocketPath) {
141
166
  socket.destroy();
142
167
  return;
143
168
  }
@@ -155,18 +180,30 @@ export class RemoteHostManager extends EventEmitter {
155
180
  const viewerId = `viewer-${this.nextViewerId++}`;
156
181
  this.viewers.set(ws, viewerId);
157
182
  ws.on("message", (raw) => {
158
- let event;
183
+ let parsed;
159
184
  try {
160
- event = JSON.parse(String(raw));
185
+ parsed = JSON.parse(String(raw));
161
186
  }
162
187
  catch {
163
188
  ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
164
189
  return;
165
190
  }
191
+ const event = parseMobileClientEvent(parsed);
192
+ if (!event) {
193
+ ws.send(JSON.stringify({ type: "error", message: "Invalid client event" }));
194
+ return;
195
+ }
166
196
  // Pairing/auth are handled inline and bind device identity to this
167
197
  // socket. Everything else is gated: an unauthenticated socket cannot
168
198
  // send chat/approval/run/job events (design §6.1).
169
- const reply = this.handleClientEvent(event);
199
+ let reply;
200
+ try {
201
+ reply = this.handleClientEvent(event);
202
+ }
203
+ catch {
204
+ ws.send(JSON.stringify({ type: "error", message: "Client event failed" }));
205
+ return;
206
+ }
170
207
  if (event.type === "auth.device" && reply?.type === "auth.ok") {
171
208
  if (!this.authed.has(ws))
172
209
  this.markOnline(reply.device.id);
@@ -188,7 +225,17 @@ export class RemoteHostManager extends EventEmitter {
188
225
  }
189
226
  // Authenticated, non-auth event → hand to the main dispatcher, which
190
227
  // routes chat/approval into the existing run/permission path.
191
- this.opts.onClientEvent({ ...event, deviceId: this.authed.get(ws), viewerId: this.viewers.get(ws) }, ws);
228
+ try {
229
+ const dispatched = this.opts.onClientEvent({ ...event, deviceId: this.authed.get(ws), viewerId: this.viewers.get(ws) }, ws);
230
+ void Promise.resolve(dispatched).catch(() => {
231
+ if (ws.readyState === ws.OPEN) {
232
+ ws.send(JSON.stringify({ type: "error", message: "Client event failed" }));
233
+ }
234
+ });
235
+ }
236
+ catch {
237
+ ws.send(JSON.stringify({ type: "error", message: "Client event failed" }));
238
+ }
192
239
  });
193
240
  ws.on("close", () => {
194
241
  this.releaseSocket(ws);
@@ -203,10 +250,35 @@ export class RemoteHostManager extends EventEmitter {
203
250
  : options.host === "lan"
204
251
  ? (resolveLanHost() ?? "127.0.0.1")
205
252
  : options.host;
206
- await new Promise((resolve, reject) => {
207
- server.once("error", reject);
208
- server.listen(options.port, bindHost, () => resolve());
209
- });
253
+ try {
254
+ await new Promise((resolve, reject) => {
255
+ const onError = (error) => reject(error);
256
+ server.once("error", onError);
257
+ server.listen(options.port, bindHost, () => {
258
+ server.off("error", onError);
259
+ resolve();
260
+ });
261
+ });
262
+ }
263
+ catch (error) {
264
+ for (const client of this.wss?.clients ?? [])
265
+ client.terminate();
266
+ try {
267
+ this.wss?.close();
268
+ }
269
+ catch {
270
+ // A noServer WebSocketServer may not have entered a running state yet.
271
+ }
272
+ server.closeAllConnections?.();
273
+ if (server.listening) {
274
+ await new Promise((resolveClose) => server.close(() => resolveClose()));
275
+ }
276
+ if (this.server === server)
277
+ this.server = undefined;
278
+ this.wss = undefined;
279
+ this.passcode = undefined;
280
+ throw error;
281
+ }
210
282
  const addr = server.address();
211
283
  const port = typeof addr === "object" && addr ? addr.port : options.port;
212
284
  this.started = {
@@ -243,19 +315,34 @@ export class RemoteHostManager extends EventEmitter {
243
315
  * trusted-device store (which rejects revoked devices), so the remote host
244
316
  * never executes tools itself — it only gates transport.
245
317
  */
246
- handleClientEvent(event) {
318
+ handleClientEvent(input) {
319
+ const event = parseMobileClientEvent(input);
320
+ if (!event)
321
+ return { type: "error", message: "Invalid client event" };
247
322
  if (event.type === "pair.complete") {
248
- if (!this.pairing.consume(event.token)) {
323
+ let device;
324
+ try {
325
+ device = this.pairing.commit(event.token, () => this.opts.devices.addDevice({
326
+ name: event.name,
327
+ secretHash: event.secretHash,
328
+ }));
329
+ }
330
+ catch {
331
+ return { type: "pair.failed", message: "Trusted device store is unavailable" };
332
+ }
333
+ if (!device) {
249
334
  return { type: "pair.failed", message: "Pairing token expired or invalid" };
250
335
  }
251
- const device = this.opts.devices.addDevice({
252
- name: event.name,
253
- secretHash: event.secretHash,
254
- });
255
336
  return { type: "pair.ok", device };
256
337
  }
257
338
  if (event.type === "auth.device") {
258
- const device = this.opts.devices.authenticate(event.deviceId, event.secretHash);
339
+ let device;
340
+ try {
341
+ device = this.opts.devices.authenticate(event.deviceId, event.secretHash);
342
+ }
343
+ catch {
344
+ return { type: "auth.failed", message: "Trusted device store is unavailable" };
345
+ }
259
346
  if (!device)
260
347
  return { type: "auth.failed", message: "Device is not trusted" };
261
348
  return { type: "auth.ok", device };
@@ -309,6 +396,8 @@ export class RemoteHostManager extends EventEmitter {
309
396
  this.markOffline(deviceId);
310
397
  }
311
398
  async stop() {
399
+ if (this.starting)
400
+ await this.starting.catch(() => undefined);
312
401
  const server = this.server;
313
402
  // Forcibly drop live WS sockets first. wss.close() alone waits for clients
314
403
  // to disconnect, so a phone left connected would hang stop() indefinitely.
@@ -150,6 +150,9 @@ export declare class RoomManager {
150
150
  * Holds the raw tool input so respondApproval can bake the user's answer into
151
151
  * the `answers` record the CLI expects. Cleared on response. */
152
152
  private pendingAskUser;
153
+ /** Original server-observed tool inputs for ordinary approvals. The response
154
+ * client may approve or deny, but must not replace the command/path it saw. */
155
+ private pendingApprovalInputs;
153
156
  /** User turns are echoed immediately for responsive room UX. When a
154
157
  * transcript follower later sees the same CLI-written user line, this queue
155
158
  * suppresses that duplicate. */
@@ -174,15 +177,20 @@ export declare class RoomManager {
174
177
  private pendingLastActive;
175
178
  private lastActiveWrittenAt;
176
179
  private now;
180
+ private readonly rootRealPath;
177
181
  /** Drop all pending AskUser entries for a room. Called when its agent goes
178
182
  * away (exit/close) so a request that can no longer be answered doesn't leak
179
183
  * in the map (small memory leak) and its approval card doesn't hang until the
180
184
  * 5-min timeout. Keys are `${roomId}:${requestId}`. */
181
- private clearPendingAskUser;
185
+ private clearPendingApprovals;
182
186
  constructor(opts: RoomManagerOptions);
187
+ private assertRootUnchanged;
183
188
  private roomDir;
189
+ private checkedRoomFile;
184
190
  private metaPath;
185
191
  private msgPath;
192
+ private writeRoomMeta;
193
+ private appendRoomMessage;
186
194
  createRoom(input: {
187
195
  name?: string;
188
196
  cwd: string;