@cjhyy/code-shell-server 0.9.47 → 0.9.48

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 CHANGED
@@ -83,6 +83,10 @@ Desktop Web 和 Hub 在注销或显式撤销会话时,会关闭该会话尚未
83
83
  响应,包括正在下载的文件,并停止源文件读取、释放句柄。其他会话的下载不受影响;
84
84
  已经返回接受结果的后台任务仍由原来的任务生命周期管理。注销请求本身仍返回成功。
85
85
 
86
+ Hub 认证每次读取磁盘上的当前会话;只有需要更新活动时间时才获取写锁,并在锁内
87
+ 重新验证撤销与过期状态。Hub 与 Mobile 静态文件按打开时的大小流式发送,`HEAD`
88
+ 只返回元数据;客户端断开会停止读取。首页和 SPA 回退也遵守静态目录边界。
89
+
86
90
  ## code-shell-serve — 个人 Hub Web host
87
91
 
88
92
  在任意机器上把一个 workspace 变成浏览器可访问的 CodeShell:
@@ -40,6 +40,7 @@ export declare class HubAuthStore {
40
40
  }): Promise<HubSessionGrant>;
41
41
  login(input: HubLoginInput, replaceToken?: string): Promise<HubSessionGrant>;
42
42
  authenticate(token: string | undefined): HubSession | null;
43
+ private findSession;
43
44
  listSessions(): HubSession[];
44
45
  /** Offline restore must not resurrect sessions revoked after the snapshot. */
45
46
  revokeAllSessions(): void;
@@ -49,6 +50,7 @@ export declare class HubAuthStore {
49
50
  private toPublic;
50
51
  private mutate;
51
52
  private ensureDirectory;
53
+ private assertDirectory;
52
54
  private read;
53
55
  private write;
54
56
  }
@@ -93,20 +93,36 @@ export class HubAuthStore {
93
93
  authenticate(token) {
94
94
  if (!token || !TOKEN_PATTERN.test(token))
95
95
  return null;
96
+ // Writers publish by atomic rename. Read the committed record on every
97
+ // request, but only contend for the writer lock when activity needs saving.
98
+ this.assertDirectory();
99
+ const tokenHash = hashToken(token);
100
+ const record = this.read();
101
+ const now = this.now();
102
+ const session = this.findSession(record, tokenHash, now);
103
+ if (!session)
104
+ return null;
105
+ if (now - session.lastSeenAt < 60_000)
106
+ return this.toPublic(record, session);
96
107
  return this.mutate((record) => {
97
108
  const now = this.now();
98
- const tokenHash = hashToken(token);
99
- const session = record.sessions.find((row) => safeHashEqual(row.tokenHash, tokenHash));
100
- if (!record.account || !session || session.expiresAt <= now || session.createdAt > now) {
109
+ // Recheck after taking the lock: a peer may have revoked, replaced or
110
+ // refreshed the session since the unlocked read. Never write that snapshot.
111
+ const session = this.findSession(record, tokenHash, now);
112
+ if (!session)
101
113
  return { result: null, changed: false };
102
- }
103
- // Persist activity at most once per minute; validation always reads disk.
104
114
  const changed = now - session.lastSeenAt >= 60_000;
105
115
  if (changed)
106
116
  session.lastSeenAt = now;
107
117
  return { result: this.toPublic(record, session), changed };
108
118
  });
109
119
  }
120
+ findSession(record, tokenHash, now) {
121
+ if (!record.account)
122
+ return null;
123
+ const session = record.sessions.find((row) => safeHashEqual(row.tokenHash, tokenHash));
124
+ return session && session.expiresAt > now && session.createdAt <= now ? session : null;
125
+ }
110
126
  listSessions() {
111
127
  const record = this.read();
112
128
  const now = this.now();
@@ -194,10 +210,13 @@ export class HubAuthStore {
194
210
  ensureDirectory() {
195
211
  const parent = dirname(this.filePath);
196
212
  mkdirSync(parent, { recursive: true, mode: 0o700 });
197
- const metadata = lstatSync(parent);
213
+ this.assertDirectory();
214
+ chmodSync(parent, 0o700);
215
+ }
216
+ assertDirectory() {
217
+ const metadata = lstatSync(dirname(this.filePath));
198
218
  if (!metadata.isDirectory() || metadata.isSymbolicLink())
199
219
  throw new Error("Invalid Hub auth directory");
200
- chmodSync(parent, 0o700);
201
220
  }
202
221
  read(allowMissing = false) {
203
222
  let descriptor;
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { stopRoomProcess } from "./room-process-stop.js";
2
+ import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
3
3
  import { createInterface } from "node:readline";
4
4
  import { pathWithCommonBins } from "./path-bins.js";
5
5
  import { parseCodexJsonLine, extractThreadId } from "./codex-parse.js";
@@ -109,6 +109,8 @@ export class CodexRoomAgent {
109
109
  stdio: ["pipe", "pipe", "pipe"],
110
110
  });
111
111
  this.child = child;
112
+ // Settle on `close` so trailing output is read, without waiting on grandchildren.
113
+ releaseStdioAfterExit(child);
112
114
  if (child.stdout) {
113
115
  const rl = createInterface({ input: child.stdout });
114
116
  rl.on("line", (line) => {
@@ -33,6 +33,12 @@ export type DispatchMobileChatTurnResult = {
33
33
  } | {
34
34
  ok: false;
35
35
  message: string;
36
+ uncertain?: false;
37
+ } | {
38
+ ok: false;
39
+ message: string;
40
+ uncertain: true;
41
+ clientMessageId: string;
36
42
  };
37
43
  /** Transaction boundary for mobile chat: workspace → canonical stage → worker ack → finalize. */
38
44
  export declare function dispatchMobileChatTurn(input: DispatchMobileChatTurnInput): Promise<DispatchMobileChatTurnResult>;
@@ -63,11 +63,24 @@ export async function dispatchMobileChatTurn(input) {
63
63
  : await injectMobileRunAndAwaitAcceptance(input.bridge, request, input.meta);
64
64
  }
65
65
  catch {
66
- acceptance = { ok: false, message: "The selected runtime rejected the submission" };
66
+ acceptance = {
67
+ ok: false,
68
+ uncertain: true,
69
+ message: "The selected runtime did not confirm the submission",
70
+ };
67
71
  }
68
72
  if (!acceptance.ok) {
73
+ // Worker input refers to the canonical staged files, not these temporary
74
+ // upload spools. Keep the staged files for a possibly accepted run and
75
+ // release only the claims so the same intent can retry its attachments.
76
+ // A late acceptance may never be observed, so uncertain files also need
77
+ // sent retention rather than expiring as abandoned drafts after one day.
78
+ if (acceptance.uncertain)
79
+ await (input.markSent ?? markAttachmentsSent)(cwd, input.sessionId, materialized.metas).catch(() => undefined);
69
80
  await settleClaims(input.uploads, input.deviceId, materialized.claims, "release");
70
- return { ok: false, message: acceptance.message };
81
+ return acceptance.uncertain
82
+ ? { ok: false, message: acceptance.message, uncertain: true, clientMessageId }
83
+ : { ok: false, message: acceptance.message };
71
84
  }
72
85
  await (input.markSent ?? markAttachmentsSent)(cwd, input.sessionId, materialized.metas).catch(() => undefined);
73
86
  await settleClaims(input.uploads, input.deviceId, materialized.claims, "finalize");
@@ -13,6 +13,7 @@ export type MobileRunAcceptance = {
13
13
  ok: false;
14
14
  message: string;
15
15
  code?: number;
16
+ uncertain?: true;
16
17
  };
17
18
  /** Inject agent/run and wait for its request-correlated validation/queue acknowledgement. */
18
19
  export declare function injectMobileRunAndAwaitAcceptance(bridge: MobileRunBridge, request: MobileRunRequest, meta: WorkerFrameMeta, timeoutMs?: number): Promise<MobileRunAcceptance>;
@@ -2,7 +2,6 @@
2
2
  export function injectMobileRunAndAwaitAcceptance(bridge, request, meta, timeoutMs = 5_000) {
3
3
  return new Promise((resolve) => {
4
4
  let settled = false;
5
- let timer;
6
5
  const finish = (result) => {
7
6
  if (settled)
8
7
  return;
@@ -40,17 +39,26 @@ export function injectMobileRunAndAwaitAcceptance(bridge, request, meta, timeout
40
39
  // Not JSON or not part of this request.
41
40
  }
42
41
  });
43
- timer = setTimeout(() => finish({ ok: false, message: "worker did not acknowledge the run" }), timeoutMs);
42
+ const timer = setTimeout(() => finish({ ok: false, uncertain: true, message: "worker did not acknowledge the run" }), timeoutMs);
43
+ let injecting = false;
44
44
  try {
45
- bridge.injectWorkerMessage(JSON.stringify({
45
+ const line = JSON.stringify({
46
46
  jsonrpc: "2.0",
47
47
  id: request.id,
48
48
  method: "agent/run",
49
49
  params: request.params,
50
- }), meta);
50
+ });
51
+ injecting = true;
52
+ bridge.injectWorkerMessage(line, meta);
51
53
  }
52
54
  catch (error) {
53
- finish({ ok: false, message: error instanceof Error ? error.message : String(error) });
55
+ // An injection can fail after queuing bytes. Only serialization failure
56
+ // proves that this request never reached the transport.
57
+ finish({
58
+ ok: false,
59
+ message: error instanceof Error ? error.message : String(error),
60
+ ...(injecting ? { uncertain: true } : {}),
61
+ });
54
62
  }
55
63
  });
56
64
  }
@@ -38,12 +38,11 @@ export declare function mobileEntryRedirect(reqUrl: string): string | null;
38
38
  * can see through it. Guarding that would need inode/device policy, and anyone
39
39
  * able to hardlink into the served root can already write files there.
40
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. */
41
+ * The returned path is the REQUESTED one, not the realpath: the resolved-name
42
+ * contract is relied on elsewhere (on macOS the realpath of a /var root is
43
+ * /private/var, which would change every result). This is only a routing check;
44
+ * both HTTP callers open and revalidate the selected file through
45
+ * streamStaticFile before returning bytes. */
47
46
  export declare function resolveSafe(root: string, subPath: string): string | null;
48
47
  export interface MobileStaticOptions {
49
48
  /** Absolute path to the built mobile app (out/mobile). */
@@ -1,7 +1,7 @@
1
- import { createReadStream, existsSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, realpathSync, statSync } from "node:fs";
2
2
  import { isAbsolute, join, normalize, relative, resolve, sep, extname } from "node:path";
3
3
  import { request as httpRequest } from "node:http";
4
- import { contentTypeFor } from "../static-files.js";
4
+ import { contentTypeFor, streamStaticFile } from "../static-files.js";
5
5
  /**
6
6
  * Serves the built mobile web app (out/mobile) as static assets for the
7
7
  * `/mobile` route family, replacing the old inline `mobileRemoteHtml()` string.
@@ -61,12 +61,11 @@ function isContained(root, target) {
61
61
  * can see through it. Guarding that would need inode/device policy, and anyone
62
62
  * able to hardlink into the served root can already write files there.
63
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. */
64
+ * The returned path is the REQUESTED one, not the realpath: the resolved-name
65
+ * contract is relied on elsewhere (on macOS the realpath of a /var root is
66
+ * /private/var, which would change every result). This is only a routing check;
67
+ * both HTTP callers open and revalidate the selected file through
68
+ * streamStaticFile before returning bytes. */
70
69
  export function resolveSafe(root, subPath) {
71
70
  const rootResolved = resolve(root);
72
71
  const rel = normalize(subPath || "index.html");
@@ -77,11 +76,11 @@ export function resolveSafe(root, subPath) {
77
76
  const full = resolve(join(rootResolved, rel));
78
77
  if (!isContained(rootResolved, full))
79
78
  return null;
80
- if (!existsSync(full) || !statSync(full).isFile())
81
- return null;
82
79
  // realpathSync throws on a dangling link or a race-deleted file; a missing
83
80
  // target is a 404 through the normal contract, never a 500.
84
81
  try {
82
+ if (!existsSync(full) || !statSync(full).isFile())
83
+ return null;
85
84
  if (!isContained(realpathSync(rootResolved), realpathSync(full)))
86
85
  return null;
87
86
  }
@@ -143,15 +142,15 @@ export function serveMobile(req, res, opts) {
143
142
  // stale index.html would reference deleted assets → 404 → blank page until a
144
143
  // hard refresh.
145
144
  const isHtml = type.startsWith("text/html");
146
- res.writeHead(200, {
147
- "content-type": type,
148
- "cache-control": isHtml
145
+ void streamStaticFile(req, res, {
146
+ rootDir: opts.rootDir,
147
+ filePath: file,
148
+ cacheControl: isHtml
149
149
  ? "no-store"
150
150
  : sub.startsWith("assets/")
151
151
  ? "public, max-age=31536000, immutable"
152
152
  : "no-cache",
153
153
  });
154
- createReadStream(file).pipe(res);
155
154
  }
156
155
  /** Dev-only: forward the request to the mobile vite dev server. The mobile vite
157
156
  * config sets base "/mobile/", so vite serves its HMR client, module graph, and
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { stopRoomProcess } from "./room-process-stop.js";
2
+ import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
3
3
  import { createInterface } from "node:readline";
4
4
  import { pathWithCommonBins } from "./path-bins.js";
5
5
  /**
@@ -149,6 +149,8 @@ export class ResidentAgentProcess {
149
149
  stdio: ["pipe", "pipe", "pipe"],
150
150
  });
151
151
  this.child = child;
152
+ // Settle on `close` so trailing output is read, without waiting on grandchildren.
153
+ releaseStdioAfterExit(child);
152
154
  if (child.stdout) {
153
155
  const rl = createInterface({ input: child.stdout });
154
156
  rl.on("line", (line) => {
@@ -119,6 +119,10 @@ export interface RoomManagerOptions {
119
119
  * Returning null means the transcript is absent. The returned identity is
120
120
  * checked again here so a stale or mismatched locator fails closed. */
121
121
  resolveLinkedSession?: (target: Readonly<LinkedSessionTarget>) => LinkedSessionTarget | null | undefined;
122
+ /** Throws while a background task (DriveAgent) may still be writing this
123
+ * external session. Checked before any resident agent can start on it, so
124
+ * desktop and phone entry points share one ownership gate. */
125
+ assertExternalSessionAvailable?: (externalSessionId: string, kind: RoomKind) => void;
122
126
  /** Called whenever a room gains a new persisted message (push to phone). */
123
127
  onMessage: (roomId: string, msg: RoomMessage) => void;
124
128
  /** Called when a room's resident agent requests tool-use approval. For
@@ -777,6 +777,8 @@ export class RoomManager {
777
777
  * so a given claude session maps to exactly one room. Returns the room id.
778
778
  */
779
779
  openForSession(claudeSessionId, cwd, mode, kind = "claude-code") {
780
+ if (claudeSessionId)
781
+ this.opts.assertExternalSessionAvailable?.(claudeSessionId, kind);
780
782
  // Reuse must match BOTH id and kind: a codex thread id and a claude session
781
783
  // id live in the same `claudeSessionId` field, so a bare id match could
782
784
  // otherwise hand back a claude room when a codex room was asked for.
@@ -851,6 +853,7 @@ export class RoomManager {
851
853
  * silently start a different session. */
852
854
  takeOverLinkedSession(roomId, externalSessionId, cwd, kind) {
853
855
  const target = this.requireLinkedSessionTarget({ externalSessionId, cwd, kind });
856
+ this.opts.assertExternalSessionAvailable?.(target.externalSessionId, target.kind);
854
857
  const meta = this.getRoom(roomId);
855
858
  if (!meta ||
856
859
  meta.claudeSessionId !== target.externalSessionId ||
@@ -969,6 +972,8 @@ export class RoomManager {
969
972
  if (meta.linkedSessionMode === "observe-only")
970
973
  return { status: "observing" };
971
974
  if (!this.agents.has(id)) {
975
+ if (meta.claudeSessionId)
976
+ this.opts.assertExternalSessionAvailable?.(meta.claudeSessionId, meta.kind);
972
977
  let agent = undefined;
973
978
  agent = this.opts.createAgent(meta, (event) => {
974
979
  // stop() sends a signal; an old process can still drain output or emit
@@ -1,3 +1,11 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
2
  /** Retire an unusable room transport without leaving an unresponsive CLI alive. */
3
3
  export declare function stopRoomProcess(child: ChildProcess): void;
4
+ /**
5
+ * `close` waits for every holder of the child's stdout/stderr. A grandchild the
6
+ * CLI backgrounded with inherited stdio (a dev server, say) keeps them open
7
+ * after the CLI exits, so the room would never settle. Once the CLI has exited
8
+ * and its buffered output had a grace period to drain, release the pipes;
9
+ * the grandchild itself is left running.
10
+ */
11
+ export declare function releaseStdioAfterExit(child: ChildProcess, graceMs?: number): void;
@@ -26,3 +26,21 @@ export function stopRoomProcess(child) {
26
26
  timer.unref();
27
27
  child.once("exit", () => clearTimeout(timer));
28
28
  }
29
+ /**
30
+ * `close` waits for every holder of the child's stdout/stderr. A grandchild the
31
+ * CLI backgrounded with inherited stdio (a dev server, say) keeps them open
32
+ * after the CLI exits, so the room would never settle. Once the CLI has exited
33
+ * and its buffered output had a grace period to drain, release the pipes;
34
+ * the grandchild itself is left running.
35
+ */
36
+ export function releaseStdioAfterExit(child, graceMs = 1_000) {
37
+ child.once("exit", () => {
38
+ const timer = setTimeout(() => {
39
+ child.stdout?.destroy();
40
+ child.stderr?.destroy();
41
+ child.stdin?.destroy();
42
+ }, graceMs);
43
+ timer.unref();
44
+ child.once("close", () => clearTimeout(timer));
45
+ });
46
+ }
@@ -39,6 +39,8 @@ export declare class PanelExecutionGate {
39
39
  register(source: () => Iterable<PanelExecutionScope>): () => void;
40
40
  /** Executions that a change matching `matches` would wait for, grouped by holder. */
41
41
  blockers(matches: (scope: PanelExecutionScope) => boolean): PanelExecutionBlocker[];
42
- mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>): Promise<T>;
42
+ mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>,
43
+ /** Trusted Host preparation that validates a review before draining existing work. */
44
+ drain?: () => Promise<void>): Promise<T>;
43
45
  }
44
46
  export declare const panelExecutionGate: PanelExecutionGate;
@@ -77,15 +77,23 @@ export class PanelExecutionGate {
77
77
  }
78
78
  return [...grouped.values()];
79
79
  }
80
- async mutate(matches, work) {
80
+ async mutate(matches, work,
81
+ /** Trusted Host preparation that validates a review before draining existing work. */
82
+ drain) {
81
83
  if (this.mutations.size)
82
84
  throw new PanelExecutionBusyError("另一个面板修改正在进行,请稍后重试。");
83
85
  // No await between the snapshot and installing the admission barrier.
84
86
  const blockers = this.blockers(matches);
85
- if (blockers.length)
87
+ if (blockers.length && !drain)
86
88
  throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", blockers);
87
89
  this.mutations.add(matches);
88
90
  try {
91
+ if (drain) {
92
+ await drain();
93
+ const remaining = this.blockers(matches);
94
+ if (remaining.length)
95
+ throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", remaining);
96
+ }
89
97
  return await work();
90
98
  }
91
99
  finally {
@@ -39,6 +39,7 @@ export declare function createPanelHttp(options: PanelManagementHttpOptions & {
39
39
  install(context: import("./types.js").PanelOperationContext, token: unknown, bind?: boolean, approval?: {
40
40
  overwrite?: boolean;
41
41
  expectedId?: string;
42
+ releaseExecutions?: (appId: string, matches: (scope: import("./execution-gate.js").PanelExecutionScope) => boolean) => Promise<void>;
42
43
  }): Promise<{
43
44
  id: string;
44
45
  packageDigest: string | undefined;
@@ -30,6 +30,7 @@ export declare function createPanelManagementHttp(options: PanelManagementHttpOp
30
30
  install(context: PanelOperationContext, token: unknown, bind?: boolean, approval?: {
31
31
  overwrite?: boolean;
32
32
  expectedId?: string;
33
+ releaseExecutions?: (appId: string, matches: (scope: import("./execution-gate.js").PanelExecutionScope) => boolean) => Promise<void>;
33
34
  }): Promise<{
34
35
  id: string;
35
36
  packageDigest: string | undefined;
@@ -1,3 +1,4 @@
1
+ import { type PanelExecutionScope } from "./execution-gate.js";
1
2
  import { type PanelAppPreview, type PanelAppSourceInput } from "@cjhyy/code-shell-core";
2
3
  import type { PanelCompatibility, PanelDiscovery, PanelOperationContext, PanelReview, PanelProjectReview, PanelSnapshot, PanelPackageHistory, PanelPackageRestoreReview } from "./types.js";
3
4
  export declare class PanelManagementError extends Error {
@@ -50,6 +51,8 @@ export declare function createPanelManagement(options: PanelManagementOptions):
50
51
  install(context: PanelOperationContext, token: unknown, bind?: boolean, approval?: {
51
52
  overwrite?: boolean;
52
53
  expectedId?: string;
54
+ /** Trusted Host callback, never supplied by an HTTP request body. */
55
+ releaseExecutions?: (appId: string, matches: (scope: PanelExecutionScope) => boolean) => Promise<void>;
53
56
  }): Promise<{
54
57
  id: string;
55
58
  packageDigest: string | undefined;
@@ -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, 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, 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;
@@ -177,14 +177,17 @@ export function createPanelManagement(options) {
177
177
  .update(JSON.stringify({
178
178
  app,
179
179
  digest,
180
- ...(options.projectPackages
181
- ? { pin: parsePanelAppPackagePins(project ?? projectSettings())[app.id] ?? null }
182
- : {}),
180
+ ...(options.projectPackages ? { pin: selectedPin(app.id, project) } : {}),
183
181
  bound: current.boundApps.has(app.id),
184
182
  disabled: current.globalDisabledApps.has(app.id),
185
183
  }))
186
184
  .digest("hex");
187
185
  }
186
+ /** The pinned package identity; when it was pinned does not change the selection. */
187
+ function selectedPin(id, project) {
188
+ const pin = parsePanelAppPackagePins(project ?? projectSettings())[id];
189
+ return pin ? { version: pin.version, packageDigest: pin.packageDigest } : null;
190
+ }
188
191
  function projectSettings() {
189
192
  return new SettingsManager(workspace, "full").getRawForScope("project", workspace, {
190
193
  strict: true,
@@ -291,7 +294,7 @@ export function createPanelManagement(options) {
291
294
  // Never attach a new revision to stale displayed binding state. File and
292
295
  // package inspection above can yield to another device's mutation.
293
296
  for (const app of [...panels, ...issues]) {
294
- const pin = options.projectPackages ? parsePanelAppPackagePins(project)[app.id] : undefined;
297
+ const pin = options.projectPackages ? currentPanelAppPackagePins(project)[app.id] : undefined;
295
298
  if (projectState(app.id, project) !== projectState(app.id) ||
296
299
  current.globalDisabledApps.has(app.id) !== policy().globalDisabledApps.has(app.id) ||
297
300
  (pin && (pin.packageDigest !== app.packageDigest || pin.version !== app.version)))
@@ -484,7 +487,7 @@ export function createPanelManagement(options) {
484
487
  return value;
485
488
  });
486
489
  }
487
- async function mutate(context, change, work) {
490
+ async function mutate(context, change, work, prepare) {
488
491
  const expected = generation(context.ownerId);
489
492
  const guard = () => assertAuthorized(context, expected);
490
493
  const run = async () => {
@@ -493,7 +496,11 @@ export function createPanelManagement(options) {
493
496
  await guard();
494
497
  return work(guard);
495
498
  };
496
- return panelExecutionGate.mutate(panelPackageMutationMatches({ ...change, projectPath: options.bindingCwd ?? options.cwd }), () => (options.withMutation ? options.withMutation(write) : write()));
499
+ const matches = panelPackageMutationMatches({
500
+ ...change,
501
+ projectPath: options.bindingCwd ?? options.cwd,
502
+ });
503
+ return panelExecutionGate.mutate(matches, () => (options.withMutation ? options.withMutation(write) : write()), prepare ? () => prepare(guard, matches) : undefined);
497
504
  };
498
505
  const next = catalogMutation.then(run, run);
499
506
  catalogMutation = next.catch(() => undefined);
@@ -524,7 +531,20 @@ export function createPanelManagement(options) {
524
531
  if (bound) {
525
532
  if (!app.packageDigest)
526
533
  throw new Error("Cannot bind an unverified Panel package");
527
- pins[app.id] = { version: app.version, packageDigest: app.packageDigest };
534
+ const existing = pins[app.id];
535
+ // Rebinding the package a current pin already selects changes nothing, so
536
+ // it must not invalidate other devices' revisions or pending reviews.
537
+ const unchanged = existing?.version === app.version &&
538
+ existing.packageDigest === app.packageDigest &&
539
+ !!currentPanelAppPackagePins({ panelAppPins: { [app.id]: existing } })[app.id];
540
+ if (!unchanged)
541
+ pins[app.id] = {
542
+ version: app.version,
543
+ packageDigest: app.packageDigest,
544
+ // Holds this project only until the next global install or update, so it
545
+ // must be strictly later than the install it was chosen after.
546
+ pinnedAt: new Date(Math.max(now(), (installedPanelAppUpdatedAt(app.id) ?? 0) + 1)).toISOString(),
547
+ };
528
548
  }
529
549
  else
530
550
  delete pins[app.id];
@@ -735,12 +755,18 @@ export function createPanelManagement(options) {
735
755
  await assertAuthorized(context, held.generation);
736
756
  if (approval?.expectedId && approval.expectedId !== held.public.preview.id)
737
757
  invalid();
758
+ if (approval?.releaseExecutions && held.public.kind !== "update")
759
+ invalid();
738
760
  if (approval && held.public.kind === "update" && !approval.overwrite)
739
761
  throw new PanelManagementError(409, "already_installed", "这个面板已经安装,请确认项目更新。");
740
- return mutate(context, { appId: held.public.preview.id, kind: held.public.kind }, async (guard) => {
741
- await assertAuthorized(context, held.generation);
742
- if (reviews.get(token) !== held || held.public.expiresAt <= now())
762
+ let admitted = false;
763
+ const assertReviewLease = () => {
764
+ if (!admitted && (reviews.get(token) !== held || held.public.expiresAt <= now()))
743
765
  throw new PanelManagementError(409, "review_expired", "预览已失效,请重新检查来源。");
766
+ };
767
+ const validateReview = async (guard) => {
768
+ await assertAuthorized(context, held.generation);
769
+ assertReviewLease();
744
770
  const id = held.public.preview.id;
745
771
  if (held.public.kind === "update") {
746
772
  const current = await selectedApp(id);
@@ -753,6 +779,11 @@ export function createPanelManagement(options) {
753
779
  if (projectState(id) !== held.projectState)
754
780
  throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新预览。");
755
781
  await guard();
782
+ assertReviewLease();
783
+ };
784
+ return mutate(context, { appId: held.public.preview.id, kind: held.public.kind }, async (guard) => {
785
+ await validateReview(guard);
786
+ const id = held.public.preview.id;
756
787
  const installed = await installReviewedLocalPanelApp(held.source, held.digest, new Date(now()).toISOString(), {
757
788
  overwrite: held.public.kind === "update",
758
789
  expectedId: id,
@@ -782,7 +813,24 @@ export function createPanelManagement(options) {
782
813
  invalidateSkillCache();
783
814
  await options.onChanged?.(id, held.public.kind);
784
815
  return { id, packageDigest: installed.packageDigest };
785
- });
816
+ }, approval?.releaseExecutions
817
+ ? async (guard, matches) => {
818
+ await validateReview(guard);
819
+ // A local directory/ZIP may have changed since preview. Check its
820
+ // reviewed bytes before cancelling work, then recheck mutable
821
+ // project/catalog authority after the source read.
822
+ const preview = await previewLocalPanelApp(held.source);
823
+ if (preview.id !== held.public.preview.id || preview.reviewToken !== held.digest)
824
+ throw new PanelManagementError(409, "conflict", "来源已改变,请重新检查更新。");
825
+ await validateReview(guard);
826
+ // This admitted operation owns the review while its holders exit.
827
+ // TTL expiry during cancellation must not invalidate that consent;
828
+ // owner revocation and package/project changes are still checked.
829
+ admitted = true;
830
+ reviews.delete(token);
831
+ await approval.releaseExecutions(held.public.preview.id, matches);
832
+ }
833
+ : undefined);
786
834
  },
787
835
  async binding(context, id, bound, expected) {
788
836
  assertId(id);
@@ -793,11 +841,11 @@ export function createPanelManagement(options) {
793
841
  const current = await selectedApp(id);
794
842
  if (bound && !compatibility(current.app).supported)
795
843
  throw new PanelManagementError(400, "unsupported", "这个面板需要当前 Web 环境未提供的能力。");
796
- // Legacy installs need a retained copy before the project can pin them.
797
- // An already pinned project must keep its selected version, not the catalog's.
844
+ // An unpinned (or superseded) selection needs a retained copy before the
845
+ // project can pin it. A current pin must keep its selected version.
798
846
  const app = bound &&
799
847
  options.projectPackages &&
800
- !parsePanelAppPackagePins(projectSettings())[current.app.id]
848
+ !currentPanelAppPackagePins(projectSettings())[current.app.id]
801
849
  ? await retainInstalledPanelApp(current.app.id, current.app.packageDigest)
802
850
  : current.app;
803
851
  await guard();
@@ -4,5 +4,5 @@ export interface PanelPackageMutation {
4
4
  projectPath: string;
5
5
  kind: "install" | "update" | "binding" | "remove";
6
6
  }
7
- /** Pinned projects retain their bytes when a different project updates the catalog. */
7
+ /** A catalog install or update supersedes every project's pin, so it reaches every project. */
8
8
  export declare function panelPackageMutationMatches(change: PanelPackageMutation): (scope: PanelExecutionScope) => boolean;
@@ -1,6 +1,6 @@
1
- import { projectPanelAppPackagePins, resolvePanelAppBindingProjectPath, } from "@cjhyy/code-shell-core";
1
+ import { resolvePanelAppBindingProjectPath } from "@cjhyy/code-shell-core";
2
2
  import { panelExecutionProject } from "./execution-gate.js";
3
- /** Pinned projects retain their bytes when a different project updates the catalog. */
3
+ /** A catalog install or update supersedes every project's pin, so it reaches every project. */
4
4
  export function panelPackageMutationMatches(change) {
5
5
  const project = panelExecutionProject(resolvePanelAppBindingProjectPath(change.projectPath));
6
6
  return (scope) => {
@@ -11,13 +11,6 @@ export function panelPackageMutationMatches(change) {
11
11
  const bindingProject = resolvePanelAppBindingProjectPath(scope.projectPath);
12
12
  if (panelExecutionProject(bindingProject) === project)
13
13
  return true;
14
- if (change.kind === "binding")
15
- return false;
16
- try {
17
- return !projectPanelAppPackagePins(bindingProject, change.appId)[change.appId];
18
- }
19
- catch {
20
- return true;
21
- } // An unreadable project cannot prove that it is insulated.
14
+ return change.kind !== "binding";
22
15
  };
23
16
  }
@@ -17,15 +17,15 @@ import { describeEnvironment, environmentIdentity } from "../environment-identit
17
17
  // server restart spawns a fresh worker on the first inbound frame and the
18
18
  // browser re-lists sessions over the same protocol.
19
19
  import { createServer } from "node:http";
20
- import { readFileSync, existsSync } from "node:fs";
21
- import { basename, dirname, extname, join, resolve } from "node:path";
20
+ import { existsSync } from "node:fs";
21
+ import { basename, dirname, join, resolve } from "node:path";
22
22
  import { randomBytes, randomUUID } from "node:crypto";
23
23
  import { WebSocketServer } from "ws";
24
24
  import { CONTRACTS, SessionManager, SettingsManager } from "@cjhyy/code-shell-core";
25
25
  import { SessionOutputCoverageStore, readOutputJournal, readOutputJournalLegacyBase, } from "@cjhyy/code-shell-core/internal";
26
26
  import { AccessPasscode } from "../mobile-remote/access-passcode.js";
27
27
  import { resolveSafe } from "../mobile-remote/mobile-static.js";
28
- import { contentTypeFor } from "../static-files.js";
28
+ import { streamStaticFile } from "../static-files.js";
29
29
  import { createHubAuth } from "../hub/auth-http.js";
30
30
  import { ApprovalLeases } from "../hub/approval-lease.js";
31
31
  import { HubUploads, hubJson } from "../hub/uploads.js";
@@ -611,7 +611,7 @@ export async function startHeadlessServer(opts) {
611
611
  isAuthorized: configurationOptions.isAuthorized,
612
612
  })
613
613
  : undefined;
614
- const serveStatic = (req, res) => {
614
+ const serveStatic = async (req, res) => {
615
615
  const root = opts.staticRootDir;
616
616
  if (!root) {
617
617
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
@@ -635,27 +635,24 @@ export async function startHeadlessServer(opts) {
635
635
  res.end("not found");
636
636
  return;
637
637
  }
638
- filePath = join(root, "index.html");
639
- if (!existsSync(filePath)) {
638
+ filePath = resolveSafe(root, "index.html");
639
+ if (!filePath) {
640
640
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
641
641
  res.end("web app not built");
642
642
  return;
643
643
  }
644
644
  }
645
- try {
646
- const body = readFileSync(filePath);
647
- res.writeHead(200, {
648
- "content-type": contentTypeFor(extname(filePath)),
649
- "cache-control": "no-cache",
650
- });
651
- res.end(body);
652
- }
653
- catch {
654
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
655
- res.end("not found");
656
- }
645
+ await streamStaticFile(req, res, {
646
+ rootDir: root,
647
+ filePath,
648
+ cacheControl: "no-cache",
649
+ });
657
650
  };
658
651
  const handleHttp = async (req, res) => {
652
+ if (closing) {
653
+ hubJson(res, 503, { error: "server is closing" });
654
+ return;
655
+ }
659
656
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
660
657
  res.setHeader("x-content-type-options", "nosniff");
661
658
  res.setHeader("referrer-policy", "no-referrer");
@@ -678,14 +675,16 @@ export async function startHeadlessServer(opts) {
678
675
  return;
679
676
  }
680
677
  if (hubAuth) {
681
- if (await links?.handlePublic(req, res))
678
+ if ((await links?.handlePublic(req, res)) || closing || res.destroyed)
682
679
  return;
683
- if (await panels?.handleAssets(req, res))
680
+ if ((await panels?.handleAssets(req, res)) || closing || res.destroyed)
684
681
  return;
685
- if (await hubAuth.handle(req, res))
682
+ if ((await hubAuth.handle(req, res)) || closing || res.destroyed)
686
683
  return;
687
684
  if (pathname.startsWith("/api/")) {
688
685
  const session = await hubAuth.authenticate(req);
686
+ if (closing || res.destroyed)
687
+ return;
689
688
  if (!session) {
690
689
  hubJson(res, 401, { error: "login required" });
691
690
  return;
@@ -694,10 +693,6 @@ export async function startHeadlessServer(opts) {
694
693
  hubJson(res, 403, { error: "origin rejected" });
695
694
  return;
696
695
  }
697
- if (closing) {
698
- hubJson(res, 503, { error: "server is closing" });
699
- return;
700
- }
701
696
  responses.track(session.id, res);
702
697
  if (pathname === "/api/v1/environment" && req.method === "GET") {
703
698
  hubJson(res, 200, environment);
@@ -738,20 +733,37 @@ export async function startHeadlessServer(opts) {
738
733
  return;
739
734
  }
740
735
  // Public static shell contains no user data and supplies the setup/login page.
741
- serveStatic(req, res);
736
+ await serveStatic(req, res);
742
737
  return;
743
738
  }
744
739
  if (!passcode.gate(req, res))
745
740
  return;
746
- serveStatic(req, res);
741
+ await serveStatic(req, res);
747
742
  };
748
743
  const server = createServer((req, res) => {
749
744
  void handleHttp(req, res).catch(() => hubJson(res, 500, { error: "server request failed" }));
750
745
  });
746
+ // HTTP closeAllConnections excludes an upgrade awaiting authentication.
747
+ // Own accepted sockets until close or a completed transfer to WebSocket.
748
+ const connections = new Set();
749
+ server.on("connection", (socket) => {
750
+ connections.add(socket);
751
+ socket.once("close", () => connections.delete(socket));
752
+ if (closing)
753
+ socket.destroy();
754
+ });
751
755
  const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
752
756
  const upgrade = async (req, socket, head) => {
757
+ if (closing || socket.destroyed) {
758
+ socket.destroy();
759
+ return;
760
+ }
753
761
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
754
762
  const session = hubAuth ? await hubAuth.authenticate(req) : undefined;
763
+ if (closing || socket.destroyed) {
764
+ socket.destroy();
765
+ return;
766
+ }
755
767
  const allowed = hubAuth
756
768
  ? !!session && hubAuth.isOriginAllowed(req)
757
769
  : passcode.allows(req);
@@ -767,6 +779,7 @@ export async function startHeadlessServer(opts) {
767
779
  }
768
780
  const tabId = nextTabId++;
769
781
  wss.handleUpgrade(req, socket, head, (ws) => {
782
+ connections.delete(socket);
770
783
  tabs.add(ws);
771
784
  if (session)
772
785
  tabAuth.set(ws, { sessionId: session.id, request: req });
@@ -792,7 +805,7 @@ export async function startHeadlessServer(opts) {
792
805
  ws.close(1009, "payload too large");
793
806
  return;
794
807
  }
795
- if (closing) {
808
+ if (closing || ws.readyState !== ws.OPEN) {
796
809
  ws.close(1012, "server stopping");
797
810
  return;
798
811
  }
@@ -943,9 +956,13 @@ export async function startHeadlessServer(opts) {
943
956
  sendToTab(ws, hostQueryError(parsed.id ?? null, -32602, error.message), "attachments");
944
957
  return;
945
958
  }
946
- if (ws.readyState !== ws.OPEN || !(await hubAuth.authenticate(req)))
959
+ if (closing || ws.readyState !== ws.OPEN)
960
+ return;
961
+ if (!(await hubAuth.authenticate(req)))
947
962
  return;
948
963
  }
964
+ if (closing || ws.readyState !== ws.OPEN)
965
+ return;
949
966
  // Recheck after asynchronous staging/authentication; other tabs may
950
967
  // have filled the runtime's capacity while this request was suspended.
951
968
  if (hubAuth && parsed.method === "agent/run" && runningSessions.size >= 64) {
@@ -1060,6 +1077,8 @@ export async function startHeadlessServer(opts) {
1060
1077
  const workerLine = JSON.stringify(workerMessage);
1061
1078
  // Spawn-on-first-frame (idempotent): the browser's first request wakes
1062
1079
  // the worker, mirroring the renderer's spawn-on-agent/run semantics.
1080
+ if (closing || ws.readyState !== ws.OPEN)
1081
+ return;
1063
1082
  bridge.ensureWorker(opts.cwd);
1064
1083
  if (!bridge.canSend()) {
1065
1084
  workerUnavailable("agent worker is unavailable");
@@ -1114,6 +1133,58 @@ export async function startHeadlessServer(opts) {
1114
1133
  server.on("upgrade", (req, socket, head) => {
1115
1134
  void upgrade(req, socket, head).catch(() => socket.destroy());
1116
1135
  });
1136
+ let closePromise;
1137
+ const close = () => {
1138
+ if (closePromise)
1139
+ return closePromise;
1140
+ closing = true;
1141
+ closePromise = Promise.resolve().then(async () => {
1142
+ // Stop admission before any asynchronous resource/worker cleanup. A
1143
+ // reconnect during a slow worker exit must not prolong server shutdown.
1144
+ const webSocketsClosed = new Promise((resolve) => wss.close(() => resolve()));
1145
+ // Finish WebSocket teardown before the HTTP close callback, including on
1146
+ // Bun where closing HTTP before its upgraded sockets can strand it.
1147
+ const httpClosed = webSocketsClosed.then(() => new Promise((resolve) => {
1148
+ server.closeAllConnections?.();
1149
+ server.close(() => resolve());
1150
+ }));
1151
+ for (const tab of tabs)
1152
+ tab.terminate();
1153
+ for (const socket of connections)
1154
+ socket.destroy();
1155
+ tabs.clear();
1156
+ responses.clear();
1157
+ clearInterval(pendingResponseReaper);
1158
+ if (authReaper)
1159
+ clearInterval(authReaper);
1160
+ // A failed cleanup must not leave the remaining services or worker alive.
1161
+ const errors = [];
1162
+ const clean = async (action) => {
1163
+ try {
1164
+ await action();
1165
+ }
1166
+ catch (error) {
1167
+ errors.push(error);
1168
+ }
1169
+ };
1170
+ await clean(() => panelAutomations?.close());
1171
+ await clean(() => {
1172
+ hostRuntime.stop();
1173
+ });
1174
+ await clean(() => uploads?.close());
1175
+ await clean(() => skills?.close());
1176
+ await clean(() => mcp?.close());
1177
+ await clean(() => configuration?.close());
1178
+ await clean(() => links?.close());
1179
+ await clean(() => panels?.close());
1180
+ await clean(() => files?.close());
1181
+ await clean(() => bridge.stopAndWait());
1182
+ await Promise.all([httpClosed, webSocketsClosed]);
1183
+ if (errors.length)
1184
+ throw new AggregateError(errors, "Headless server shutdown failed");
1185
+ });
1186
+ return closePromise;
1187
+ };
1117
1188
  try {
1118
1189
  if (hubAuth) {
1119
1190
  automationWorker = createHubAutomationWorker({
@@ -1187,23 +1258,18 @@ export async function startHeadlessServer(opts) {
1187
1258
  });
1188
1259
  }
1189
1260
  catch (error) {
1190
- closing = true;
1191
- responses.clear();
1192
- await panelAutomations?.close();
1193
- clearInterval(pendingResponseReaper);
1194
- hostRuntime.stop();
1195
- if (authReaper)
1196
- clearInterval(authReaper);
1197
- await uploads?.close();
1198
- await skills?.close();
1199
- await mcp?.close();
1200
- configuration?.close();
1201
- links?.close();
1202
- await panels?.close();
1203
- files?.close();
1204
- await bridge.stopAndWait();
1205
- wss.close();
1206
- server.close();
1261
+ const failures = [error];
1262
+ try {
1263
+ await close();
1264
+ }
1265
+ catch (cleanupError) {
1266
+ failures.push(cleanupError);
1267
+ }
1268
+ if (failures.length > 1) {
1269
+ throw new AggregateError(failures, "Headless server startup and shutdown failed", {
1270
+ cause: error,
1271
+ });
1272
+ }
1207
1273
  throw error;
1208
1274
  }
1209
1275
  const address = server.address();
@@ -1220,39 +1286,7 @@ export async function startHeadlessServer(opts) {
1220
1286
  bridge,
1221
1287
  tabCount: () => tabs.size,
1222
1288
  pendingResponseCount: () => pendingWorkerResponses.size,
1223
- close: async () => {
1224
- closing = true;
1225
- responses.clear();
1226
- await panelAutomations?.close();
1227
- clearInterval(pendingResponseReaper);
1228
- if (authReaper)
1229
- clearInterval(authReaper);
1230
- await uploads?.close();
1231
- await skills?.close();
1232
- await mcp?.close();
1233
- configuration?.close();
1234
- links?.close();
1235
- await panels?.close();
1236
- files?.close();
1237
- for (const tab of tabs) {
1238
- try {
1239
- tab.terminate();
1240
- }
1241
- catch {
1242
- /* ignore */
1243
- }
1244
- }
1245
- tabs.clear();
1246
- await bridge.stopAndWait();
1247
- await new Promise((resolve) => {
1248
- wss.close(() => {
1249
- server.close(() => resolve());
1250
- });
1251
- // server.close() alone waits out keep-alive HTTP sockets forever;
1252
- // drop them so shutdown is prompt.
1253
- server.closeAllConnections?.();
1254
- });
1255
- },
1289
+ close,
1256
1290
  };
1257
1291
  }
1258
1292
  function webSocketPayloadBytes(data) {
@@ -1 +1,8 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
1
2
  export declare function contentTypeFor(extname: string): string;
3
+ /** Stream a pinned regular file, bounded by its opened size and the response lifetime. */
4
+ export declare function streamStaticFile(req: IncomingMessage, res: ServerResponse, options: {
5
+ rootDir: string;
6
+ filePath: string;
7
+ cacheControl: string;
8
+ }): Promise<void>;
@@ -3,6 +3,11 @@
3
3
  // Shared static-file primitives for the two HTTP hosts (pairing host's
4
4
  // /mobile static root and the headless serve SPA root). Keep this file free
5
5
  // of host wiring — MIME map + helpers only.
6
+ import { constants } from "node:fs";
7
+ import { createHash } from "node:crypto";
8
+ import { open, realpath, stat } from "node:fs/promises";
9
+ import { extname, isAbsolute, relative, sep } from "node:path";
10
+ import { pipeline } from "node:stream/promises";
6
11
  const CONTENT_TYPES = {
7
12
  ".html": "text/html; charset=utf-8",
8
13
  ".js": "text/javascript; charset=utf-8",
@@ -24,3 +29,112 @@ const CONTENT_TYPES = {
24
29
  export function contentTypeFor(extname) {
25
30
  return CONTENT_TYPES[extname.toLowerCase()] ?? "application/octet-stream";
26
31
  }
32
+ /** RFC 9110 entity-tag lists allow commas inside opaque tags and empty list items. */
33
+ function matchesIfNoneMatch(value, etag) {
34
+ if (typeof value !== "string")
35
+ return false;
36
+ const header = value.replace(/^[ \t]+|[ \t]+$/g, "");
37
+ if (header === "*")
38
+ return true;
39
+ const tag = /(?:W\/)?("[\x21\x23-\x7e\x80-\xff]*")/y;
40
+ const opaque = etag.slice(2);
41
+ let position = 0;
42
+ let matches = false;
43
+ while (position < header.length) {
44
+ while (/[ \t,]/.test(header[position] ?? "") && position < header.length)
45
+ position++;
46
+ if (position === header.length)
47
+ break;
48
+ tag.lastIndex = position;
49
+ const item = tag.exec(header);
50
+ if (!item)
51
+ return false;
52
+ matches ||= item[1] === opaque;
53
+ position = tag.lastIndex;
54
+ while (/[ \t]/.test(header[position] ?? "") && position < header.length)
55
+ position++;
56
+ if (position < header.length && header[position++] !== ",")
57
+ return false;
58
+ }
59
+ // Validate the entire field before accepting a match, including later items.
60
+ return matches;
61
+ }
62
+ /** Stream a pinned regular file, bounded by its opened size and the response lifetime. */
63
+ export async function streamStaticFile(req, res, options) {
64
+ let handle;
65
+ try {
66
+ if (res.destroyed || res.writableEnded)
67
+ return;
68
+ const root = await realpath(options.rootDir);
69
+ const path = await realpath(options.filePath);
70
+ const contained = (target) => {
71
+ const rel = relative(root, target);
72
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
73
+ };
74
+ if (!contained(path))
75
+ throw new Error("Static asset is outside its root");
76
+ // Resolve aliases before opening so valid in-root symlinks remain supported.
77
+ // O_NONBLOCK avoids hanging on a regular file replaced by a FIFO.
78
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
79
+ const info = await handle.stat({ bigint: true });
80
+ if (!info.isFile())
81
+ throw new Error("Static asset is not a regular file");
82
+ if (info.size > BigInt(Number.MAX_SAFE_INTEGER))
83
+ throw new Error("Static asset is too large");
84
+ const currentPath = await realpath(options.filePath);
85
+ const current = await stat(currentPath, { bigint: true });
86
+ if (!contained(currentPath) ||
87
+ (await realpath(options.rootDir)) !== root ||
88
+ current.dev !== info.dev ||
89
+ current.ino !== info.ino ||
90
+ current.size !== info.size ||
91
+ current.mtimeNs !== info.mtimeNs ||
92
+ current.ctimeNs !== info.ctimeNs)
93
+ throw new Error("Static asset changed while opening");
94
+ if (res.destroyed || res.writableEnded)
95
+ return;
96
+ // A weak validator describes the opened file's metadata, not a byte hash.
97
+ // Include identity and change time so replacement or an equal-size rewrite
98
+ // with a restored mtime does not reuse the previous representation's tag.
99
+ const fingerprint = createHash("sha256")
100
+ .update(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`)
101
+ .digest("base64url");
102
+ const etag = `W/"${fingerprint}"`;
103
+ const headers = {
104
+ "content-type": contentTypeFor(extname(options.filePath)),
105
+ "cache-control": options.cacheControl,
106
+ "x-content-type-options": "nosniff",
107
+ etag,
108
+ };
109
+ if ((req.method === "GET" || req.method === "HEAD") &&
110
+ matchesIfNoneMatch(req.headers["if-none-match"], etag)) {
111
+ res.writeHead(304, headers);
112
+ res.end();
113
+ return;
114
+ }
115
+ res.writeHead(200, { ...headers, "content-length": info.size.toString() });
116
+ if (req.method === "HEAD" || info.size === 0n) {
117
+ res.end();
118
+ return;
119
+ }
120
+ // pipeline propagates backpressure and destroys the source on disconnect.
121
+ // The explicit end prevents a growing file from extending this response.
122
+ await pipeline(handle.createReadStream({ autoClose: false, start: 0, end: Number(info.size - 1n) }), res);
123
+ }
124
+ catch {
125
+ if (res.destroyed || res.writableEnded)
126
+ return;
127
+ if (res.headersSent)
128
+ res.destroy();
129
+ else {
130
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
131
+ res.end("not found");
132
+ }
133
+ }
134
+ finally {
135
+ await handle?.close().catch(() => {
136
+ if (!res.destroyed)
137
+ res.destroy();
138
+ });
139
+ }
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.9.47",
3
+ "version": "0.9.48",
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.47",
66
- "@cjhyy/code-shell-core": "0.9.47",
67
- "@cjhyy/code-shell-web": "0.9.47",
65
+ "@cjhyy/code-shell-capability-coding": "0.9.48",
66
+ "@cjhyy/code-shell-core": "0.9.48",
67
+ "@cjhyy/code-shell-web": "0.9.48",
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.47",
72
- "@cjhyy/code-shell-host": "0.9.47"
71
+ "@cjhyy/code-shell-link": "0.9.48",
72
+ "@cjhyy/code-shell-host": "0.9.48"
73
73
  },
74
74
  "engines": {
75
75
  "node": ">=20.10"