@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.
- package/dist/account/cli.d.ts +23 -0
- package/dist/account/cli.js +249 -0
- package/dist/account/cloud-contract.d.ts +39 -0
- package/dist/account/cloud-contract.js +134 -0
- package/dist/account/cloud-types.d.ts +47 -0
- package/dist/account/cloud-types.js +1 -0
- package/dist/account/feature-service.d.ts +47 -0
- package/dist/account/feature-service.js +184 -0
- package/dist/account/http.d.ts +17 -0
- package/dist/account/http.js +158 -0
- package/dist/account/links.d.ts +13 -0
- package/dist/account/links.js +137 -0
- package/dist/account/manager.d.ts +96 -0
- package/dist/account/manager.js +976 -0
- package/dist/account/native-lock.d.ts +6 -0
- package/dist/account/native-lock.js +26 -0
- package/dist/account/native-store.d.ts +32 -0
- package/dist/account/native-store.js +162 -0
- package/dist/account/profile.d.ts +4 -0
- package/dist/account/profile.js +46 -0
- package/dist/account/store.d.ts +34 -0
- package/dist/account/store.js +118 -0
- package/dist/account/types.d.ts +138 -0
- package/dist/account/types.js +2 -0
- package/dist/desktop-web/http-api.d.ts +4 -0
- package/dist/desktop-web/http-api.js +12 -4
- package/dist/environment-identity.d.ts +1 -1
- package/dist/environment-identity.js +2 -1
- package/dist/headless-worker-env.d.ts +2 -0
- package/dist/headless-worker-env.js +15 -0
- package/dist/hub/approval-lease.js +6 -1
- package/dist/hub/configuration.d.ts +11 -0
- package/dist/hub/configuration.js +34 -1
- package/dist/hub/model-parameter-metadata.d.ts +26 -0
- package/dist/hub/model-parameter-metadata.js +92 -0
- package/dist/hub/run-replay.js +1 -1
- package/dist/hub/session-management.js +1 -1
- package/dist/index.account.d.ts +12 -0
- package/dist/index.account.js +12 -0
- package/dist/index.mobile-remote.d.ts +2 -0
- package/dist/index.mobile-remote.js +1 -0
- package/dist/index.worker.d.ts +1 -0
- package/dist/index.worker.js +1 -0
- package/dist/mobile-remote/account-relay-access.d.ts +59 -0
- package/dist/mobile-remote/account-relay-access.js +174 -0
- package/dist/mobile-remote/approval-clock.d.ts +3 -0
- package/dist/mobile-remote/approval-clock.js +15 -0
- package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
- package/dist/mobile-remote/codex-room-agent.js +17 -1
- package/dist/mobile-remote/mobile-chat-turn.d.ts +4 -0
- package/dist/mobile-remote/mobile-chat-turn.js +3 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +47 -2
- package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
- package/dist/mobile-remote/mobile-upload-service.js +25 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +21 -4
- package/dist/mobile-remote/remote-host-manager.js +142 -25
- package/dist/mobile-remote/resident-agent.d.ts +14 -0
- package/dist/mobile-remote/resident-agent.js +38 -11
- package/dist/mobile-remote/room-history.d.ts +7 -0
- package/dist/mobile-remote/room-history.js +56 -1
- package/dist/mobile-remote/room-manager.d.ts +216 -9
- package/dist/mobile-remote/room-manager.js +700 -65
- package/dist/mobile-remote/room-process-stop.d.ts +15 -0
- package/dist/mobile-remote/room-process-stop.js +41 -0
- package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
- package/dist/mobile-remote/trusted-device-store.js +54 -1
- package/dist/mobile-remote/turn-queue.d.ts +183 -0
- package/dist/mobile-remote/turn-queue.js +422 -0
- package/dist/mobile-remote/types.d.ts +1 -1
- package/dist/panels/agent-task-host.js +6 -12
- package/dist/panels/agent-task-service.d.ts +2 -0
- package/dist/panels/agent-task-service.js +7 -0
- package/dist/panels/browser-capture.d.ts +1 -15
- package/dist/panels/browser-capture.js +1 -35
- package/dist/panels/directory-picker.d.ts +48 -0
- package/dist/panels/directory-picker.js +247 -0
- package/dist/panels/http.d.ts +15 -15
- package/dist/panels/management-http.d.ts +9 -9
- package/dist/panels/process-service.d.ts +5 -4
- package/dist/panels/process-service.js +54 -16
- package/dist/panels/runtime.js +148 -30
- package/dist/panels/tool-jobs-storage.d.ts +4 -0
- package/dist/panels/tool-jobs-storage.js +111 -34
- package/dist/panels/types.d.ts +1 -97
- package/dist/remote-relay/connector.d.ts +8 -0
- package/dist/remote-relay/connector.js +29 -2
- package/dist/remote-relay/protocol.d.ts +21 -0
- package/dist/remote-relay/protocol.js +31 -0
- package/dist/serve/cli.js +6 -0
- package/dist/serve/headless-server.js +24 -19
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +14 -1
- 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
|
-
|
|
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"
|
|
26
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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, {
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
482
|
-
...(this.
|
|
483
|
-
...(this.opts.
|
|
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.
|
|
509
|
-
...(this.
|
|
510
|
-
...(this.opts.
|
|
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, "
|
|
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
|
}
|