@cjhyy/code-shell-server 0.8.20 → 0.9.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.
@@ -2,17 +2,21 @@ import { markAttachmentsSent, type InputAttachmentMeta } from "../attachment-ser
2
2
  import { type MobileRunBridge } from "./mobile-run-dispatch.js";
3
3
  import type { MobileUploadService } from "./mobile-upload-service.js";
4
4
  import type { MobileAttachmentSummary, MobileImageAttachment, PermissionMode } from "./types.js";
5
+ import type { WorkerFrameMeta } from "../worker-bridge-core.js";
5
6
  type ChatTurnUploads = Pick<MobileUploadService, "claim" | "release" | "finalize">;
6
7
  export interface DispatchMobileChatTurnInput {
7
8
  deviceId: string;
8
9
  sessionId: string;
9
10
  fallbackCwd: string;
11
+ projectId?: string;
12
+ rootId?: string;
10
13
  text: string;
11
14
  attachments?: MobileImageAttachment[];
12
15
  clientMessageId?: string;
13
16
  permissionMode?: PermissionMode;
14
17
  runId: string;
15
18
  bridge: MobileRunBridge;
19
+ meta: WorkerFrameMeta;
16
20
  uploads: ChatTurnUploads;
17
21
  resolveWorkspace: (sessionId: string, fallbackCwd: string) => Promise<string>;
18
22
  markSent?: typeof markAttachmentsSent;
@@ -46,11 +46,13 @@ export async function dispatchMobileChatTurn(input) {
46
46
  task: text,
47
47
  cwd,
48
48
  sessionId: input.sessionId,
49
+ ...(input.projectId ? { projectId: input.projectId } : {}),
50
+ ...(input.rootId ? { rootId: input.rootId } : {}),
49
51
  clientMessageId,
50
52
  attachments: materialized.metas,
51
53
  ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}),
52
54
  },
53
- });
55
+ }, input.meta);
54
56
  if (!acceptance.ok) {
55
57
  await settleClaims(input.uploads, input.deviceId, materialized.claims, "release");
56
58
  return { ok: false, message: acceptance.message };
@@ -46,9 +46,13 @@ export function parseMobileClientEvent(value) {
46
46
  break;
47
47
  case "session.create":
48
48
  valid =
49
- (event.cwd === undefined ||
50
- event.cwd === null ||
51
- boundedPath(event.cwd)) &&
49
+ (event.cwd === undefined || event.cwd === null || boundedPath(event.cwd)) &&
50
+ (event.projectId === undefined ||
51
+ event.projectId === null ||
52
+ boundedString(event.projectId, MAX_ID_LENGTH)) &&
53
+ optionalBoundedString(event.rootId, MAX_ID_LENGTH) &&
54
+ (event.rootId === undefined || typeof event.projectId === "string") &&
55
+ !(event.projectId === null && event.rootId !== undefined) &&
52
56
  optionalBoundedString(event.name, MAX_NAME_LENGTH);
53
57
  break;
54
58
  case "run.stop":
@@ -71,8 +75,7 @@ export function parseMobileClientEvent(value) {
71
75
  break;
72
76
  case "session.sync":
73
77
  valid =
74
- boundedString(event.sessionId, MAX_ID_LENGTH) &&
75
- optionalNonNegativeInteger(event.sinceSeq);
78
+ boundedString(event.sessionId, MAX_ID_LENGTH) && optionalNonNegativeInteger(event.sinceSeq);
76
79
  break;
77
80
  case "permission.setMode":
78
81
  valid =
@@ -114,8 +117,7 @@ export function parseMobileClientEvent(value) {
114
117
  break;
115
118
  case "room.history":
116
119
  valid =
117
- boundedString(event.roomId, MAX_ID_LENGTH) &&
118
- optionalNonNegativeInteger(event.sinceSeq);
120
+ boundedString(event.roomId, MAX_ID_LENGTH) && optionalNonNegativeInteger(event.sinceSeq);
119
121
  break;
120
122
  case "ccRoom.probe":
121
123
  valid =
@@ -202,9 +204,7 @@ function isBoundedHistoryLimit(value) {
202
204
  }
203
205
  function optionalAttachments(value) {
204
206
  return (value === undefined ||
205
- (Array.isArray(value) &&
206
- value.length <= MAX_MOBILE_ATTACHMENTS &&
207
- value.every(isAttachment)));
207
+ (Array.isArray(value) && value.length <= MAX_MOBILE_ATTACHMENTS && value.every(isAttachment)));
208
208
  }
209
209
  function isAttachment(value) {
210
210
  if (!isRecord(value) ||
@@ -223,8 +223,9 @@ function isApprovalDecision(value) {
223
223
  if (!isRecord(value))
224
224
  return false;
225
225
  if (value.behavior === "allow") {
226
- return ((value.answer === undefined || boundedOptionalEmptyString(value.answer, MAX_MESSAGE_LENGTH)) &&
226
+ return ((value.answer === undefined ||
227
+ boundedOptionalEmptyString(value.answer, MAX_MESSAGE_LENGTH)) &&
227
228
  (value.updatedInput === undefined || isRecord(value.updatedInput)));
228
229
  }
229
- return (value.behavior === "deny" && boundedOptionalEmptyString(value.message, MAX_MESSAGE_LENGTH));
230
+ return value.behavior === "deny" && boundedOptionalEmptyString(value.message, MAX_MESSAGE_LENGTH);
230
231
  }
@@ -1,6 +1,7 @@
1
+ import type { WorkerFrameMeta } from "../worker-bridge-core.js";
1
2
  export interface MobileRunBridge {
2
3
  subscribeOutbound(listener: (line: string) => void): () => void;
3
- injectWorkerMessage(line: string): void;
4
+ injectWorkerMessage(line: string, meta: WorkerFrameMeta): void;
4
5
  }
5
6
  export interface MobileRunRequest {
6
7
  id: string;
@@ -14,4 +15,4 @@ export type MobileRunAcceptance = {
14
15
  code?: number;
15
16
  };
16
17
  /** Inject agent/run and wait for its request-correlated validation/queue acknowledgement. */
17
- export declare function injectMobileRunAndAwaitAcceptance(bridge: MobileRunBridge, request: MobileRunRequest, timeoutMs?: number): Promise<MobileRunAcceptance>;
18
+ export declare function injectMobileRunAndAwaitAcceptance(bridge: MobileRunBridge, request: MobileRunRequest, meta: WorkerFrameMeta, timeoutMs?: number): Promise<MobileRunAcceptance>;
@@ -1,5 +1,5 @@
1
1
  /** Inject agent/run and wait for its request-correlated validation/queue acknowledgement. */
2
- export function injectMobileRunAndAwaitAcceptance(bridge, request, timeoutMs = 5_000) {
2
+ export function injectMobileRunAndAwaitAcceptance(bridge, request, meta, timeoutMs = 5_000) {
3
3
  return new Promise((resolve) => {
4
4
  let settled = false;
5
5
  let timer;
@@ -47,7 +47,7 @@ export function injectMobileRunAndAwaitAcceptance(bridge, request, timeoutMs = 5
47
47
  id: request.id,
48
48
  method: "agent/run",
49
49
  params: request.params,
50
- }));
50
+ }), meta);
51
51
  }
52
52
  catch (error) {
53
53
  finish({ ok: false, message: error instanceof Error ? error.message : String(error) });
@@ -27,7 +27,23 @@ export declare function mobileAssetPath(reqUrl: string): string;
27
27
  export declare function mobileEntryRedirect(reqUrl: string): string | null;
28
28
  /** Resolve an asset sub-path to an absolute file inside `root`, or null if it
29
29
  * would escape the root (traversal) or does not resolve to a regular file. An
30
- * empty sub-path resolves to index.html (SPA entry). */
30
+ * empty sub-path resolves to index.html (SPA entry).
31
+ *
32
+ * Containment is checked on the REAL paths (both sides `realpathSync`d), not on
33
+ * the lexical path: `statSync` follows symlinks, so a link sitting inside the
34
+ * root but pointing outside it — e.g. a `node_modules` link left by a packaging
35
+ * step — used to pass a string-prefix check and serve the target's contents.
36
+ * This covers symlinks (including chains and symlinked directories) but NOT
37
+ * hardlinks: a hardlink IS a real name inside the root, so no path-level check
38
+ * can see through it. Guarding that would need inode/device policy, and anyone
39
+ * able to hardlink into the served root can already write files there.
40
+ *
41
+ * The returned path is the REQUESTED one, not the realpath: callers stream it
42
+ * directly and the resolved-name contract is relied on elsewhere (on macOS the
43
+ * realpath of a /var root is /private/var, which would change every result).
44
+ * That leaves the same synchronous stat-then-read window this module always
45
+ * had; closing it properly needs an open-then-fstat rework of both callers,
46
+ * which is out of scope for this fix. */
31
47
  export declare function resolveSafe(root: string, subPath: string): string | null;
32
48
  export interface MobileStaticOptions {
33
49
  /** Absolute path to the built mobile app (out/mobile). */
@@ -1,5 +1,5 @@
1
- import { createReadStream, existsSync, statSync } from "node:fs";
2
- import { join, normalize, resolve, sep, extname } from "node:path";
1
+ import { createReadStream, existsSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, join, normalize, relative, resolve, sep, extname } from "node:path";
3
3
  import { request as httpRequest } from "node:http";
4
4
  import { contentTypeFor } from "../static-files.js";
5
5
  /**
@@ -41,9 +41,32 @@ export function mobileEntryRedirect(reqUrl) {
41
41
  return "/mobile/" + suffix;
42
42
  return null;
43
43
  }
44
+ /** True when `target` is `root` itself or sits underneath it. Uses `relative`
45
+ * rather than a `startsWith` prefix compare so a sibling directory sharing the
46
+ * root's name prefix (`/srv/app-old` vs `/srv/app`) can't slip through. */
47
+ function isContained(root, target) {
48
+ const rel = relative(root, target);
49
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
50
+ }
44
51
  /** Resolve an asset sub-path to an absolute file inside `root`, or null if it
45
52
  * would escape the root (traversal) or does not resolve to a regular file. An
46
- * empty sub-path resolves to index.html (SPA entry). */
53
+ * empty sub-path resolves to index.html (SPA entry).
54
+ *
55
+ * Containment is checked on the REAL paths (both sides `realpathSync`d), not on
56
+ * the lexical path: `statSync` follows symlinks, so a link sitting inside the
57
+ * root but pointing outside it — e.g. a `node_modules` link left by a packaging
58
+ * step — used to pass a string-prefix check and serve the target's contents.
59
+ * This covers symlinks (including chains and symlinked directories) but NOT
60
+ * hardlinks: a hardlink IS a real name inside the root, so no path-level check
61
+ * can see through it. Guarding that would need inode/device policy, and anyone
62
+ * able to hardlink into the served root can already write files there.
63
+ *
64
+ * The returned path is the REQUESTED one, not the realpath: callers stream it
65
+ * directly and the resolved-name contract is relied on elsewhere (on macOS the
66
+ * realpath of a /var root is /private/var, which would change every result).
67
+ * That leaves the same synchronous stat-then-read window this module always
68
+ * had; closing it properly needs an open-then-fstat rework of both callers,
69
+ * which is out of scope for this fix. */
47
70
  export function resolveSafe(root, subPath) {
48
71
  const rootResolved = resolve(root);
49
72
  const rel = normalize(subPath || "index.html");
@@ -52,10 +75,19 @@ export function resolveSafe(root, subPath) {
52
75
  return null;
53
76
  }
54
77
  const full = resolve(join(rootResolved, rel));
55
- if (full !== rootResolved && !full.startsWith(rootResolved + sep))
78
+ if (!isContained(rootResolved, full))
56
79
  return null;
57
80
  if (!existsSync(full) || !statSync(full).isFile())
58
81
  return null;
82
+ // realpathSync throws on a dangling link or a race-deleted file; a missing
83
+ // target is a 404 through the normal contract, never a 500.
84
+ try {
85
+ if (!isContained(realpathSync(rootResolved), realpathSync(full)))
86
+ return null;
87
+ }
88
+ catch {
89
+ return null;
90
+ }
59
91
  return full;
60
92
  }
61
93
  /** True when the request is a browser navigation (accepts text/html), used to
@@ -232,7 +232,7 @@ export async function startHeadlessServer(opts) {
232
232
  // Spawn-on-first-frame (idempotent): the browser's first request wakes
233
233
  // the worker, mirroring the renderer's spawn-on-agent/run semantics.
234
234
  bridge.ensureWorker(opts.cwd);
235
- bridge.injectWorkerMessage(workerLine);
235
+ bridge.injectWorkerMessage(workerLine, { origin: "serve", producer: "serve-ws" });
236
236
  });
237
237
  ws.on("close", () => {
238
238
  tabs.delete(ws);
@@ -1,6 +1,11 @@
1
1
  /** Truncate a wire line for logs. */
2
2
  export declare function previewLine(s: string, max?: number): string;
3
3
  export type WorkerBridgeLog = (event: string, data?: Record<string, unknown>) => void;
4
+ export interface WorkerFrameMeta {
5
+ origin: "renderer" | "mobile" | "host" | "serve";
6
+ /** Producer label for logs and audits. It is never an authorization input. */
7
+ producer: string;
8
+ }
4
9
  export interface WorkerExitInfo {
5
10
  code: number | null;
6
11
  signal: NodeJS.Signals | null;
@@ -55,6 +60,8 @@ export interface WorkerRequestOptions {
55
60
  ensureWorker?: boolean;
56
61
  /** cwd handed to ensureWorker() when it spawns. Ignored unless ensureWorker. */
57
62
  ensureWorkerCwd?: string;
63
+ /** Main-owned provenance for this frame. */
64
+ meta: WorkerFrameMeta;
58
65
  }
59
66
  export interface WorkerBridgeCoreOptions {
60
67
  /** Absolute path of the worker entry script (agent-server-stdio). */
@@ -71,7 +78,7 @@ export interface WorkerBridgeCoreOptions {
71
78
  * injected or correlated (e.g. agent/run trust metadata + on-demand spawn).
72
79
  * Returns the line to write; `method` is only used for log context.
73
80
  */
74
- prepareInbound?: (line: string) => {
81
+ prepareInbound?: (line: string, meta: WorkerFrameMeta) => {
75
82
  line: string;
76
83
  method?: string;
77
84
  };
@@ -141,7 +148,7 @@ export declare class WorkerBridgeCore {
141
148
  * written, or dropped with a log when no worker is alive — identical
142
149
  * semantics for every front end (renderer, mobile remote, orchestrators).
143
150
  */
144
- injectWorkerMessage(rawLine: string): void;
151
+ injectWorkerMessage(rawLine: string, meta: WorkerFrameMeta): void;
145
152
  /**
146
153
  * Send a correlated JSON-RPC request and await its response by id.
147
154
  * `params` is omitted from the frame when undefined. Never rejects — the
@@ -200,9 +200,7 @@ export class WorkerBridgeCore {
200
200
  const pending = this.pendingRequests.get(msg.id);
201
201
  if (!pending)
202
202
  return false;
203
- pending.settle(msg.error
204
- ? { status: "error", error: msg.error }
205
- : { status: "result", result: msg.result });
203
+ pending.settle(msg.error ? { status: "error", error: msg.error } : { status: "result", result: msg.result });
206
204
  return pending.consume;
207
205
  }
208
206
  settlePendingOnExit() {
@@ -239,18 +237,25 @@ export class WorkerBridgeCore {
239
237
  * written, or dropped with a log when no worker is alive — identical
240
238
  * semantics for every front end (renderer, mobile remote, orchestrators).
241
239
  */
242
- injectWorkerMessage(rawLine) {
240
+ injectWorkerMessage(rawLine, meta) {
243
241
  const prep = this.opts.prepareInbound
244
- ? this.opts.prepareInbound(rawLine)
242
+ ? this.opts.prepareInbound(rawLine, meta)
245
243
  : { line: rawLine, method: undefined };
246
244
  if (!this.canSend()) {
247
245
  this.log("inject.dropped", {
248
246
  reason: this.child ? "stdin destroyed" : "no child",
249
247
  method: prep.method,
248
+ origin: meta.origin,
249
+ producer: meta.producer,
250
250
  });
251
251
  return;
252
252
  }
253
- this.log("inject→worker", { method: prep.method, raw: previewLine(prep.line) });
253
+ this.log("inject→worker", {
254
+ method: prep.method,
255
+ origin: meta.origin,
256
+ producer: meta.producer,
257
+ raw: previewLine(prep.line),
258
+ });
254
259
  this.sendLine(prep.line);
255
260
  }
256
261
  /**
@@ -298,7 +303,7 @@ export class WorkerBridgeCore {
298
303
  // bypassing this hook left the main process unable to route the first
299
304
  // Panel tool call for a restored session.
300
305
  const prepared = this.opts.prepareInbound
301
- ? this.opts.prepareInbound(rawFrame)
306
+ ? this.opts.prepareInbound(rawFrame, options.meta)
302
307
  : { line: rawFrame, method: undefined };
303
308
  // Wake a lazily-unspawned worker before writing, so a spawn-triggering
304
309
  // request (pet/IM-gateway agent/run) isn't dropped by sendLine and left
@@ -311,6 +316,17 @@ export class WorkerBridgeCore {
311
316
  catch (e) {
312
317
  sendError = e;
313
318
  }
319
+ const rejected = sendError;
320
+ if (typeof rejected?.code === "number") {
321
+ settle({
322
+ status: "error",
323
+ error: {
324
+ code: rejected.code,
325
+ message: typeof rejected.message === "string" ? rejected.message : "worker frame rejected",
326
+ },
327
+ });
328
+ return;
329
+ }
314
330
  if (options.failFast && (!sent || sendError !== undefined)) {
315
331
  settle({ status: "sendFailed", error: sendError });
316
332
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.8.20",
3
+ "version": "0.9.1",
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",
@@ -39,9 +39,9 @@
39
39
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
40
40
  },
41
41
  "dependencies": {
42
- "@cjhyy/code-shell-capability-coding": "0.8.20",
43
- "@cjhyy/code-shell-core": "0.8.20",
44
- "@cjhyy/code-shell-web": "0.8.20",
42
+ "@cjhyy/code-shell-capability-coding": "0.9.1",
43
+ "@cjhyy/code-shell-core": "0.9.1",
44
+ "@cjhyy/code-shell-web": "0.9.1",
45
45
  "@types/ws": "^8.18.1",
46
46
  "ws": "^8.21.0"
47
47
  },