@cjhyy/code-shell-server 0.8.8 → 0.8.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -10
- package/dist/attachment-service.js +147 -13
- package/dist/mobile-remote/access-passcode.d.ts +13 -0
- package/dist/mobile-remote/access-passcode.js +147 -13
- package/dist/mobile-remote/mobile-attachments.d.ts +1 -5
- package/dist/mobile-remote/mobile-attachments.js +2 -4
- package/dist/mobile-remote/mobile-client-event-validator.d.ts +3 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +230 -0
- package/dist/mobile-remote/mobile-limits.d.ts +6 -0
- package/dist/mobile-remote/mobile-limits.js +6 -0
- package/dist/mobile-remote/mobile-upload-service.d.ts +2 -1
- package/dist/mobile-remote/mobile-upload-service.js +11 -5
- package/dist/mobile-remote/pairing.d.ts +11 -0
- package/dist/mobile-remote/pairing.js +47 -2
- package/dist/mobile-remote/remote-host-manager.d.ts +5 -3
- package/dist/mobile-remote/remote-host-manager.js +105 -16
- package/dist/mobile-remote/room-manager.d.ts +9 -1
- package/dist/mobile-remote/room-manager.js +388 -55
- package/dist/mobile-remote/trusted-device-store.d.ts +3 -0
- package/dist/mobile-remote/trusted-device-store.js +215 -58
- package/dist/serve/headless-server.d.ts +5 -0
- package/dist/serve/headless-server.js +112 -15
- package/package.json +5 -4
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import { chmodSync,
|
|
1
|
+
import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { acquireFileLock } from "@cjhyy/code-shell-core/internal";
|
|
5
|
+
const MAX_DEVICE_ID_LENGTH = 512;
|
|
6
|
+
const MAX_DEVICE_NAME_LENGTH = 512;
|
|
7
|
+
const MAX_DEVICE_SECRET_LENGTH = 4_096;
|
|
8
|
+
const MAX_TRUSTED_DEVICE_ROWS = 4_096;
|
|
9
|
+
const MAX_TRUSTED_DEVICE_FILE_BYTES = 32 * 1024 * 1024;
|
|
4
10
|
/**
|
|
5
11
|
* Constant-time compare for a device's bearer credential (secretHash), so
|
|
6
12
|
* authentication doesn't leak hash bytes through compare-timing (Y-3).
|
|
@@ -22,89 +28,212 @@ export class TrustedDeviceStore {
|
|
|
22
28
|
this.filePath = filePath;
|
|
23
29
|
}
|
|
24
30
|
addDevice(input) {
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
existing
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
31
|
+
const name = normalizeDeviceName(input.name);
|
|
32
|
+
if (!name)
|
|
33
|
+
throw new Error("Invalid trusted device name");
|
|
34
|
+
if (!isBoundedOpaqueString(input.secretHash, MAX_DEVICE_SECRET_LENGTH)) {
|
|
35
|
+
throw new Error("Invalid trusted device secret");
|
|
36
|
+
}
|
|
37
|
+
return this.mutate((devices) => {
|
|
38
|
+
// Get-or-create by secretHash. A phone's secret is stable per browser
|
|
39
|
+
// (persisted in localStorage as cs.deviceSecret), so re-scanning the QR
|
|
40
|
+
// code must reuse its live row rather than accumulating duplicates.
|
|
41
|
+
const existing = devices.find((d) => d.secretHash === input.secretHash && !d.revokedAt);
|
|
42
|
+
if (existing) {
|
|
43
|
+
existing.name = name;
|
|
44
|
+
existing.lastSeenAt = Date.now();
|
|
45
|
+
return { result: this.toPublic(existing), changed: true };
|
|
46
|
+
}
|
|
47
|
+
if (devices.length >= MAX_TRUSTED_DEVICE_ROWS) {
|
|
48
|
+
throw new Error("Trusted device limit reached");
|
|
49
|
+
}
|
|
50
|
+
const device = {
|
|
51
|
+
id: randomUUID(),
|
|
52
|
+
name,
|
|
53
|
+
secretHash: input.secretHash,
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
};
|
|
56
|
+
devices.push(device);
|
|
57
|
+
return { result: this.toPublic(device), changed: true };
|
|
58
|
+
});
|
|
48
59
|
}
|
|
49
60
|
listDevices() {
|
|
50
61
|
return this.readAll().map((device) => this.toPublic(device));
|
|
51
62
|
}
|
|
52
63
|
authenticate(id, secretHash) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (!device)
|
|
64
|
+
if (!isBoundedOpaqueString(id, MAX_DEVICE_ID_LENGTH) ||
|
|
65
|
+
!isBoundedOpaqueString(secretHash, MAX_DEVICE_SECRET_LENGTH)) {
|
|
56
66
|
return undefined;
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
|
|
67
|
+
}
|
|
68
|
+
return this.mutate((devices) => {
|
|
69
|
+
const device = devices.find((item) => item.id === id && !item.revokedAt && secretHashEquals(item.secretHash, secretHash));
|
|
70
|
+
if (!device)
|
|
71
|
+
return { result: undefined, changed: false };
|
|
72
|
+
device.lastSeenAt = Date.now();
|
|
73
|
+
return { result: this.toPublic(device), changed: true };
|
|
74
|
+
});
|
|
60
75
|
}
|
|
61
76
|
revoke(id) {
|
|
62
|
-
|
|
63
|
-
const device = devices.find((item) => item.id === id && !item.revokedAt);
|
|
64
|
-
if (!device)
|
|
77
|
+
if (!isBoundedOpaqueString(id, MAX_DEVICE_ID_LENGTH))
|
|
65
78
|
return false;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
79
|
+
return this.mutate((devices) => {
|
|
80
|
+
const device = devices.find((item) => item.id === id && !item.revokedAt);
|
|
81
|
+
if (!device)
|
|
82
|
+
return { result: false, changed: false };
|
|
83
|
+
device.revokedAt = Date.now();
|
|
84
|
+
return { result: true, changed: true };
|
|
85
|
+
});
|
|
69
86
|
}
|
|
70
87
|
/** Hard-delete a device row entirely (no revoked zombie left behind). */
|
|
71
88
|
remove(id) {
|
|
72
|
-
|
|
73
|
-
const next = devices.filter((item) => item.id !== id);
|
|
74
|
-
if (next.length === devices.length)
|
|
89
|
+
if (!isBoundedOpaqueString(id, MAX_DEVICE_ID_LENGTH))
|
|
75
90
|
return false;
|
|
76
|
-
this.
|
|
77
|
-
|
|
91
|
+
return this.mutate((devices) => {
|
|
92
|
+
const index = devices.findIndex((item) => item.id === id);
|
|
93
|
+
if (index < 0)
|
|
94
|
+
return { result: false, changed: false };
|
|
95
|
+
devices.splice(index, 1);
|
|
96
|
+
return { result: true, changed: true };
|
|
97
|
+
});
|
|
78
98
|
}
|
|
79
99
|
/** Rename a device's display label. Rejects blank names and unknown ids. */
|
|
80
100
|
rename(id, name) {
|
|
81
|
-
|
|
82
|
-
if (!trimmed)
|
|
101
|
+
if (!isBoundedOpaqueString(id, MAX_DEVICE_ID_LENGTH))
|
|
83
102
|
return false;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
if (!device)
|
|
103
|
+
const trimmed = normalizeDeviceName(name);
|
|
104
|
+
if (!trimmed)
|
|
87
105
|
return false;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
106
|
+
return this.mutate((devices) => {
|
|
107
|
+
const device = devices.find((item) => item.id === id);
|
|
108
|
+
if (!device)
|
|
109
|
+
return { result: false, changed: false };
|
|
110
|
+
device.name = trimmed;
|
|
111
|
+
return { result: true, changed: true };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** Serialize read-modify-write across desktop/server processes. */
|
|
115
|
+
mutate(mutation) {
|
|
116
|
+
this.ensurePrivateParent();
|
|
117
|
+
const release = acquireFileLock(this.filePath);
|
|
118
|
+
try {
|
|
119
|
+
// Crucially reload INSIDE the lock; atomic rename alone cannot prevent
|
|
120
|
+
// two writers that both read revision N from losing one update.
|
|
121
|
+
const devices = this.readAll();
|
|
122
|
+
const { result, changed } = mutation(devices);
|
|
123
|
+
if (changed)
|
|
124
|
+
this.writeAll(devices);
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
release();
|
|
129
|
+
}
|
|
91
130
|
}
|
|
92
131
|
readAll() {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
132
|
+
let descriptor;
|
|
133
|
+
try {
|
|
134
|
+
const pathInfo = lstatSync(this.filePath);
|
|
135
|
+
if (pathInfo.isSymbolicLink() ||
|
|
136
|
+
!pathInfo.isFile() ||
|
|
137
|
+
pathInfo.size > MAX_TRUSTED_DEVICE_FILE_BYTES) {
|
|
138
|
+
throw new Error("Trusted device store is corrupt");
|
|
139
|
+
}
|
|
140
|
+
descriptor = openSync(this.filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
141
|
+
const opened = fstatSync(descriptor);
|
|
142
|
+
if (!opened.isFile() || opened.size > MAX_TRUSTED_DEVICE_FILE_BYTES) {
|
|
143
|
+
throw new Error("Trusted device store is corrupt");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (descriptor !== undefined) {
|
|
148
|
+
closeSync(descriptor);
|
|
149
|
+
}
|
|
150
|
+
if (error.code === "ENOENT")
|
|
151
|
+
return [];
|
|
152
|
+
if (error instanceof Error && error.message === "Trusted device store is corrupt") {
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
throw new Error("Trusted device store is corrupt", { cause: error });
|
|
156
|
+
}
|
|
157
|
+
let parsed;
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(readFileSync(descriptor, "utf-8"));
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
throw new Error("Trusted device store is corrupt", { cause: error });
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
if (descriptor !== undefined)
|
|
166
|
+
closeSync(descriptor);
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(parsed) ||
|
|
169
|
+
parsed.length > MAX_TRUSTED_DEVICE_ROWS ||
|
|
170
|
+
!parsed.every(isTrustedDevice)) {
|
|
171
|
+
throw new Error("Trusted device store is corrupt");
|
|
172
|
+
}
|
|
173
|
+
const ids = new Set();
|
|
174
|
+
for (const device of parsed) {
|
|
175
|
+
if (ids.has(device.id))
|
|
176
|
+
throw new Error("Trusted device store is corrupt");
|
|
177
|
+
ids.add(device.id);
|
|
178
|
+
}
|
|
179
|
+
return parsed;
|
|
96
180
|
}
|
|
97
181
|
writeAll(devices) {
|
|
98
|
-
|
|
182
|
+
this.ensurePrivateParent();
|
|
183
|
+
const serialized = JSON.stringify(devices, null, 2);
|
|
184
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_TRUSTED_DEVICE_FILE_BYTES) {
|
|
185
|
+
throw new Error("Trusted device store is too large");
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const target = lstatSync(this.filePath);
|
|
189
|
+
if (target.isSymbolicLink() || !target.isFile()) {
|
|
190
|
+
throw new Error("Trusted device store is corrupt");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error.code !== "ENOENT")
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
99
197
|
// 0o600: devices.json holds each device's secretHash (its bearer credential).
|
|
100
198
|
// Owner-only, same hardening as settings.json/credentials.json/cookie leases.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
199
|
+
const tempPath = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(tempPath, serialized, {
|
|
202
|
+
encoding: "utf-8",
|
|
203
|
+
flag: "wx",
|
|
204
|
+
mode: 0o600,
|
|
205
|
+
});
|
|
206
|
+
renameSync(tempPath, this.filePath);
|
|
207
|
+
try {
|
|
208
|
+
chmodSync(this.filePath, 0o600);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// Best effort: tighten an existing file on platforms that support it.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
try {
|
|
216
|
+
rmSync(tempPath, { force: true });
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// Never hide the original write/rename error with cleanup failure.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
ensurePrivateParent() {
|
|
224
|
+
const parent = dirname(this.filePath);
|
|
225
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
226
|
+
const metadata = lstatSync(parent);
|
|
227
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
228
|
+
throw new Error("Trusted device store parent must be a real directory");
|
|
229
|
+
}
|
|
104
230
|
try {
|
|
105
|
-
chmodSync(
|
|
231
|
+
chmodSync(parent, 0o700);
|
|
106
232
|
}
|
|
107
|
-
catch {
|
|
233
|
+
catch {
|
|
234
|
+
// Best effort on platforms/filesystems without POSIX permissions.
|
|
235
|
+
}
|
|
236
|
+
return parent;
|
|
108
237
|
}
|
|
109
238
|
toPublic(device) {
|
|
110
239
|
return {
|
|
@@ -116,3 +245,31 @@ export class TrustedDeviceStore {
|
|
|
116
245
|
};
|
|
117
246
|
}
|
|
118
247
|
}
|
|
248
|
+
function isTrustedDevice(value) {
|
|
249
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
250
|
+
return false;
|
|
251
|
+
const device = value;
|
|
252
|
+
return (isBoundedOpaqueString(device.id, MAX_DEVICE_ID_LENGTH) &&
|
|
253
|
+
typeof device.name === "string" &&
|
|
254
|
+
device.name.length <= MAX_DEVICE_NAME_LENGTH &&
|
|
255
|
+
!device.name.includes("\0") &&
|
|
256
|
+
isBoundedOpaqueString(device.secretHash, MAX_DEVICE_SECRET_LENGTH) &&
|
|
257
|
+
isTimestamp(device.createdAt) &&
|
|
258
|
+
(device.lastSeenAt === undefined || isTimestamp(device.lastSeenAt)) &&
|
|
259
|
+
(device.revokedAt === undefined || isTimestamp(device.revokedAt)));
|
|
260
|
+
}
|
|
261
|
+
function isTimestamp(value) {
|
|
262
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
263
|
+
}
|
|
264
|
+
function isBoundedOpaqueString(value, maxLength) {
|
|
265
|
+
return (typeof value === "string" &&
|
|
266
|
+
value.length > 0 &&
|
|
267
|
+
value.length <= maxLength &&
|
|
268
|
+
!value.includes("\0"));
|
|
269
|
+
}
|
|
270
|
+
function normalizeDeviceName(value) {
|
|
271
|
+
if (typeof value !== "string" || value.includes("\0"))
|
|
272
|
+
return undefined;
|
|
273
|
+
const trimmed = value.trim();
|
|
274
|
+
return trimmed.length > 0 && trimmed.length <= MAX_DEVICE_NAME_LENGTH ? trimmed : undefined;
|
|
275
|
+
}
|
|
@@ -13,6 +13,11 @@ export interface HeadlessServeOptions {
|
|
|
13
13
|
workerEntryPath: string;
|
|
14
14
|
/** Session root override for tests/relocated workers. Defaults to core's canonical root. */
|
|
15
15
|
sessionRootDir?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Isolated data root passed to the stdio worker. Defaults to `<dataDir>/worker`.
|
|
18
|
+
* When `sessionRootDir` is supplied without this option, its parent is used.
|
|
19
|
+
*/
|
|
20
|
+
workerDataRoot?: string;
|
|
16
21
|
/** Runtime binary for the worker; defaults to process.execPath. */
|
|
17
22
|
execPath?: string;
|
|
18
23
|
/** Built web app root; when absent the server is WS/API-only. */
|
|
@@ -10,18 +10,17 @@
|
|
|
10
10
|
// - WorkerBridgeCore spawns/drives ONE agent-server-stdio worker.
|
|
11
11
|
// - resolveSafe path-traversal-safe static file resolution.
|
|
12
12
|
//
|
|
13
|
-
// The browser
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// (same semantics as AgentBridge's renderer fan-out).
|
|
13
|
+
// The browser uses a deliberately small CORE-protocol projection: the host
|
|
14
|
+
// answers session list/detail itself and forwards only run/approve/cancel after
|
|
15
|
+
// workspace/session authorization. Per-tab request IDs are translated so
|
|
16
|
+
// responses return only to their origin; notifications fan out to all tabs.
|
|
18
17
|
//
|
|
19
|
-
// Restart recovery: sessions persist
|
|
18
|
+
// Restart recovery: sessions persist under the serve-owned worker data root; a
|
|
20
19
|
// server restart spawns a fresh worker on the first inbound frame and the
|
|
21
20
|
// browser re-lists sessions over the same protocol.
|
|
22
21
|
import { createServer } from "node:http";
|
|
23
22
|
import { readFileSync, existsSync } from "node:fs";
|
|
24
|
-
import { extname, join, resolve } from "node:path";
|
|
23
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
25
24
|
import { randomBytes } from "node:crypto";
|
|
26
25
|
import { WebSocketServer } from "ws";
|
|
27
26
|
import { SessionManager } from "@cjhyy/code-shell-core";
|
|
@@ -33,7 +32,14 @@ export async function startHeadlessServer(opts) {
|
|
|
33
32
|
const host = opts.host ?? "127.0.0.1";
|
|
34
33
|
const log = opts.log ?? (() => { });
|
|
35
34
|
const workspaceCwd = resolve(opts.cwd);
|
|
36
|
-
const
|
|
35
|
+
const workerDataRoot = resolve(opts.workerDataRoot ??
|
|
36
|
+
(opts.sessionRootDir ? dirname(resolve(opts.sessionRootDir)) : join(opts.dataDir, "worker")));
|
|
37
|
+
const sessionRootDir = resolve(opts.sessionRootDir ?? join(workerDataRoot, "sessions"));
|
|
38
|
+
const expectedSessionRootDir = resolve(join(workerDataRoot, "sessions"));
|
|
39
|
+
if (sessionRootDir !== expectedSessionRootDir) {
|
|
40
|
+
throw new Error(`sessionRootDir must equal <workerDataRoot>/sessions (${expectedSessionRootDir}) so the host and worker authorize the same session store`);
|
|
41
|
+
}
|
|
42
|
+
const sessionManager = new SessionManager(sessionRootDir);
|
|
37
43
|
const passcode = new AccessPasscode({ filePath: join(opts.dataDir, "access.json") });
|
|
38
44
|
let generatedPasscode;
|
|
39
45
|
if (opts.passcode) {
|
|
@@ -44,19 +50,59 @@ export async function startHeadlessServer(opts) {
|
|
|
44
50
|
passcode.set(generatedPasscode);
|
|
45
51
|
}
|
|
46
52
|
const tabs = new Set();
|
|
53
|
+
const pendingWorkerResponses = new Map();
|
|
54
|
+
let nextTabId = 1;
|
|
55
|
+
let nextWorkerRequestId = 1;
|
|
47
56
|
const broadcast = (line) => {
|
|
48
57
|
for (const tab of tabs) {
|
|
49
58
|
if (tab.readyState === tab.OPEN)
|
|
50
59
|
tab.send(line);
|
|
51
60
|
}
|
|
52
61
|
};
|
|
62
|
+
const failPendingWorkerResponses = (message) => {
|
|
63
|
+
for (const { tab, originalId } of pendingWorkerResponses.values()) {
|
|
64
|
+
if (tab.readyState !== tab.OPEN)
|
|
65
|
+
continue;
|
|
66
|
+
tab.send(hostQueryError(originalId, -32000, message));
|
|
67
|
+
}
|
|
68
|
+
pendingWorkerResponses.clear();
|
|
69
|
+
};
|
|
70
|
+
// Notifications describe shared agent state and are broadcast. Correlated
|
|
71
|
+
// JSON-RPC responses must return only to the tab that issued the request;
|
|
72
|
+
// browser tabs all start their local counters at `web-1`, so broadcasting a
|
|
73
|
+
// response lets one tab resolve another tab's promise.
|
|
74
|
+
const routeWorkerLine = (line) => {
|
|
75
|
+
let message;
|
|
76
|
+
try {
|
|
77
|
+
message = JSON.parse(line);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
log("worker.frame_dropped", { reason: "not json", raw: previewLine(line) });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!message || message.id === undefined || message.method !== undefined) {
|
|
84
|
+
broadcast(line);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const route = pendingWorkerResponses.get(String(message.id));
|
|
88
|
+
if (!route) {
|
|
89
|
+
log("worker.response_dropped", { reason: "unknown request id", id: message.id });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
pendingWorkerResponses.delete(String(message.id));
|
|
93
|
+
if (route.tab.readyState !== route.tab.OPEN)
|
|
94
|
+
return;
|
|
95
|
+
route.tab.send(JSON.stringify({ ...message, id: route.originalId }));
|
|
96
|
+
};
|
|
53
97
|
const bridge = new WorkerBridgeCore({
|
|
54
98
|
entryPath: opts.workerEntryPath,
|
|
55
99
|
execPath: opts.execPath,
|
|
56
100
|
fallbackCwd: () => opts.cwd,
|
|
101
|
+
buildEnv: () => ({ ...process.env, CODE_SHELL_DATA_ROOT: workerDataRoot }),
|
|
57
102
|
log,
|
|
58
103
|
onStderr: (text) => log("worker.stderr", { text: previewLine(text) }),
|
|
59
104
|
onExit: (info) => {
|
|
105
|
+
failPendingWorkerResponses("agent worker stopped");
|
|
60
106
|
// Synthetic serve-level notification so the UI can show "agent worker
|
|
61
107
|
// stopped" without conflating it with in-protocol agent/status.
|
|
62
108
|
broadcast(JSON.stringify({
|
|
@@ -66,7 +112,7 @@ export async function startHeadlessServer(opts) {
|
|
|
66
112
|
}));
|
|
67
113
|
},
|
|
68
114
|
});
|
|
69
|
-
bridge.subscribeLines(
|
|
115
|
+
bridge.subscribeLines(routeWorkerLine);
|
|
70
116
|
const serveStatic = (req, res) => {
|
|
71
117
|
const root = opts.staticRootDir;
|
|
72
118
|
if (!root) {
|
|
@@ -127,6 +173,7 @@ export async function startHeadlessServer(opts) {
|
|
|
127
173
|
socket.destroy();
|
|
128
174
|
return;
|
|
129
175
|
}
|
|
176
|
+
const tabId = nextTabId++;
|
|
130
177
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
131
178
|
tabs.add(ws);
|
|
132
179
|
log("tab.connected", { tabs: tabs.size });
|
|
@@ -151,15 +198,31 @@ export async function startHeadlessServer(opts) {
|
|
|
151
198
|
ws.send(hostReply);
|
|
152
199
|
return;
|
|
153
200
|
}
|
|
201
|
+
const policyReply = authorizeServeRequest(parsed, sessionManager, workspaceCwd);
|
|
202
|
+
if (policyReply) {
|
|
203
|
+
ws.send(policyReply);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
154
206
|
// This host intentionally exposes one workspace. A browser must not be
|
|
155
207
|
// able to escape it by supplying another cwd, and a missing cwd must
|
|
156
208
|
// not silently become the worker's global no-repo conversation.
|
|
157
|
-
const
|
|
158
|
-
?
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
209
|
+
const workerMessage = parsed.method === "agent/run"
|
|
210
|
+
? { ...parsed, params: { ...(parsed.params ?? {}), cwd: workspaceCwd } }
|
|
211
|
+
: parsed;
|
|
212
|
+
if (workerMessage.id === null) {
|
|
213
|
+
ws.send(hostQueryError(null, -32600, "JSON-RPC request id must not be null"));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (workerMessage.id !== undefined) {
|
|
217
|
+
const workerRequestId = `serve-${tabId}-${nextWorkerRequestId++}`;
|
|
218
|
+
pendingWorkerResponses.set(workerRequestId, {
|
|
219
|
+
tab: ws,
|
|
220
|
+
originalId: workerMessage.id,
|
|
221
|
+
tabId,
|
|
222
|
+
});
|
|
223
|
+
workerMessage.id = workerRequestId;
|
|
224
|
+
}
|
|
225
|
+
const workerLine = JSON.stringify(workerMessage);
|
|
163
226
|
// Spawn-on-first-frame (idempotent): the browser's first request wakes
|
|
164
227
|
// the worker, mirroring the renderer's spawn-on-agent/run semantics.
|
|
165
228
|
bridge.ensureWorker(opts.cwd);
|
|
@@ -167,6 +230,10 @@ export async function startHeadlessServer(opts) {
|
|
|
167
230
|
});
|
|
168
231
|
ws.on("close", () => {
|
|
169
232
|
tabs.delete(ws);
|
|
233
|
+
for (const [requestId, route] of pendingWorkerResponses) {
|
|
234
|
+
if (route.tabId === tabId)
|
|
235
|
+
pendingWorkerResponses.delete(requestId);
|
|
236
|
+
}
|
|
170
237
|
log("tab.closed", { tabs: tabs.size });
|
|
171
238
|
});
|
|
172
239
|
ws.on("error", () => {
|
|
@@ -262,6 +329,36 @@ function replyToHostSessionQuery(message, sessionManager, workspaceCwd) {
|
|
|
262
329
|
return hostQueryError(message.id, -32001, error instanceof Error ? error.message : "Session not found");
|
|
263
330
|
}
|
|
264
331
|
}
|
|
332
|
+
const SERVE_ALLOWED_WORKER_METHODS = new Set(["agent/run", "agent/approve", "agent/cancel"]);
|
|
333
|
+
/**
|
|
334
|
+
* The no-account Web host deliberately exposes only the methods used by its
|
|
335
|
+
* bundled SPA. Passcode possession grants control of this workspace, not raw
|
|
336
|
+
* access to the worker's full local protocol surface.
|
|
337
|
+
*/
|
|
338
|
+
function authorizeServeRequest(message, sessionManager, workspaceCwd) {
|
|
339
|
+
if (!message.method || !SERVE_ALLOWED_WORKER_METHODS.has(message.method)) {
|
|
340
|
+
return hostQueryError(message.id ?? null, -32601, "Method is not available in Web serve mode");
|
|
341
|
+
}
|
|
342
|
+
const rawSessionId = message.params?.sessionId;
|
|
343
|
+
const sessionId = typeof rawSessionId === "string" && rawSessionId.length > 0 ? rawSessionId : null;
|
|
344
|
+
if (message.method === "agent/run" && sessionId === null)
|
|
345
|
+
return undefined;
|
|
346
|
+
if (!sessionId) {
|
|
347
|
+
return hostQueryError(message.id ?? null, -32602, "sessionId is required");
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const session = sessionManager.resume(sessionId).state;
|
|
351
|
+
if (resolve(session.cwd) === workspaceCwd)
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// A caller may choose the id for a brand-new run. Other methods must target
|
|
356
|
+
// an already-persisted session owned by this workspace.
|
|
357
|
+
if (message.method === "agent/run")
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
return hostQueryError(message.id ?? null, -32001, "Session not found in this workspace");
|
|
361
|
+
}
|
|
265
362
|
function hostQueryError(id, code, message) {
|
|
266
363
|
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
|
267
364
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cjhyy/code-shell-server",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.10",
|
|
4
4
|
"description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -35,12 +35,13 @@
|
|
|
35
35
|
],
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "bun run clean && tsc -p tsconfig.json",
|
|
38
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
39
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
|
-
"@cjhyy/code-shell-capability-coding": "0.8.
|
|
42
|
-
"@cjhyy/code-shell-core": "0.8.
|
|
43
|
-
"@cjhyy/code-shell-web": "0.8.
|
|
42
|
+
"@cjhyy/code-shell-capability-coding": "0.8.10",
|
|
43
|
+
"@cjhyy/code-shell-core": "0.8.10",
|
|
44
|
+
"@cjhyy/code-shell-web": "0.8.10",
|
|
44
45
|
"@types/ws": "^8.18.1",
|
|
45
46
|
"ws": "^8.21.0"
|
|
46
47
|
},
|