@cjhyy/code-shell-server 0.8.9 → 0.8.11

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.
@@ -1,6 +1,12 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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 devices = this.readAll();
26
- // Get-or-create by secretHash. A phone's secret is stable per browser
27
- // (persisted in localStorage as cs.deviceSecret), so re-scanning the QR
28
- // code — which the client always prefers over re-auth when the URL carries
29
- // a fresh pairing token — used to mint a BRAND-NEW device every time, piling
30
- // up trusted devices unboundedly. Reuse the existing row instead, refreshing
31
- // its name + lastSeenAt. (mobile-remote device accumulation)
32
- const existing = devices.find((d) => d.secretHash === input.secretHash && !d.revokedAt);
33
- if (existing) {
34
- existing.name = input.name;
35
- existing.lastSeenAt = Date.now();
36
- this.writeAll(devices);
37
- return this.toPublic(existing);
38
- }
39
- const device = {
40
- id: randomUUID(),
41
- name: input.name,
42
- secretHash: input.secretHash,
43
- createdAt: Date.now(),
44
- };
45
- devices.push(device);
46
- this.writeAll(devices);
47
- return this.toPublic(device);
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
- const devices = this.readAll();
54
- const device = devices.find((item) => item.id === id && !item.revokedAt && secretHashEquals(item.secretHash, secretHash));
55
- if (!device)
64
+ if (!isBoundedOpaqueString(id, MAX_DEVICE_ID_LENGTH) ||
65
+ !isBoundedOpaqueString(secretHash, MAX_DEVICE_SECRET_LENGTH)) {
56
66
  return undefined;
57
- device.lastSeenAt = Date.now();
58
- this.writeAll(devices);
59
- return this.toPublic(device);
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
- const devices = this.readAll();
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
- device.revokedAt = Date.now();
67
- this.writeAll(devices);
68
- return true;
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
- const devices = this.readAll();
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.writeAll(next);
77
- return true;
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
- const trimmed = name.trim();
82
- if (!trimmed)
101
+ if (!isBoundedOpaqueString(id, MAX_DEVICE_ID_LENGTH))
83
102
  return false;
84
- const devices = this.readAll();
85
- const device = devices.find((item) => item.id === id);
86
- if (!device)
103
+ const trimmed = normalizeDeviceName(name);
104
+ if (!trimmed)
87
105
  return false;
88
- device.name = trimmed;
89
- this.writeAll(devices);
90
- return true;
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
- if (!existsSync(this.filePath))
94
- return [];
95
- return JSON.parse(readFileSync(this.filePath, "utf-8"));
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
- mkdirSync(dirname(this.filePath), { recursive: true });
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
- // (Y-3 partial: this closes the world-readable file; the non-timing-safe `===`
102
- // compare stays in the §2.4 mobile-remote-hardening bucket for the user.)
103
- writeFileSync(this.filePath, JSON.stringify(devices, null, 2), { encoding: "utf-8", mode: 0o600 });
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(this.filePath, 0o600);
231
+ chmodSync(parent, 0o700);
106
232
  }
107
- catch { /* best-effort: tighten an existing file */ }
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 speaks the CORE protocol (agent/run, agent/approve,
14
- // agent/streamEvent, …) as a first-party front end: this module is a thin
15
- // WS ↔ worker-stdio pipe, NOT a re-implementation of the desktop mobile
16
- // orchestrator. Every authenticated tab sees the identical line stream
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 on disk in the worker's data dir; a
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 sessionManager = new SessionManager(opts.sessionRootDir);
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(broadcast);
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 workerLine = parsed.method === "agent/run"
158
- ? JSON.stringify({
159
- ...parsed,
160
- params: { ...(parsed.params ?? {}), cwd: workspaceCwd },
161
- })
162
- : line;
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.9",
3
+ "version": "0.8.11",
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.9",
42
- "@cjhyy/code-shell-core": "0.8.9",
43
- "@cjhyy/code-shell-web": "0.8.9",
42
+ "@cjhyy/code-shell-capability-coding": "0.8.11",
43
+ "@cjhyy/code-shell-core": "0.8.11",
44
+ "@cjhyy/code-shell-web": "0.8.11",
44
45
  "@types/ws": "^8.18.1",
45
46
  "ws": "^8.21.0"
46
47
  },