@cjhyy/code-shell-server 0.9.49 → 0.9.51

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,7 +1,7 @@
1
1
  export { createPanelHttp } from "./panels/http.js";
2
2
  export * from "./panels/automations.js";
3
3
  export * from "./panels/hub-automations.js";
4
- export { createPanelManagement, PanelManagementError } from "./panels/management.js";
4
+ export { createPanelManagement, createPanelSnapshotReads, PanelManagementError, type PanelSnapshotReads, } from "./panels/management.js";
5
5
  export { createPanelRuntime, panelWebCompatibility } from "./panels/runtime.js";
6
6
  export { createHubPanelBinding } from "./panels/hub-binding.js";
7
7
  export * from "./panels/project-scope.js";
@@ -1,7 +1,7 @@
1
1
  export { createPanelHttp } from "./panels/http.js";
2
2
  export * from "./panels/automations.js";
3
3
  export * from "./panels/hub-automations.js";
4
- export { createPanelManagement, PanelManagementError } from "./panels/management.js";
4
+ export { createPanelManagement, createPanelSnapshotReads, PanelManagementError, } from "./panels/management.js";
5
5
  export { createPanelRuntime, panelWebCompatibility } from "./panels/runtime.js";
6
6
  export { createHubPanelBinding } from "./panels/hub-binding.js";
7
7
  export * from "./panels/project-scope.js";
@@ -28,7 +28,8 @@ export function parseMobileClientEvent(value) {
28
28
  break;
29
29
  case "chat.send":
30
30
  valid =
31
- boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
31
+ optionalMember(event.permissionMode, PERMISSION_MODES) &&
32
+ boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
32
33
  (event.modelKey === undefined ||
33
34
  (boundedString(event.modelKey, MAX_ID_LENGTH) &&
34
35
  /^(codex|claude-code)\/[^\r\n\0]*$/.test(event.modelKey))) &&
@@ -74,20 +75,37 @@ export function parseMobileClientEvent(value) {
74
75
  ((boundedString(event.throughEventId, 512) && event.cursor === undefined) ||
75
76
  (boundedString(event.cursor, 2048) && event.throughEventId === undefined));
76
77
  break;
78
+ case "session.historyView":
79
+ case "session.historyDetail":
80
+ valid =
81
+ boundedString(event.sessionId, 128) &&
82
+ boundedString(event.recoveryId, 128) &&
83
+ boundedString(event.requestId, 128) &&
84
+ (event.type === "session.historyView"
85
+ ? optionalBoundedString(event.before, 2048) &&
86
+ Object.keys(event).every((key) => ["type", "sessionId", "recoveryId", "requestId", "before"].includes(key))
87
+ : boundedString(event.ref, 2048) &&
88
+ optionalBoundedString(event.cursor, 2048) &&
89
+ Object.keys(event).every((key) => ["type", "sessionId", "recoveryId", "requestId", "ref", "cursor"].includes(key)));
90
+ break;
77
91
  case "session.outputJournal":
78
92
  valid =
79
93
  boundedString(event.sessionId, 128) &&
80
94
  boundedString(event.recoveryId, 128) &&
81
95
  boundedString(event.requestId, 128) &&
82
96
  optionalBoundedString(event.after, 2048) &&
83
- optionalBoundedString(event.through, 2048);
97
+ optionalBoundedString(event.through, 2048) &&
98
+ (event.frozenSnapshot === undefined || event.frozenSnapshot === true) &&
99
+ (event.maxFrames === undefined ||
100
+ (isPositiveInteger(event.maxFrames) && event.maxFrames <= 2048));
84
101
  break;
85
102
  case "session.history":
86
103
  valid = boundedString(event.sessionId, MAX_ID_LENGTH);
87
104
  break;
88
105
  case "session.create":
89
106
  valid =
90
- optionalBoundedString(event.clientRequestId, MAX_ID_LENGTH) &&
107
+ optionalMember(event.permissionMode, PERMISSION_MODES) &&
108
+ optionalBoundedString(event.clientRequestId, MAX_ID_LENGTH) &&
91
109
  (event.cwd === undefined || event.cwd === null || boundedPath(event.cwd)) &&
92
110
  (event.projectId === undefined ||
93
111
  event.projectId === null ||
@@ -105,6 +123,9 @@ export function parseMobileClientEvent(value) {
105
123
  boundedString(event.approvalId, MAX_ID_LENGTH) &&
106
124
  (event.decision === "approve" || event.decision === "reject") &&
107
125
  optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
126
+ optionalBoundedString(event.connectionId, MAX_ID_LENGTH) &&
127
+ (event.generation === undefined ||
128
+ (Number.isSafeInteger(event.generation) && event.generation >= 0)) &&
108
129
  optionalBoundedString(event.reason, MAX_MESSAGE_LENGTH) &&
109
130
  optionalBoundedString(event.answer, MAX_MESSAGE_LENGTH) &&
110
131
  optionalMember(event.scope, SCOPES) &&
@@ -5,15 +5,27 @@
5
5
  * are stateful: keep the latest unresolved raw JSON-RPC request here and replay
6
6
  * it to a phone when that phone binds to the matching session.
7
7
  */
8
+ export interface PendingMobileApprovalRoute {
9
+ sessionId: string;
10
+ requestId: string;
11
+ connectionId?: string;
12
+ generation?: number;
13
+ expiresAt?: number;
14
+ }
8
15
  export declare class PendingMobileApprovals {
16
+ private readonly now;
9
17
  private readonly bySession;
10
18
  private workerGeneration;
19
+ constructor(now?: () => number);
11
20
  /** Requests belong to the worker that can answer them, never its successor. */
12
21
  setWorkerState(generation: number, alive: boolean): void;
13
22
  observeOutboundLine(line: string): void;
14
- resolve(requestId: string): void;
23
+ resolve(requestId: string, route?: Partial<PendingMobileApprovalRoute>): void;
15
24
  replayLines(sessionId: string): string[];
25
+ /** The stored worker envelope, rather than a phone's mutable current session, owns routing. */
26
+ find(sessionId: string, requestId: string): PendingMobileApprovalRoute | undefined;
16
27
  /** The desktop has a queue across sessions; return only its current pending requests. */
17
28
  replayAllLines(): string[];
18
29
  forgetSession(sessionId: string): void;
30
+ private pruneExpired;
19
31
  }
@@ -1,13 +1,10 @@
1
- /**
2
- * Tracks currently pending core approval requests for mobile clients.
3
- *
4
- * Session snapshots intentionally contain only stream events. Approval requests
5
- * are stateful: keep the latest unresolved raw JSON-RPC request here and replay
6
- * it to a phone when that phone binds to the matching session.
7
- */
8
1
  export class PendingMobileApprovals {
2
+ now;
9
3
  bySession = new Map();
10
4
  workerGeneration;
5
+ constructor(now = Date.now) {
6
+ this.now = now;
7
+ }
11
8
  /** Requests belong to the worker that can answer them, never its successor. */
12
9
  setWorkerState(generation, alive) {
13
10
  if (!alive || this.workerGeneration !== generation)
@@ -32,6 +29,8 @@ export class PendingMobileApprovals {
32
29
  return;
33
30
  if (typeof requestId !== "string" || !requestId)
34
31
  return;
32
+ if (typeof params?.expiresAt === "number" && params.expiresAt <= this.now())
33
+ return;
35
34
  let pending = this.bySession.get(sessionId);
36
35
  if (!pending) {
37
36
  pending = new Map();
@@ -44,24 +43,61 @@ export class PendingMobileApprovals {
44
43
  const params = msg.params;
45
44
  const requestId = params?.requestId;
46
45
  if (typeof requestId === "string" && requestId)
47
- this.resolve(requestId);
46
+ this.resolve(requestId, params ?? {});
48
47
  }
49
48
  }
50
- resolve(requestId) {
49
+ resolve(requestId, route = {}) {
51
50
  for (const [sessionId, pending] of this.bySession) {
51
+ if (route.sessionId !== undefined && route.sessionId !== sessionId)
52
+ continue;
53
+ const line = pending.get(requestId);
54
+ if (!line)
55
+ continue;
56
+ const params = JSON.parse(line).params;
57
+ if ((params.connectionId !== undefined || params.generation !== undefined) &&
58
+ (route.sessionId !== sessionId ||
59
+ route.connectionId !== params.connectionId ||
60
+ route.generation !== params.generation))
61
+ continue;
62
+ if ((route.connectionId !== undefined && route.connectionId !== params.connectionId) ||
63
+ (route.generation !== undefined && route.generation !== params.generation))
64
+ continue;
52
65
  pending.delete(requestId);
53
66
  if (pending.size === 0)
54
67
  this.bySession.delete(sessionId);
55
68
  }
56
69
  }
57
70
  replayLines(sessionId) {
71
+ this.pruneExpired();
58
72
  return [...(this.bySession.get(sessionId)?.values() ?? [])];
59
73
  }
74
+ /** The stored worker envelope, rather than a phone's mutable current session, owns routing. */
75
+ find(sessionId, requestId) {
76
+ this.pruneExpired();
77
+ const line = this.bySession.get(sessionId)?.get(requestId);
78
+ if (!line)
79
+ return undefined;
80
+ const { connectionId, generation, expiresAt } = JSON.parse(line).params;
81
+ return { sessionId, requestId, connectionId, generation, expiresAt };
82
+ }
60
83
  /** The desktop has a queue across sessions; return only its current pending requests. */
61
84
  replayAllLines() {
85
+ this.pruneExpired();
62
86
  return [...this.bySession.values()].flatMap((pending) => [...pending.values()]);
63
87
  }
64
88
  forgetSession(sessionId) {
65
89
  this.bySession.delete(sessionId);
66
90
  }
91
+ pruneExpired() {
92
+ const now = this.now();
93
+ for (const [sessionId, pending] of this.bySession) {
94
+ for (const [requestId, line] of pending) {
95
+ const expiresAt = JSON.parse(line).params.expiresAt;
96
+ if (typeof expiresAt === "number" && expiresAt <= now)
97
+ pending.delete(requestId);
98
+ }
99
+ if (!pending.size)
100
+ this.bySession.delete(sessionId);
101
+ }
102
+ }
67
103
  }
@@ -40,6 +40,10 @@ export interface RemoteHostManagerOptions {
40
40
  outputJournal?: boolean;
41
41
  /** Enable only when the attached selected-session host implements legacy base pages. */
42
42
  legacyBasePages?: boolean;
43
+ /** Enable only when the selected-session reader supports opt-in dense pages. */
44
+ outputJournalPageFrames?: 2048;
45
+ /** Selected-session Host implements verified display views and bounded detail parts. */
46
+ historyView?: boolean;
43
47
  onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
44
48
  /**
45
49
  * Absolute path to the built mobile app (out/mobile). Defaults to the
@@ -478,7 +478,11 @@ export class RemoteHostManager extends EventEmitter {
478
478
  ? {
479
479
  capabilities: {
480
480
  outputJournal: 1,
481
+ ...(this.opts.historyView ? { historyView: 1 } : {}),
481
482
  ...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
483
+ ...(this.opts.outputJournalPageFrames === 2048
484
+ ? { outputJournalPageFrames: 2048 }
485
+ : {}),
482
486
  },
483
487
  }
484
488
  : {}),
@@ -501,7 +505,11 @@ export class RemoteHostManager extends EventEmitter {
501
505
  ? {
502
506
  capabilities: {
503
507
  outputJournal: 1,
508
+ ...(this.opts.historyView ? { historyView: 1 } : {}),
504
509
  ...(this.opts.legacyBasePages ? { legacyBasePages: 1 } : {}),
510
+ ...(this.opts.outputJournalPageFrames === 2048
511
+ ? { outputJournalPageFrames: 2048 }
512
+ : {}),
505
513
  },
506
514
  }
507
515
  : {}),
@@ -17,7 +17,7 @@ export declare function createPanelHttp(options: PanelManagementHttpOptions & {
17
17
  authorizePanelDirectory?: PanelDirectoryAuthorizer;
18
18
  }): {
19
19
  service: {
20
- snapshot: (appId?: string) => Promise<import("./types.js").PanelSnapshot>;
20
+ snapshot: (appId?: string, reads?: import("./management.js").PanelSnapshotReads) => Promise<import("./types.js").PanelSnapshot>;
21
21
  packageHistory(context: import("./types.js").PanelOperationContext, id: unknown, expected: unknown): Promise<import("./types.js").PanelPackageHistory>;
22
22
  previewRestore(context: import("./types.js").PanelOperationContext, id: unknown, digest: unknown, expected: unknown): Promise<import("./types.js").PanelPackageRestoreReview>;
23
23
  cancelRestore(context: import("./types.js").PanelOperationContext, token: unknown): Promise<{
@@ -8,7 +8,7 @@ export interface PanelManagementHttpOptions extends PanelManagementOptions {
8
8
  /** Call only after the host's same-origin authentication boundary. */
9
9
  export declare function createPanelManagementHttp(options: PanelManagementHttpOptions): {
10
10
  service: {
11
- snapshot: (appId?: string) => Promise<import("./types.js").PanelSnapshot>;
11
+ snapshot: (appId?: string, reads?: import("./management.js").PanelSnapshotReads) => Promise<import("./types.js").PanelSnapshot>;
12
12
  packageHistory(context: PanelOperationContext, id: unknown, expected: unknown): Promise<import("./types.js").PanelPackageHistory>;
13
13
  previewRestore(context: PanelOperationContext, id: unknown, digest: unknown, expected: unknown): Promise<import("./types.js").PanelPackageRestoreReview>;
14
14
  cancelRestore(context: PanelOperationContext, token: unknown): Promise<{
@@ -1,5 +1,5 @@
1
1
  import { type PanelExecutionScope } from "./execution-gate.js";
2
- import { type PanelAppPreview, type PanelAppSourceInput } from "@cjhyy/code-shell-core";
2
+ import { type PanelAppInspectionReads, type PanelAppPreview, type PanelAppSourceInput } from "@cjhyy/code-shell-core";
3
3
  import type { PanelCompatibility, PanelDiscovery, PanelOperationContext, PanelReview, PanelProjectReview, PanelSnapshot, PanelPackageHistory, PanelPackageRestoreReview } from "./types.js";
4
4
  export declare class PanelManagementError extends Error {
5
5
  readonly status: number;
@@ -28,8 +28,17 @@ export interface PanelManagementOptions {
28
28
  repo: string;
29
29
  }, ref: string) => Promise<string>;
30
30
  }
31
+ /**
32
+ * Verified reads shared by the snapshots of one multi-project request, so the
33
+ * same package is not re-verified once per project. Create one per request.
34
+ */
35
+ export interface PanelSnapshotReads {
36
+ inspection: PanelAppInspectionReads;
37
+ digests: Map<string, Promise<string>>;
38
+ }
39
+ export declare function createPanelSnapshotReads(): PanelSnapshotReads;
31
40
  export declare function createPanelManagement(options: PanelManagementOptions): {
32
- snapshot: (appId?: string) => Promise<PanelSnapshot>;
41
+ snapshot: (appId?: string, reads?: PanelSnapshotReads) => Promise<PanelSnapshot>;
33
42
  packageHistory(context: PanelOperationContext, id: unknown, expected: unknown): Promise<PanelPackageHistory>;
34
43
  previewRestore(context: PanelOperationContext, id: unknown, digest: unknown, expected: unknown): Promise<PanelPackageRestoreReview>;
35
44
  cancelRestore(context: PanelOperationContext, token: unknown): Promise<{
@@ -3,7 +3,7 @@ import { panelPackageMutationMatches } from "./package-mutation.js";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { lstatSync, mkdirSync, statSync } from "node:fs";
5
5
  import { basename, extname, isAbsolute, join, resolve } from "node:path";
6
- import { SettingsManager, PanelAppAlreadyInstalledError, PanelAppInstallError, PanelAppReviewChangedError, assertSafePanelAppId, discoverGitPanelApps, installReviewedLocalPanelApp, invalidateSkillCache, listInstalledPanelApps, inspectProjectPanelApps, listRetainedPanelAppPackages, resolvePanelAppPackage, parsePanelAppPackagePins, currentPanelAppPackagePins, installedPanelAppUpdatedAt, retainInstalledPanelApp, userHome, panelAppsRoot, previewLocalPanelApp, resolveGitHubPanelAppCommit, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
6
+ import { SettingsManager, PanelAppAlreadyInstalledError, PanelAppInstallError, PanelAppReviewChangedError, assertSafePanelAppId, discoverGitPanelApps, installReviewedLocalPanelApp, invalidateSkillCache, listInstalledPanelApps, inspectProjectPanelApps, listRetainedPanelAppPackages, resolvePanelAppPackage, parsePanelAppPackagePins, currentPanelAppPackagePins, installedPanelAppUpdatedAt, retainInstalledPanelApp, userHome, panelAppsRoot, previewLocalPanelApp, inspectInstalledPanelAppDigest, resolveGitHubPanelAppCommit, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
7
7
  import { mutateJsonFile } from "@cjhyy/code-shell-core/internal";
8
8
  import { parseGithubUrl } from "@cjhyy/code-shell-core/internal/skills";
9
9
  const REVIEW_TTL = 8 * 60_000;
@@ -151,6 +151,9 @@ function assertRevision(revision) {
151
151
  if (typeof revision !== "string" || !/^[a-f0-9]{64}$/.test(revision))
152
152
  invalid();
153
153
  }
154
+ export function createPanelSnapshotReads() {
155
+ return { inspection: { packages: new Map() }, digests: new Map() };
156
+ }
154
157
  export function createPanelManagement(options) {
155
158
  const now = options.now ?? Date.now;
156
159
  const workspace = resolve(options.bindingCwd ?? options.cwd);
@@ -206,12 +209,17 @@ export function createPanelManagement(options) {
206
209
  const listApps = () => options.projectPackages
207
210
  ? inspectProjectPanelApps(workspace).then((result) => result.apps)
208
211
  : listInstalledPanelApps();
209
- async function inspectApp(app) {
212
+ async function inspectApp(app, reads) {
210
213
  const info = lstatSync(app.installPath);
211
214
  if (info.isSymbolicLink() || !info.isDirectory())
212
215
  throw new PanelManagementError(403, "unsafe_path", "面板安装目录不支持链接。");
213
- const preview = await previewLocalPanelApp({ kind: "dir", path: app.installPath });
214
- return { app, digest: preview.reviewToken };
216
+ // Same digest a dir preview reports, without re-hashing unchanged installed bytes.
217
+ let digest = reads?.digests.get(app.installPath);
218
+ if (!digest) {
219
+ digest = inspectInstalledPanelAppDigest(app.installPath);
220
+ reads?.digests.set(app.installPath, digest);
221
+ }
222
+ return { app, digest: await digest };
215
223
  }
216
224
  async function selectedApp(id) {
217
225
  const app = (await listApps()).find((item) => item.id === id);
@@ -240,12 +248,12 @@ export function createPanelManagement(options) {
240
248
  commit: SHA.test(parsed.source.ref ?? "") ? parsed.source.ref : "",
241
249
  };
242
250
  }
243
- async function snapshot(appId) {
251
+ async function snapshot(appId, reads) {
244
252
  if (appId !== undefined)
245
253
  assertSafePanelAppId(appId);
246
254
  options.assertBinding?.();
247
255
  const inspected = options.projectPackages
248
- ? await inspectProjectPanelApps(workspace, appId)
256
+ ? await inspectProjectPanelApps(workspace, appId, reads?.inspection)
249
257
  : undefined;
250
258
  const apps = inspected?.apps ?? (await listInstalledPanelApps(appId));
251
259
  options.assertBinding?.();
@@ -268,7 +276,7 @@ export function createPanelManagement(options) {
268
276
  .digest("hex"),
269
277
  }));
270
278
  for (const listed of apps) {
271
- const { app, digest } = await inspectApp(listed);
279
+ const { app, digest } = await inspectApp(listed, reads);
272
280
  const source = savedSource(app, saved);
273
281
  const { installPath: _path, source: _source, installedAt: _installedAt, lastUpdated: _lastUpdated, ...publicApp } = app;
274
282
  const support = compatibility(app);
@@ -125,8 +125,15 @@ export class ResourceLibrary {
125
125
  async importFile(scope, sourcePath, options = {}) {
126
126
  const verify = async () => {
127
127
  options.signal?.throwIfAborted();
128
- await this.authorize(scope);
129
- await options.assertAuthorized?.();
128
+ // Both checks must complete before any source read or publication. They
129
+ // are independent; a captured directory may itself recheck the same scope.
130
+ const checks = await Promise.allSettled([
131
+ this.authorize(scope),
132
+ Promise.resolve().then(() => options.assertAuthorized?.()),
133
+ ]);
134
+ for (const check of checks)
135
+ if (check.status === "rejected")
136
+ throw check.reason;
130
137
  options.signal?.throwIfAborted();
131
138
  };
132
139
  await verify();
@@ -400,7 +407,14 @@ export class ResourceLibrary {
400
407
  if (!info.isFile() || info.size !== asset.bytes)
401
408
  throw new Error("Stored media content changed");
402
409
  await this.authorize(scope);
403
- return { status, headers, body: handle.createReadStream({ ...bounds, autoClose: true }) };
410
+ return {
411
+ status,
412
+ headers,
413
+ // Bulk readers authorize every delivered block. Bound read-ahead to
414
+ // 1 MiB so large downloads and integrity rechecks do not repeat a full
415
+ // project/package inspection for each default 64 KiB filesystem read.
416
+ body: handle.createReadStream({ ...bounds, autoClose: true, highWaterMark: 1024 * 1024 }),
417
+ };
404
418
  }
405
419
  catch (error) {
406
420
  await handle.close();
@@ -1606,7 +1606,7 @@ export function createPanelRuntime(options) {
1606
1606
  error(400, "不支持这个服务端目录。");
1607
1607
  if (method === "filesystem.pickDirectory" &&
1608
1608
  !(await confirm(grant, "使用服务端下载目录?", directory)))
1609
- return null;
1609
+ return { cancelled: true };
1610
1610
  await options.authorizePanelDirectory?.(grant.app, options.bindingCwd ?? options.cwd, options.cwd);
1611
1611
  if (name !== "project")
1612
1612
  await mkdir(directory, { recursive: true, mode: 0o700 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.9.49",
3
+ "version": "0.9.51",
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",
@@ -62,14 +62,14 @@
62
62
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
63
63
  },
64
64
  "dependencies": {
65
- "@cjhyy/code-shell-capability-coding": "0.9.49",
66
- "@cjhyy/code-shell-core": "0.9.49",
67
- "@cjhyy/code-shell-web": "0.9.49",
65
+ "@cjhyy/code-shell-capability-coding": "0.9.51",
66
+ "@cjhyy/code-shell-core": "0.9.51",
67
+ "@cjhyy/code-shell-web": "0.9.51",
68
68
  "@modelcontextprotocol/sdk": "^1.31.0",
69
69
  "@types/ws": "^8.18.1",
70
70
  "ws": "^8.21.0",
71
- "@cjhyy/code-shell-link": "0.9.49",
72
- "@cjhyy/code-shell-host": "0.9.49"
71
+ "@cjhyy/code-shell-link": "0.9.51",
72
+ "@cjhyy/code-shell-host": "0.9.51"
73
73
  },
74
74
  "engines": {
75
75
  "node": ">=20.10"