@cjhyy/code-shell-server 0.9.51 → 0.10.1

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 (93) hide show
  1. package/dist/account/cli.d.ts +23 -0
  2. package/dist/account/cli.js +249 -0
  3. package/dist/account/cloud-contract.d.ts +39 -0
  4. package/dist/account/cloud-contract.js +134 -0
  5. package/dist/account/cloud-types.d.ts +47 -0
  6. package/dist/account/cloud-types.js +1 -0
  7. package/dist/account/feature-service.d.ts +47 -0
  8. package/dist/account/feature-service.js +184 -0
  9. package/dist/account/http.d.ts +17 -0
  10. package/dist/account/http.js +158 -0
  11. package/dist/account/links.d.ts +13 -0
  12. package/dist/account/links.js +137 -0
  13. package/dist/account/manager.d.ts +96 -0
  14. package/dist/account/manager.js +976 -0
  15. package/dist/account/native-lock.d.ts +6 -0
  16. package/dist/account/native-lock.js +26 -0
  17. package/dist/account/native-store.d.ts +32 -0
  18. package/dist/account/native-store.js +162 -0
  19. package/dist/account/profile.d.ts +4 -0
  20. package/dist/account/profile.js +46 -0
  21. package/dist/account/store.d.ts +34 -0
  22. package/dist/account/store.js +118 -0
  23. package/dist/account/types.d.ts +138 -0
  24. package/dist/account/types.js +2 -0
  25. package/dist/desktop-web/http-api.d.ts +4 -0
  26. package/dist/desktop-web/http-api.js +12 -4
  27. package/dist/environment-identity.d.ts +1 -1
  28. package/dist/environment-identity.js +2 -1
  29. package/dist/headless-worker-env.d.ts +2 -0
  30. package/dist/headless-worker-env.js +15 -0
  31. package/dist/hub/approval-lease.js +6 -1
  32. package/dist/hub/configuration.d.ts +11 -0
  33. package/dist/hub/configuration.js +34 -1
  34. package/dist/hub/model-parameter-metadata.d.ts +26 -0
  35. package/dist/hub/model-parameter-metadata.js +92 -0
  36. package/dist/hub/run-replay.js +1 -1
  37. package/dist/hub/session-management.js +1 -1
  38. package/dist/index.account.d.ts +12 -0
  39. package/dist/index.account.js +12 -0
  40. package/dist/index.mobile-remote.d.ts +2 -0
  41. package/dist/index.mobile-remote.js +1 -0
  42. package/dist/index.worker.d.ts +1 -0
  43. package/dist/index.worker.js +1 -0
  44. package/dist/mobile-remote/account-relay-access.d.ts +59 -0
  45. package/dist/mobile-remote/account-relay-access.js +174 -0
  46. package/dist/mobile-remote/approval-clock.d.ts +3 -0
  47. package/dist/mobile-remote/approval-clock.js +15 -0
  48. package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
  49. package/dist/mobile-remote/codex-room-agent.js +17 -1
  50. package/dist/mobile-remote/mobile-chat-turn.d.ts +4 -0
  51. package/dist/mobile-remote/mobile-chat-turn.js +3 -0
  52. package/dist/mobile-remote/mobile-client-event-validator.js +47 -2
  53. package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
  54. package/dist/mobile-remote/mobile-upload-service.js +25 -0
  55. package/dist/mobile-remote/remote-host-manager.d.ts +21 -4
  56. package/dist/mobile-remote/remote-host-manager.js +142 -25
  57. package/dist/mobile-remote/resident-agent.d.ts +14 -0
  58. package/dist/mobile-remote/resident-agent.js +38 -11
  59. package/dist/mobile-remote/room-history.d.ts +7 -0
  60. package/dist/mobile-remote/room-history.js +56 -1
  61. package/dist/mobile-remote/room-manager.d.ts +216 -9
  62. package/dist/mobile-remote/room-manager.js +700 -65
  63. package/dist/mobile-remote/room-process-stop.d.ts +15 -0
  64. package/dist/mobile-remote/room-process-stop.js +41 -0
  65. package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
  66. package/dist/mobile-remote/trusted-device-store.js +54 -1
  67. package/dist/mobile-remote/turn-queue.d.ts +183 -0
  68. package/dist/mobile-remote/turn-queue.js +422 -0
  69. package/dist/mobile-remote/types.d.ts +1 -1
  70. package/dist/panels/agent-task-host.js +6 -12
  71. package/dist/panels/agent-task-service.d.ts +2 -0
  72. package/dist/panels/agent-task-service.js +7 -0
  73. package/dist/panels/browser-capture.d.ts +1 -15
  74. package/dist/panels/browser-capture.js +1 -35
  75. package/dist/panels/directory-picker.d.ts +48 -0
  76. package/dist/panels/directory-picker.js +247 -0
  77. package/dist/panels/http.d.ts +15 -15
  78. package/dist/panels/management-http.d.ts +9 -9
  79. package/dist/panels/process-service.d.ts +5 -4
  80. package/dist/panels/process-service.js +54 -16
  81. package/dist/panels/runtime.js +148 -30
  82. package/dist/panels/tool-jobs-storage.d.ts +4 -0
  83. package/dist/panels/tool-jobs-storage.js +111 -34
  84. package/dist/panels/types.d.ts +1 -97
  85. package/dist/remote-relay/connector.d.ts +8 -0
  86. package/dist/remote-relay/connector.js +29 -2
  87. package/dist/remote-relay/protocol.d.ts +21 -0
  88. package/dist/remote-relay/protocol.js +31 -0
  89. package/dist/serve/cli.js +6 -0
  90. package/dist/serve/headless-server.js +24 -19
  91. package/dist/sessions-service.d.ts +4 -0
  92. package/dist/sessions-service.js +14 -1
  93. package/package.json +10 -6
@@ -44,6 +44,9 @@ export async function dispatchMobileChatTurn(input) {
44
44
  id: input.runId,
45
45
  params: {
46
46
  task: text,
47
+ ...(input.model ? { model: input.model } : {}),
48
+ ...(input.configuredModel ? { configuredModel: { ...input.configuredModel } } : {}),
49
+ ...(input.workspaceProfile ? { workspaceProfile: input.workspaceProfile } : {}),
47
50
  ...(input.modelTraceSource && /^panel:[a-z][a-z0-9-]{0,63}$/.test(input.modelTraceSource)
48
51
  ? { modelTraceSource: input.modelTraceSource }
49
52
  : {}),
@@ -28,8 +28,14 @@ export function parseMobileClientEvent(value) {
28
28
  break;
29
29
  case "chat.send":
30
30
  valid =
31
- optionalMember(event.permissionMode, PERMISSION_MODES) &&
31
+ event.configuredModel === undefined &&
32
+ event.model === undefined &&
33
+ optionalMember(event.permissionMode, PERMISSION_MODES) &&
32
34
  boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
35
+ (event.nativeConnectionId === undefined ||
36
+ (boundedTrimmedString(event.nativeConnectionId, MAX_ID_LENGTH) &&
37
+ !/[\r\n\0]/.test(event.nativeConnectionId))) &&
38
+ !(event.modelKey !== undefined && event.nativeConnectionId !== undefined) &&
33
39
  (event.modelKey === undefined ||
34
40
  (boundedString(event.modelKey, MAX_ID_LENGTH) &&
35
41
  /^(codex|claude-code)\/[^\r\n\0]*$/.test(event.modelKey))) &&
@@ -38,6 +44,7 @@ export function parseMobileClientEvent(value) {
38
44
  (typeof event.modelTraceSource === "string" &&
39
45
  /^panel:[a-z][a-z0-9-]{0,63}$/.test(event.modelTraceSource))) &&
40
46
  optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
47
+ optionalQueueFlag(event.queue) &&
41
48
  optionalAttachments(event.attachments);
42
49
  break;
43
50
  case "attachment.upload.begin":
@@ -131,6 +138,14 @@ export function parseMobileClientEvent(value) {
131
138
  optionalMember(event.scope, SCOPES) &&
132
139
  optionalMember(event.pathScope, PATH_SCOPES);
133
140
  break;
141
+ case "approval.keepWaiting":
142
+ valid =
143
+ boundedString(event.approvalId, MAX_ID_LENGTH) &&
144
+ optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
145
+ optionalBoundedString(event.connectionId, MAX_ID_LENGTH) &&
146
+ (event.generation === undefined ||
147
+ (Number.isSafeInteger(event.generation) && event.generation >= 0));
148
+ break;
134
149
  case "session.list":
135
150
  valid =
136
151
  optionalBoundedString(event.requestId, 128) &&
@@ -190,8 +205,23 @@ export function parseMobileClientEvent(value) {
190
205
  boundedString(event.roomId, MAX_ID_LENGTH) &&
191
206
  boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
192
207
  optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
208
+ optionalQueueFlag(event.queue) &&
193
209
  optionalAttachments(event.attachments);
194
210
  break;
211
+ case "queue.sync":
212
+ case "queue.clear":
213
+ case "queue.resume":
214
+ valid =
215
+ queueTarget(event.target) &&
216
+ Object.keys(event).every((key) => key === "type" || key === "target");
217
+ break;
218
+ case "queue.cancel":
219
+ case "queue.retry":
220
+ valid =
221
+ queueTarget(event.target) &&
222
+ boundedString(event.entryId, MAX_ID_LENGTH) &&
223
+ Object.keys(event).every((key) => ["type", "target", "entryId"].includes(key));
224
+ break;
195
225
  case "room.history":
196
226
  valid =
197
227
  boundedString(event.roomId, MAX_ID_LENGTH) &&
@@ -221,7 +251,8 @@ export function parseMobileClientEvent(value) {
221
251
  boundedString(event.sessionId, MAX_ID_LENGTH) &&
222
252
  boundedPath(event.cwd) &&
223
253
  member(event.mode, PERMISSION_MODES) &&
224
- optionalMember(event.kind, KINDS);
254
+ optionalMember(event.kind, KINDS) &&
255
+ optionalBoundedString(event.requestId, MAX_ID_LENGTH);
225
256
  break;
226
257
  case "ccRoom.subscribeTranscript":
227
258
  valid =
@@ -291,6 +322,20 @@ function optionalPositiveInteger(value) {
291
322
  function isBoundedHistoryLimit(value) {
292
323
  return isPositiveInteger(value) && value <= MAX_HISTORY_LIMIT;
293
324
  }
325
+ function optionalQueueFlag(value) {
326
+ return value === undefined || value === true;
327
+ }
328
+ function queueTarget(value) {
329
+ if (!isRecord(value))
330
+ return false;
331
+ const keys = Object.keys(value);
332
+ if (value.kind === "session")
333
+ return (boundedTrimmedString(value.sessionId, MAX_ID_LENGTH) &&
334
+ keys.every((key) => key === "kind" || key === "sessionId"));
335
+ return (value.kind === "room" &&
336
+ boundedTrimmedString(value.roomId, MAX_ID_LENGTH) &&
337
+ keys.every((key) => key === "kind" || key === "roomId"));
338
+ }
294
339
  function optionalAttachments(value) {
295
340
  return (value === undefined ||
296
341
  (Array.isArray(value) && value.length <= MAX_MOBILE_ATTACHMENTS && value.every(isAttachment)));
@@ -3,6 +3,8 @@ import type { MobileImageBase } from "./types.js";
3
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
+ /** Upper bound for an image waiting behind a long turn in the Host FIFO. */
7
+ export declare const MOBILE_UPLOAD_QUEUE_RETENTION_MS: number;
6
8
  export declare const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
7
9
  export interface MobileUploadServiceOptions {
8
10
  rootDir: string;
@@ -43,6 +45,13 @@ export declare class MobileUploadService {
43
45
  private receivePut;
44
46
  /** Atomically move a ready upload into a request-owned lease. */
45
47
  claim(deviceId: string, uploadId: string): ClaimedMobileUpload;
48
+ /**
49
+ * Keep a ready upload alive for a Host-queued turn that has not run yet. The
50
+ * extension is bounded; the per-device ticket cap still limits retained files.
51
+ */
52
+ retain(deviceId: string, uploadId: string, durationMs?: number): void;
53
+ /** Drop an unclaimed upload of a cancelled queued turn immediately. */
54
+ discard(deviceId: string, uploadId: string): Promise<void>;
46
55
  /** Release a failed staging/dispatch attempt for a bounded retry. */
47
56
  release(deviceId: string, uploadId: string, claimId: string): Promise<void>;
48
57
  /** Finalize a successful turn; only the active claim owner may consume it. */
@@ -8,6 +8,8 @@ import { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
8
8
  export { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
9
9
  export const MOBILE_UPLOAD_TTL_MS = 5 * 60 * 1000;
10
10
  export const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
11
+ /** Upper bound for an image waiting behind a long turn in the Host FIFO. */
12
+ export const MOBILE_UPLOAD_QUEUE_RETENTION_MS = 12 * 60 * 60 * 1000;
11
13
  export const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
12
14
  const IMAGE_MIMES = new Set([
13
15
  "image/png",
@@ -286,6 +288,29 @@ export class MobileUploadService {
286
288
  sha256: record.sha256,
287
289
  };
288
290
  }
291
+ /**
292
+ * Keep a ready upload alive for a Host-queued turn that has not run yet. The
293
+ * extension is bounded; the per-device ticket cap still limits retained files.
294
+ */
295
+ retain(deviceId, uploadId, durationMs = MOBILE_UPLOAD_QUEUE_RETENTION_MS) {
296
+ if (this.revokedDevices.has(deviceId))
297
+ throw new Error("upload device was revoked");
298
+ const record = this.uploads.get(uploadId);
299
+ if (!record || record.expiresAt <= this.now() || record.deviceId !== deviceId)
300
+ throw new Error("upload is not ready or no longer exists");
301
+ if (record.status !== "ready")
302
+ throw new Error("upload is already claimed or not ready");
303
+ const until = this.now() + Math.min(durationMs, MOBILE_UPLOAD_QUEUE_RETENTION_MS);
304
+ if (until > record.expiresAt)
305
+ record.expiresAt = until;
306
+ }
307
+ /** Drop an unclaimed upload of a cancelled queued turn immediately. */
308
+ async discard(deviceId, uploadId) {
309
+ const record = this.uploads.get(uploadId);
310
+ if (!record || record.deviceId !== deviceId || record.status === "claimed")
311
+ return;
312
+ await this.expire(uploadId, record);
313
+ }
289
314
  /** Release a failed staging/dispatch attempt for a bounded retry. */
290
315
  async release(deviceId, uploadId, claimId) {
291
316
  const record = this.assertClaimOwner(deviceId, uploadId, claimId);
@@ -6,6 +6,7 @@ import type { MobileServerEvent } from "./types.js";
6
6
  import type { MobileUploadService } from "./mobile-upload-service.js";
7
7
  import type { DesktopWebHttpApi } from "../desktop-web/http-api.js";
8
8
  import type { RelayLocalTarget } from "../remote-relay/connector.js";
9
+ import { type AccountRelayAccessProvider } from "./account-relay-access.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
@@ -22,11 +23,11 @@ export interface RemoteHostStartOptions {
22
23
  * "lan" (default) keeps the existing LAN behaviour unchanged. "tunnel" binds
23
24
  * 127.0.0.1 (cloudflared connects to loopback) and inserts a passcode gate in
24
25
  * front of ordinary HTTP routes and the WS upgrade. Host-verified Panel asset
25
- * capabilities use their own short-lived authorization. "relay" also requires an
26
- * initialized passcode and a stable HTTPS origin before pairing/WS upgrades.
26
+ * capabilities use their own short-lived authorization. "relay" requires a
27
+ * passcode or main-owned account handoff provider and a stable HTTPS origin.
27
28
  */
28
29
  mode?: "lan" | "tunnel" | "relay";
29
- /** Required and initialized in relay mode; public gate for tunnel mode too. */
30
+ /** Public passcode gate; account handoffs may independently authorize relay mode. */
30
31
  passcode?: AccessPasscode;
31
32
  }
32
33
  export interface RemoteHostStarted {
@@ -44,6 +45,10 @@ export interface RemoteHostManagerOptions {
44
45
  outputJournalPageFrames?: 2048;
45
46
  /** Selected-session Host implements verified display views and bounded detail parts. */
46
47
  historyView?: boolean;
48
+ /** The attached command Host resolves native text connections per authorized Session. */
49
+ nativeModelSelection?: boolean;
50
+ /** The attached command Host owns a pending-turn FIFO (queue.* events, chat.queued). */
51
+ turnQueue?: boolean;
47
52
  onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
48
53
  /**
49
54
  * Absolute path to the built mobile app (out/mobile). Defaults to the
@@ -83,11 +88,23 @@ export declare class RemoteHostManager extends EventEmitter {
83
88
  private passcode?;
84
89
  private socketAccessValidity;
85
90
  private accessSweep?;
91
+ private readonly accountAccess;
92
+ private accountSocketDevices;
86
93
  /** Where the built mobile app lives (out/mobile). */
87
94
  private readonly mobileRootDir;
88
95
  /** Dev proxy target for /mobile (HMR); undefined in prod. */
89
96
  private readonly mobileDevUrl?;
97
+ private turnQueueEnabled;
90
98
  constructor(opts: RemoteHostManagerOptions);
99
+ /** Install main-owned directory redemption; replacing it synchronously revokes old sessions. */
100
+ setAccountRelayAccess(provider?: AccountRelayAccessProvider): void;
101
+ /** The attached command Host installed a pending-turn FIFO; advertise it on (re)auth. */
102
+ advertiseTurnQueue(): void;
103
+ /** A paired (or live account) device that has not been revoked or removed. */
104
+ isTrustedDevice(deviceId: string): boolean;
105
+ private get turnQueueAdvertised();
106
+ private relayRequestOrigin;
107
+ private relayJson;
91
108
  /** Device ids with at least one live socket right now. */
92
109
  onlineDeviceIds(): string[];
93
110
  /** Register a live socket for a device; emits `online-change` only when the
@@ -118,7 +135,7 @@ export declare class RemoteHostManager extends EventEmitter {
118
135
  * trusted-device store (which rejects revoked devices), so the remote host
119
136
  * never executes tools itself — it only gates transport.
120
137
  */
121
- handleClientEvent(input: unknown): MobileServerEvent | undefined;
138
+ handleClientEvent(input: unknown, accountDeviceId?: string): MobileServerEvent | undefined;
122
139
  /** Broadcast a server event to every connected, authenticated mobile socket. */
123
140
  broadcast(event: MobileServerEvent): void;
124
141
  /** Send a server event to ONLY the sockets belonging to one device. Used for
@@ -1,3 +1,4 @@
1
+ import { stampApprovalClock } from "./approval-clock.js";
1
2
  import { createServer } from "node:http";
2
3
  import { networkInterfaces } from "node:os";
3
4
  import { EventEmitter } from "node:events";
@@ -8,6 +9,7 @@ import { serveMobile } from "./mobile-static.js";
8
9
  import { PairingTokenManager } from "./pairing.js";
9
10
  import { parseMobileClientEvent } from "./mobile-client-event-validator.js";
10
11
  import { isRelayOrigin } from "../remote-relay/protocol.js";
12
+ import { AccountRelayAccessGate, readRelayHandoff, } from "./account-relay-access.js";
11
13
  /** Outbound bytes one paired socket may leave unread before it is retired. */
12
14
  const MAX_SOCKET_BACKLOG_BYTES = 4 * 1024 * 1024;
13
15
  /** The query suffix ("" or "?…") when the raw request target is exactly the
@@ -69,18 +71,62 @@ export class RemoteHostManager extends EventEmitter {
69
71
  passcode;
70
72
  socketAccessValidity = new WeakMap();
71
73
  accessSweep;
74
+ accountAccess;
75
+ accountSocketDevices = new WeakMap();
72
76
  /** Where the built mobile app lives (out/mobile). */
73
77
  mobileRootDir;
74
78
  /** Dev proxy target for /mobile (HMR); undefined in prod. */
75
79
  mobileDevUrl;
80
+ turnQueueEnabled = false;
76
81
  constructor(opts) {
77
82
  super();
78
83
  this.opts = opts;
84
+ this.accountAccess = new AccountRelayAccessGate(opts.devices, (id) => this.revokeDevice(id));
79
85
  // Bundled main is out/main/index.mjs → mobile app is the sibling out/mobile.
80
86
  const here = dirname(fileURLToPath(import.meta.url));
81
87
  this.mobileRootDir = opts.mobileRootDir ?? resolve(here, "../mobile");
82
88
  this.mobileDevUrl = opts.mobileDevUrl ?? process.env.MOBILE_DEV_URL;
83
89
  }
90
+ /** Install main-owned directory redemption; replacing it synchronously revokes old sessions. */
91
+ setAccountRelayAccess(provider) {
92
+ this.accountAccess.setProvider(provider);
93
+ }
94
+ /** The attached command Host installed a pending-turn FIFO; advertise it on (re)auth. */
95
+ advertiseTurnQueue() {
96
+ this.turnQueueEnabled = true;
97
+ }
98
+ /** A paired (or live account) device that has not been revoked or removed. */
99
+ isTrustedDevice(deviceId) {
100
+ try {
101
+ return this.opts.devices
102
+ .listDevices()
103
+ .some((device) => device.id === deviceId && !device.revokedAt);
104
+ }
105
+ catch {
106
+ // An unreadable store proves nothing; fail closed.
107
+ return false;
108
+ }
109
+ }
110
+ get turnQueueAdvertised() {
111
+ return this.turnQueueEnabled || this.opts.turnQueue === true;
112
+ }
113
+ relayRequestOrigin(request, requireOrigin = false) {
114
+ return !!(this.publicBaseUrl &&
115
+ request.headers.host === new URL(this.publicBaseUrl).host &&
116
+ (request.headers.origin === this.publicBaseUrl ||
117
+ (!requireOrigin && request.headers.origin === undefined)) &&
118
+ request.headers["sec-fetch-site"] !== "cross-site");
119
+ }
120
+ relayJson(response, status, body) {
121
+ if (response.writableEnded || response.destroyed)
122
+ return;
123
+ response.writeHead(status, {
124
+ "content-type": "application/json; charset=utf-8",
125
+ "cache-control": "no-store",
126
+ "x-content-type-options": "nosniff",
127
+ });
128
+ response.end(JSON.stringify(body));
129
+ }
84
130
  /** Device ids with at least one live socket right now. */
85
131
  onlineDeviceIds() {
86
132
  return [...this.onlineCounts.keys()];
@@ -129,11 +175,13 @@ export class RemoteHostManager extends EventEmitter {
129
175
  async startOnce(options) {
130
176
  const relay = options.mode === "relay";
131
177
  const publicAccess = relay || options.mode === "tunnel";
132
- if (publicAccess && !options.passcode?.isSet()) {
178
+ if (publicAccess && !options.passcode?.isSet() && !(relay && this.accountAccess.available)) {
133
179
  throw new Error(`${relay ? "Relay" : "Tunnel"} mode requires an initialized access passcode`);
134
180
  }
135
181
  if (relay)
136
182
  this.publicBaseUrl = undefined;
183
+ // Keep the gate object even when account auto-start precedes passcode setup.
184
+ // Its current configuration is consulted for each HTTP/WS admission.
137
185
  this.passcode = publicAccess ? options.passcode : undefined;
138
186
  const gate = this.passcode;
139
187
  this.opts.webApi?.start();
@@ -148,11 +196,47 @@ export class RemoteHostManager extends EventEmitter {
148
196
  req.resume();
149
197
  return;
150
198
  }
199
+ if (relay && pathname === "/api/v1/relay/session") {
200
+ if (req.method !== "POST" ||
201
+ !this.accountAccess.available ||
202
+ !this.relayRequestOrigin(req, true)) {
203
+ this.relayJson(res, 403, { error: "Account relay session is unavailable" });
204
+ req.resume();
205
+ return;
206
+ }
207
+ const lifetime = this.relayLifetime;
208
+ void readRelayHandoff(req)
209
+ .then((handoff) => {
210
+ if (!lifetime || lifetime.signal.aborted || lifetime !== this.relayLifetime)
211
+ throw new Error("Relay Host stopped");
212
+ return this.accountAccess.exchange(handoff);
213
+ })
214
+ .then((session) => this.relayJson(res, 200, session))
215
+ .catch(() => this.relayJson(res, 401, { error: "Account relay handoff expired or invalid" }));
216
+ return;
217
+ }
151
218
  const serve = () => {
152
219
  // All ordinary routes retain the tunnel passcode gate. Only a Host-verified
153
220
  // short-lived Panel asset capability may use independent authorization.
154
- if (gate && !gate.gate(req, res))
221
+ const account = relay && this.relayRequestOrigin(req) ? this.accountAccess.capture(req) : undefined;
222
+ if (!account) {
223
+ if (relay && (req.headers["x-codeshell-host-access"] !== undefined || !gate?.isSet())) {
224
+ this.relayJson(res, 401, { error: "Account relay authorization is required" });
225
+ req.resume();
226
+ return;
227
+ }
228
+ if (gate && !gate.gate(req, res))
229
+ return;
230
+ }
231
+ if (relay && pathname === "/api/v1/relay/projects") {
232
+ if (req.method !== "GET" || !account) {
233
+ this.relayJson(res, 401, { error: "Account relay authorization is required" });
234
+ req.resume();
235
+ return;
236
+ }
237
+ void this.accountAccess.projects(account).then((projects) => this.relayJson(res, 200, projects), () => this.relayJson(res, 401, { error: "Account relay authorization expired" }));
155
238
  return;
239
+ }
156
240
  if (req.url === "/health") {
157
241
  res.writeHead(200, { "content-type": "application/json" });
158
242
  res.end(JSON.stringify({ ok: true }));
@@ -166,7 +250,11 @@ export class RemoteHostManager extends EventEmitter {
166
250
  return;
167
251
  }
168
252
  void this.opts.webApi
169
- .handle(req, res, { baseUrl, isAccessAuthorized: gate?.captureValidity() })
253
+ .handle(req, res, {
254
+ baseUrl,
255
+ isAccessAuthorized: account?.isAuthorized ?? gate?.captureValidity(),
256
+ ...(account ? { accessDeviceId: account.deviceId } : {}),
257
+ })
170
258
  .then((handled) => {
171
259
  if (!handled && !res.writableEnded) {
172
260
  res.writeHead(404);
@@ -234,13 +322,13 @@ export class RemoteHostManager extends EventEmitter {
234
322
  // In tunnel mode we must gate the WS handshake too. `noServer` lets us run
235
323
  // the passcode check during the HTTP `upgrade` event before completing the
236
324
  // handshake; in lan mode behaviour is unchanged (the same path/server bind).
237
- this.wss = gate
325
+ this.wss = publicAccess
238
326
  ? new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 })
239
327
  : new WebSocketServer({ server, path: "/ws", maxPayload: 1024 * 1024 });
240
328
  // ws re-emits its HTTP server's bind errors. Without an error listener a
241
329
  // handled EADDRINUSE rejection still becomes an uncaught EventEmitter error.
242
330
  this.wss.on("error", (error) => this.emit("host-error", error));
243
- if (gate) {
331
+ if (publicAccess) {
244
332
  server.on("upgrade", (req, socket, head) => {
245
333
  let isWebSocketPath = false;
246
334
  try {
@@ -253,21 +341,23 @@ export class RemoteHostManager extends EventEmitter {
253
341
  socket.destroy();
254
342
  return;
255
343
  }
256
- if (relay &&
257
- (!this.publicBaseUrl ||
258
- req.headers.origin !== this.publicBaseUrl ||
259
- req.headers.host !== new URL(this.publicBaseUrl).host)) {
344
+ if (relay && !this.relayRequestOrigin(req, true)) {
260
345
  socket.end("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
261
346
  return;
262
347
  }
263
- if (!gate.allows(req)) {
348
+ const account = relay ? this.accountAccess.capture(req) : undefined;
349
+ if (!account &&
350
+ ((relay && req.headers["x-codeshell-host-access"] !== undefined) ||
351
+ !gate?.allows(req))) {
264
352
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
265
353
  socket.destroy();
266
354
  return;
267
355
  }
268
- const isAccessAuthorized = gate.captureValidity();
356
+ const isAccessAuthorized = account?.isAuthorized ?? gate.captureValidity();
269
357
  this.wss.handleUpgrade(req, socket, head, (ws) => {
270
358
  this.socketAccessValidity.set(ws, isAccessAuthorized);
359
+ if (account)
360
+ this.accountSocketDevices.set(ws, account.deviceId);
271
361
  this.wss.emit("connection", ws, req);
272
362
  });
273
363
  });
@@ -298,12 +388,19 @@ export class RemoteHostManager extends EventEmitter {
298
388
  this.sendBounded(ws, JSON.stringify({ type: "error", message: "Invalid client event" }));
299
389
  return;
300
390
  }
391
+ const accountDevice = this.accountSocketDevices.get(ws);
392
+ if (accountDevice &&
393
+ (event.type === "pair.complete" ||
394
+ (event.type === "auth.device" && event.deviceId !== accountDevice))) {
395
+ this.sendBounded(ws, JSON.stringify({ type: "auth.failed", message: "Account device mismatch" }));
396
+ return;
397
+ }
301
398
  // Pairing/auth are handled inline and bind device identity to this
302
399
  // socket. Everything else is gated: an unauthenticated socket cannot
303
400
  // send chat/approval/run/job events (design §6.1).
304
401
  let reply;
305
402
  try {
306
- reply = this.handleClientEvent(event);
403
+ reply = this.handleClientEvent(event, accountDevice);
307
404
  }
308
405
  catch {
309
406
  this.sendBounded(ws, JSON.stringify({ type: "error", message: "Client event failed" }));
@@ -390,7 +487,7 @@ export class RemoteHostManager extends EventEmitter {
390
487
  url: `http://${bindHost}:${port}`,
391
488
  mode: options.mode ?? "lan",
392
489
  };
393
- if (gate) {
490
+ if (publicAccess) {
394
491
  const sockets = this.wss;
395
492
  // Idle public tabs must also relinquish viewer-owned state after rotation.
396
493
  // Traffic is checked synchronously in both directions, without rehashing
@@ -453,7 +550,7 @@ export class RemoteHostManager extends EventEmitter {
453
550
  * trusted-device store (which rejects revoked devices), so the remote host
454
551
  * never executes tools itself — it only gates transport.
455
552
  */
456
- handleClientEvent(input) {
553
+ handleClientEvent(input, accountDeviceId) {
457
554
  const event = parseMobileClientEvent(input);
458
555
  if (!event)
459
556
  return { type: "error", message: "Invalid client event" };
@@ -474,13 +571,19 @@ export class RemoteHostManager extends EventEmitter {
474
571
  return {
475
572
  type: "pair.ok",
476
573
  device,
477
- ...(this.opts.outputJournal
574
+ ...(this.opts.outputJournal || this.opts.nativeModelSelection || this.turnQueueAdvertised
478
575
  ? {
479
576
  capabilities: {
480
- outputJournal: 1,
481
- ...(this.opts.historyView ? { historyView: 1 } : {}),
482
- ...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
483
- ...(this.opts.outputJournalPageFrames === 2048
577
+ ...(this.opts.outputJournal ? { outputJournal: 1 } : {}),
578
+ ...(this.opts.nativeModelSelection ? { nativeModelSelection: 1 } : {}),
579
+ ...(this.turnQueueAdvertised ? { turnQueue: 1 } : {}),
580
+ ...(this.opts.outputJournal && this.opts.historyView
581
+ ? { historyView: 1 }
582
+ : {}),
583
+ ...(this.opts.outputJournal && this.opts.legacyBasePages
584
+ ? { legacyBasePages: 1 }
585
+ : {}),
586
+ ...(this.opts.outputJournal && this.opts.outputJournalPageFrames === 2048
484
587
  ? { outputJournalPageFrames: 2048 }
485
588
  : {}),
486
589
  },
@@ -489,6 +592,8 @@ export class RemoteHostManager extends EventEmitter {
489
592
  };
490
593
  }
491
594
  if (event.type === "auth.device") {
595
+ if (this.opts.devices.isEphemeralDevice(event.deviceId) && event.deviceId !== accountDeviceId)
596
+ return { type: "auth.failed", message: "Account device requires its Host access grant" };
492
597
  let device;
493
598
  try {
494
599
  device = this.opts.devices.authenticate(event.deviceId, event.secretHash);
@@ -501,13 +606,19 @@ export class RemoteHostManager extends EventEmitter {
501
606
  return {
502
607
  type: "auth.ok",
503
608
  device,
504
- ...(this.opts.outputJournal
609
+ ...(this.opts.outputJournal || this.opts.nativeModelSelection || this.turnQueueAdvertised
505
610
  ? {
506
611
  capabilities: {
507
- outputJournal: 1,
508
- ...(this.opts.historyView ? { historyView: 1 } : {}),
509
- ...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
510
- ...(this.opts.outputJournalPageFrames === 2048
612
+ ...(this.opts.outputJournal ? { outputJournal: 1 } : {}),
613
+ ...(this.opts.nativeModelSelection ? { nativeModelSelection: 1 } : {}),
614
+ ...(this.turnQueueAdvertised ? { turnQueue: 1 } : {}),
615
+ ...(this.opts.outputJournal && this.opts.historyView
616
+ ? { historyView: 1 }
617
+ : {}),
618
+ ...(this.opts.outputJournal && this.opts.legacyBasePages
619
+ ? { legacyBasePages: 1 }
620
+ : {}),
621
+ ...(this.opts.outputJournal && this.opts.outputJournalPageFrames === 2048
511
622
  ? { outputJournalPageFrames: 2048 }
512
623
  : {}),
513
624
  },
@@ -548,6 +659,7 @@ export class RemoteHostManager extends EventEmitter {
548
659
  }
549
660
  /** Preserve the full worker envelope while retaining exact authenticated-tab routing. */
550
661
  sendRawToViewer(viewerId, payload) {
662
+ payload = stampApprovalClock(payload);
551
663
  for (const client of this.wss?.clients ?? []) {
552
664
  if (client.readyState === client.OPEN &&
553
665
  this.authed.has(client) &&
@@ -561,6 +673,7 @@ export class RemoteHostManager extends EventEmitter {
561
673
  }
562
674
  /** Send a raw JSON-RPC worker line to ONLY one authenticated device. */
563
675
  sendRawToDevice(deviceId, payload) {
676
+ payload = stampApprovalClock(payload);
564
677
  for (const client of this.wss?.clients ?? []) {
565
678
  if (client.readyState === client.OPEN && this.authed.get(client) === deviceId) {
566
679
  this.sendBounded(client, payload);
@@ -571,6 +684,7 @@ export class RemoteHostManager extends EventEmitter {
571
684
  * every authenticated mobile socket. Unauthenticated sockets are skipped so
572
685
  * a half-paired client never sees session output. */
573
686
  broadcastRaw(payload) {
687
+ payload = stampApprovalClock(payload);
574
688
  for (const client of this.wss?.clients ?? []) {
575
689
  if (client.readyState === client.OPEN && this.authed.has(client)) {
576
690
  this.sendBounded(client, payload);
@@ -604,7 +718,7 @@ export class RemoteHostManager extends EventEmitter {
604
718
  this.releaseSocket(client);
605
719
  // Distinct from paired-device rejection (4401/4403): the browser keeps its
606
720
  // identity and offers a fresh HTTP challenge instead of silently retrying.
607
- client.close(4407, "Access passcode changed");
721
+ client.close(4407, "Host access expired");
608
722
  const deadline = setTimeout(() => client.terminate(), 1000);
609
723
  deadline.unref();
610
724
  client.once("close", () => clearTimeout(deadline));
@@ -637,6 +751,8 @@ export class RemoteHostManager extends EventEmitter {
637
751
  }
638
752
  /** Call after revoking/removing a trusted device to release both transports immediately. */
639
753
  revokeDevice(deviceId) {
754
+ if (this.accountAccess.revokeDevice(deviceId))
755
+ return;
640
756
  void this.opts.uploads?.revokeDevice(deviceId).catch((error) => {
641
757
  this.emit("host-error", error instanceof Error ? error.message : String(error));
642
758
  });
@@ -652,6 +768,7 @@ export class RemoteHostManager extends EventEmitter {
652
768
  // Listeners synchronously fence and destroy outbound sockets before the
653
769
  // loopback listener can close or its port can be reused by another Host.
654
770
  this.relayLifetime?.abort();
771
+ this.accountAccess.clear();
655
772
  if (this.stopping)
656
773
  return this.stopping;
657
774
  const attempt = Promise.resolve()
@@ -87,7 +87,13 @@ export interface ResidentAgentOptions {
87
87
  export declare class ResidentAgentProcess {
88
88
  private readonly opts;
89
89
  private child?;
90
+ /** The most recently spawned process, kept after stop() so a caller can
91
+ * wait for it to actually exit before starting a replacement. */
92
+ private lastChild?;
93
+ private spawned?;
90
94
  constructor(opts: ResidentAgentOptions);
95
+ /** Exact argv for one resident process. Pure → unit-testable. */
96
+ static argsFor(opts: Pick<ResidentAgentOptions, "permissionMode" | "resumeSessionId" | "appendSystemPrompt">): string[];
91
97
  start(): void;
92
98
  /** Feed one user turn. Returns false if the process isn't running. */
93
99
  send(text: string): boolean;
@@ -95,4 +101,12 @@ export declare class ResidentAgentProcess {
95
101
  respondControl(requestId: string, decision: ControlDecision): void;
96
102
  isRunning(): boolean;
97
103
  stop(): void;
104
+ /** Resolves once the spawned process exists; rejects when the spawn failed. */
105
+ whenStarted(): Promise<void>;
106
+ /**
107
+ * Stop, then resolve true only after the last spawned process has exited.
108
+ * A process already retired by a pipe error is still waited for: it may be
109
+ * alive for up to the SIGKILL grace period.
110
+ */
111
+ stopAndWait(timeoutMs: number): Promise<boolean>;
98
112
  }