@cjhyy/code-shell-server 0.9.52 → 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/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-client-event-validator.js +40 -1
- 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 +19 -4
- package/dist/mobile-remote/remote-host-manager.js +124 -17
- 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/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 +25 -14
- 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
|
@@ -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,11 +571,12 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
474
571
|
return {
|
|
475
572
|
type: "pair.ok",
|
|
476
573
|
device,
|
|
477
|
-
...(this.opts.outputJournal || this.opts.nativeModelSelection
|
|
574
|
+
...(this.opts.outputJournal || this.opts.nativeModelSelection || this.turnQueueAdvertised
|
|
478
575
|
? {
|
|
479
576
|
capabilities: {
|
|
480
577
|
...(this.opts.outputJournal ? { outputJournal: 1 } : {}),
|
|
481
578
|
...(this.opts.nativeModelSelection ? { nativeModelSelection: 1 } : {}),
|
|
579
|
+
...(this.turnQueueAdvertised ? { turnQueue: 1 } : {}),
|
|
482
580
|
...(this.opts.outputJournal && this.opts.historyView
|
|
483
581
|
? { historyView: 1 }
|
|
484
582
|
: {}),
|
|
@@ -494,6 +592,8 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
494
592
|
};
|
|
495
593
|
}
|
|
496
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" };
|
|
497
597
|
let device;
|
|
498
598
|
try {
|
|
499
599
|
device = this.opts.devices.authenticate(event.deviceId, event.secretHash);
|
|
@@ -506,11 +606,12 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
506
606
|
return {
|
|
507
607
|
type: "auth.ok",
|
|
508
608
|
device,
|
|
509
|
-
...(this.opts.outputJournal || this.opts.nativeModelSelection
|
|
609
|
+
...(this.opts.outputJournal || this.opts.nativeModelSelection || this.turnQueueAdvertised
|
|
510
610
|
? {
|
|
511
611
|
capabilities: {
|
|
512
612
|
...(this.opts.outputJournal ? { outputJournal: 1 } : {}),
|
|
513
613
|
...(this.opts.nativeModelSelection ? { nativeModelSelection: 1 } : {}),
|
|
614
|
+
...(this.turnQueueAdvertised ? { turnQueue: 1 } : {}),
|
|
514
615
|
...(this.opts.outputJournal && this.opts.historyView
|
|
515
616
|
? { historyView: 1 }
|
|
516
617
|
: {}),
|
|
@@ -558,6 +659,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
558
659
|
}
|
|
559
660
|
/** Preserve the full worker envelope while retaining exact authenticated-tab routing. */
|
|
560
661
|
sendRawToViewer(viewerId, payload) {
|
|
662
|
+
payload = stampApprovalClock(payload);
|
|
561
663
|
for (const client of this.wss?.clients ?? []) {
|
|
562
664
|
if (client.readyState === client.OPEN &&
|
|
563
665
|
this.authed.has(client) &&
|
|
@@ -571,6 +673,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
571
673
|
}
|
|
572
674
|
/** Send a raw JSON-RPC worker line to ONLY one authenticated device. */
|
|
573
675
|
sendRawToDevice(deviceId, payload) {
|
|
676
|
+
payload = stampApprovalClock(payload);
|
|
574
677
|
for (const client of this.wss?.clients ?? []) {
|
|
575
678
|
if (client.readyState === client.OPEN && this.authed.get(client) === deviceId) {
|
|
576
679
|
this.sendBounded(client, payload);
|
|
@@ -581,6 +684,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
581
684
|
* every authenticated mobile socket. Unauthenticated sockets are skipped so
|
|
582
685
|
* a half-paired client never sees session output. */
|
|
583
686
|
broadcastRaw(payload) {
|
|
687
|
+
payload = stampApprovalClock(payload);
|
|
584
688
|
for (const client of this.wss?.clients ?? []) {
|
|
585
689
|
if (client.readyState === client.OPEN && this.authed.has(client)) {
|
|
586
690
|
this.sendBounded(client, payload);
|
|
@@ -614,7 +718,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
614
718
|
this.releaseSocket(client);
|
|
615
719
|
// Distinct from paired-device rejection (4401/4403): the browser keeps its
|
|
616
720
|
// identity and offers a fresh HTTP challenge instead of silently retrying.
|
|
617
|
-
client.close(4407, "
|
|
721
|
+
client.close(4407, "Host access expired");
|
|
618
722
|
const deadline = setTimeout(() => client.terminate(), 1000);
|
|
619
723
|
deadline.unref();
|
|
620
724
|
client.once("close", () => clearTimeout(deadline));
|
|
@@ -647,6 +751,8 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
647
751
|
}
|
|
648
752
|
/** Call after revoking/removing a trusted device to release both transports immediately. */
|
|
649
753
|
revokeDevice(deviceId) {
|
|
754
|
+
if (this.accountAccess.revokeDevice(deviceId))
|
|
755
|
+
return;
|
|
650
756
|
void this.opts.uploads?.revokeDevice(deviceId).catch((error) => {
|
|
651
757
|
this.emit("host-error", error instanceof Error ? error.message : String(error));
|
|
652
758
|
});
|
|
@@ -662,6 +768,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
662
768
|
// Listeners synchronously fence and destroy outbound sockets before the
|
|
663
769
|
// loopback listener can close or its port can be reused by another Host.
|
|
664
770
|
this.relayLifetime?.abort();
|
|
771
|
+
this.accountAccess.clear();
|
|
665
772
|
if (this.stopping)
|
|
666
773
|
return this.stopping;
|
|
667
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
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
|
|
2
|
+
import { releaseStdioAfterExit, stopRoomProcess, stopRoomProcessAndWait, waitForRoomProcessSpawn, } from "./room-process-stop.js";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
import { pathWithCommonBins } from "./path-bins.js";
|
|
5
5
|
/**
|
|
@@ -119,12 +119,15 @@ export function parseStreamJsonLine(line) {
|
|
|
119
119
|
export class ResidentAgentProcess {
|
|
120
120
|
opts;
|
|
121
121
|
child;
|
|
122
|
+
/** The most recently spawned process, kept after stop() so a caller can
|
|
123
|
+
* wait for it to actually exit before starting a replacement. */
|
|
124
|
+
lastChild;
|
|
125
|
+
spawned;
|
|
122
126
|
constructor(opts) {
|
|
123
127
|
this.opts = opts;
|
|
124
128
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return;
|
|
129
|
+
/** Exact argv for one resident process. Pure → unit-testable. */
|
|
130
|
+
static argsFor(opts) {
|
|
128
131
|
const args = [
|
|
129
132
|
"--print",
|
|
130
133
|
"--verbose",
|
|
@@ -135,13 +138,17 @@ export class ResidentAgentProcess {
|
|
|
135
138
|
"--permission-prompt-tool",
|
|
136
139
|
"stdio",
|
|
137
140
|
];
|
|
138
|
-
if (
|
|
139
|
-
args.push("--append-system-prompt",
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
if (opts.appendSystemPrompt)
|
|
142
|
+
args.push("--append-system-prompt", opts.appendSystemPrompt);
|
|
143
|
+
if (opts.resumeSessionId)
|
|
144
|
+
args.push("--resume", opts.resumeSessionId);
|
|
145
|
+
args.push("--permission-mode", opts.permissionMode);
|
|
146
|
+
return args;
|
|
147
|
+
}
|
|
148
|
+
start() {
|
|
149
|
+
if (this.child)
|
|
150
|
+
return;
|
|
151
|
+
const args = ResidentAgentProcess.argsFor(this.opts);
|
|
145
152
|
const child = spawn(this.opts.command, args, {
|
|
146
153
|
cwd: this.opts.cwd,
|
|
147
154
|
env: { ...process.env, PATH: pathWithCommonBins() },
|
|
@@ -149,6 +156,10 @@ export class ResidentAgentProcess {
|
|
|
149
156
|
stdio: ["pipe", "pipe", "pipe"],
|
|
150
157
|
});
|
|
151
158
|
this.child = child;
|
|
159
|
+
this.lastChild = child;
|
|
160
|
+
this.spawned = waitForRoomProcessSpawn(child);
|
|
161
|
+
// Observed by whenStarted(); an unobserved failed spawn is reported via onEvent.
|
|
162
|
+
this.spawned.catch(() => undefined);
|
|
152
163
|
// Settle on `close` so trailing output is read, without waiting on grandchildren.
|
|
153
164
|
releaseStdioAfterExit(child);
|
|
154
165
|
if (child.stdout) {
|
|
@@ -231,4 +242,20 @@ export class ResidentAgentProcess {
|
|
|
231
242
|
if (child)
|
|
232
243
|
stopRoomProcess(child);
|
|
233
244
|
}
|
|
245
|
+
/** Resolves once the spawned process exists; rejects when the spawn failed. */
|
|
246
|
+
whenStarted() {
|
|
247
|
+
return this.spawned ?? Promise.resolve();
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Stop, then resolve true only after the last spawned process has exited.
|
|
251
|
+
* A process already retired by a pipe error is still waited for: it may be
|
|
252
|
+
* alive for up to the SIGKILL grace period.
|
|
253
|
+
*/
|
|
254
|
+
stopAndWait(timeoutMs) {
|
|
255
|
+
const child = this.child ?? this.lastChild;
|
|
256
|
+
this.child = undefined;
|
|
257
|
+
if (!child)
|
|
258
|
+
return Promise.resolve(true);
|
|
259
|
+
return stopRoomProcessAndWait(child, timeoutMs);
|
|
260
|
+
}
|
|
234
261
|
}
|
|
@@ -14,6 +14,13 @@ export interface RoomHistoryRequest {
|
|
|
14
14
|
export declare function scanRoomHistory<T extends {
|
|
15
15
|
seq: number;
|
|
16
16
|
}>(file: string, parse: (value: unknown) => T | undefined, visit?: (message: T) => void): number;
|
|
17
|
+
/** Same strict snapshot proof as scanRoomHistory, yielding between bounded reads. */
|
|
18
|
+
export declare function scanRoomHistoryAsync<T extends {
|
|
19
|
+
seq: number;
|
|
20
|
+
}>(file: string, parse: (value: unknown) => T | undefined, visit?: (message: T) => void): Promise<{
|
|
21
|
+
sequence: number;
|
|
22
|
+
stamp: string;
|
|
23
|
+
}>;
|
|
17
24
|
/** Durable immutable copies keep a frozen page boundary valid across Host restarts. */
|
|
18
25
|
export declare function readRoomHistoryPage<T extends {
|
|
19
26
|
seq: number;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync, writeSync, } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { lstat, open } from "node:fs/promises";
|
|
4
5
|
const MAX_BYTES = 256 * 1024 * 1024;
|
|
5
6
|
const MAX_LINE = 4 * 1024 * 1024;
|
|
6
7
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
7
8
|
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
8
9
|
function regular(path) {
|
|
9
|
-
|
|
10
|
+
return assertRegular(lstatSync(path));
|
|
11
|
+
}
|
|
12
|
+
function assertRegular(stat) {
|
|
10
13
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
|
|
11
14
|
throw new Error("room history must be a private regular file");
|
|
12
15
|
}
|
|
@@ -99,6 +102,58 @@ export function scanRoomHistory(file, parse, visit) {
|
|
|
99
102
|
closeSync(fd);
|
|
100
103
|
}
|
|
101
104
|
}
|
|
105
|
+
/** Same strict snapshot proof as scanRoomHistory, yielding between bounded reads. */
|
|
106
|
+
export async function scanRoomHistoryAsync(file, parse, visit) {
|
|
107
|
+
const initial = assertRegular(await lstat(file));
|
|
108
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
109
|
+
try {
|
|
110
|
+
const before = assertRegular(await handle.stat());
|
|
111
|
+
if (stamp(initial) !== stamp(before))
|
|
112
|
+
throw new Error("room history changed while opening");
|
|
113
|
+
if (before.size > MAX_BYTES)
|
|
114
|
+
throw new Error("room history exceeds 256 MiB; archive it before continuing");
|
|
115
|
+
let offset = 0, seq = 0, partialBytes = 0;
|
|
116
|
+
let parts = [];
|
|
117
|
+
while (offset < before.size) {
|
|
118
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, before.size - offset));
|
|
119
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, offset);
|
|
120
|
+
if (!bytesRead)
|
|
121
|
+
throw new Error("room history was truncated while reading");
|
|
122
|
+
offset += bytesRead;
|
|
123
|
+
let from = 0;
|
|
124
|
+
while (from < bytesRead) {
|
|
125
|
+
const next = chunk.indexOf(0x0a, from);
|
|
126
|
+
const newline = next >= 0 && next < bytesRead ? next : -1;
|
|
127
|
+
const end = newline < 0 ? bytesRead : newline;
|
|
128
|
+
const piece = chunk.subarray(from, end);
|
|
129
|
+
partialBytes += piece.length;
|
|
130
|
+
if (partialBytes > MAX_LINE)
|
|
131
|
+
throw new Error("room history record exceeds size limit");
|
|
132
|
+
parts.push(piece);
|
|
133
|
+
if (newline < 0)
|
|
134
|
+
break;
|
|
135
|
+
const bytes = parts.length === 1 ? parts[0] : Buffer.concat(parts, partialBytes);
|
|
136
|
+
const message = parse(JSON.parse(decoder.decode(bytes)));
|
|
137
|
+
if (!message || message.seq !== seq + 1)
|
|
138
|
+
throw new Error(`room history is corrupt at sequence ${seq + 1}`);
|
|
139
|
+
seq = message.seq;
|
|
140
|
+
visit?.(message);
|
|
141
|
+
parts = [];
|
|
142
|
+
partialBytes = 0;
|
|
143
|
+
from = newline + 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (partialBytes)
|
|
147
|
+
throw new Error("room history has an incomplete final record");
|
|
148
|
+
if (stamp(before) !== stamp(await handle.stat()) ||
|
|
149
|
+
stamp(before) !== stamp(assertRegular(await lstat(file))))
|
|
150
|
+
throw new Error("room history changed while scanning");
|
|
151
|
+
return { sequence: seq, stamp: stamp(before) };
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await handle.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
102
157
|
function validateManifest(value, roomId, snapshotId) {
|
|
103
158
|
if (!value ||
|
|
104
159
|
value.version !== 1 ||
|