@cjhyy/code-shell-server 0.9.23 → 0.9.26

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.
@@ -0,0 +1,13 @@
1
+ /** Host-only optimization. Both paths are managed storage, never guest paths. */
2
+ export declare function stageResourceClone(options: {
3
+ sourcePath: string;
4
+ stagingDirectory: string;
5
+ targetDevice: number;
6
+ verify(): Promise<void>;
7
+ signal?: AbortSignal;
8
+ }): Promise<{
9
+ path: string;
10
+ handle: import("fs/promises").FileHandle;
11
+ verify: () => Promise<void>;
12
+ close: () => Promise<void>;
13
+ } | undefined>;
@@ -0,0 +1,99 @@
1
+ import { spawn } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { copyFile, lstat, mkdtemp, open, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { sameResourceIdentity } from "./directories.js";
6
+ import { mediaSourceIdentity } from "./library.js";
7
+ /** Host-only optimization. Both paths are managed storage, never guest paths. */
8
+ export async function stageResourceClone(options) {
9
+ const { sourcePath, stagingDirectory, signal } = options;
10
+ signal?.throwIfAborted();
11
+ await options.verify();
12
+ signal?.throwIfAborted();
13
+ const source = await lstat(sourcePath);
14
+ if (!source.isFile() || source.isSymbolicLink())
15
+ throw new Error("Invalid resource clone source");
16
+ if (source.dev !== options.targetDevice || !["darwin", "linux"].includes(process.platform))
17
+ return undefined;
18
+ const staging = await lstat(stagingDirectory);
19
+ if (!staging.isDirectory() || staging.isSymbolicLink())
20
+ throw new Error("Invalid resource clone staging directory");
21
+ if (staging.dev !== source.dev)
22
+ return undefined;
23
+ const directory = await mkdtemp(join(stagingDirectory, "clone-"));
24
+ const identity = await lstat(directory);
25
+ const path = join(directory, "content");
26
+ let handle;
27
+ const verify = async () => {
28
+ signal?.throwIfAborted();
29
+ await options.verify();
30
+ const currentStaging = await lstat(stagingDirectory);
31
+ const currentDirectory = await lstat(directory);
32
+ const currentSource = await lstat(sourcePath);
33
+ if (!currentStaging.isDirectory() ||
34
+ currentStaging.isSymbolicLink() ||
35
+ !sameResourceIdentity(currentStaging, staging) ||
36
+ !currentDirectory.isDirectory() ||
37
+ currentDirectory.isSymbolicLink() ||
38
+ !sameResourceIdentity(currentDirectory, identity) ||
39
+ !currentSource.isFile() ||
40
+ currentSource.isSymbolicLink() ||
41
+ Object.entries(mediaSourceIdentity(source)).some(([key, value]) => currentSource[key] !== value))
42
+ throw new Error("Resource changed during clone");
43
+ signal?.throwIfAborted();
44
+ };
45
+ const close = async () => {
46
+ await handle?.close().catch(() => { });
47
+ handle = undefined;
48
+ const current = await lstat(directory).catch(() => undefined);
49
+ if (current?.isDirectory() && sameResourceIdentity(current, identity))
50
+ await rm(directory, { recursive: true, force: true }).catch(() => { });
51
+ };
52
+ try {
53
+ await verify();
54
+ try {
55
+ if (process.platform === "darwin") {
56
+ // Electron's Node/libuv does not implement FICLONE on macOS. The OS
57
+ // utility uses clonefile, with ordinary copy fallback on other volumes.
58
+ // Its target is fresh Host-private storage, outside the tool grant.
59
+ await new Promise((resolve, reject) => {
60
+ const child = spawn("/bin/cp", ["-c", "-n", sourcePath, path], {
61
+ stdio: "ignore",
62
+ signal,
63
+ });
64
+ child.once("error", reject);
65
+ child.once("close", (code) => {
66
+ if (code === 0)
67
+ resolve();
68
+ else
69
+ reject(new Error("Native resource clone failed"));
70
+ });
71
+ });
72
+ }
73
+ else {
74
+ // FORCE prevents a failed Linux reflink from silently doing another
75
+ // full copy before the existing streaming fallback is used.
76
+ await copyFile(sourcePath, path, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE_FORCE);
77
+ }
78
+ }
79
+ catch (error) {
80
+ await verify();
81
+ if (process.platform !== "darwin" &&
82
+ !["ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV", "EINVAL"].includes(error.code ?? ""))
83
+ throw error;
84
+ await close();
85
+ return undefined;
86
+ }
87
+ await verify();
88
+ handle = await open(path, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
89
+ const cloned = await handle.stat();
90
+ if (!cloned.isFile() || cloned.size !== source.size || sameResourceIdentity(cloned, source))
91
+ throw new Error("Invalid cloned resource");
92
+ await handle.chmod(0o600);
93
+ return { path, handle, verify, close };
94
+ }
95
+ catch (error) {
96
+ await close();
97
+ throw error;
98
+ }
99
+ }
@@ -2,7 +2,8 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
3
  import { link, lstat, open, rm } from "node:fs/promises";
4
4
  import { ResourceLibrary, mediaSourceIdentity } from "./library.js";
5
- import { mediaScopeKey, normalizeMediaScope } from "./storage.js";
5
+ import { mediaDirectory, mediaScopeKey, normalizeMediaScope } from "./storage.js";
6
+ import { stageResourceClone } from "./clone.js";
6
7
  import { openResourceDirectory, resourceRelativePath, sameResourceIdentity, } from "./directories.js";
7
8
  import { ResourceUploadIngest } from "./uploads.js";
8
9
  import { ExternalResourceReferences } from "./references.js";
@@ -138,43 +139,71 @@ export class PanelResourceService {
138
139
  await directory.close();
139
140
  }
140
141
  }
141
- async materialize(scope, raw, context) {
142
+ async materialize(scope, raw, context, cloneManaged = true) {
142
143
  const input = object(raw, ["assetId", "directoryHandle", "path"]);
143
144
  const asset = await this.get(scope, assetId(input.assetId));
144
145
  const directory = await this.directory(scope, input, context, true);
145
- const temporary = directory.location(`.${randomUUID()}.resource-partial`);
146
+ let temporary = directory.location(`.${randomUUID()}.resource-partial`);
147
+ let clone;
146
148
  let destination;
147
149
  let published = false;
148
150
  let identity;
149
151
  try {
150
152
  await directory.verify();
151
- destination = await open(temporary, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
153
+ if (cloneManaged && "sha256" in asset) {
154
+ const sourcePath = await this.library.resolvePath(scope, asset.id);
155
+ const stagingDirectory = await mediaDirectory(this.options.rootDirectory, [
156
+ "scopes",
157
+ mediaScopeKey(scope),
158
+ "materialize",
159
+ ]);
160
+ clone = await stageResourceClone({
161
+ sourcePath,
162
+ stagingDirectory,
163
+ targetDevice: directory.identities.at(-1).dev,
164
+ verify: directory.verify,
165
+ signal: context.signal,
166
+ });
167
+ }
168
+ if (clone) {
169
+ temporary = clone.path;
170
+ destination = clone.handle;
171
+ }
172
+ else {
173
+ destination = await open(temporary, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
174
+ }
152
175
  identity = await destination.stat();
153
- const source = await this.openRead(scope, asset.id, { signal: context.signal });
154
- const hash = createHash("sha256");
155
- let bytes = 0;
156
- try {
157
- for await (const chunk of source.body) {
158
- await directory.verify();
159
- if (bytes + chunk.length > asset.bytes)
160
- throw new Error("Resource content changed");
161
- hash.update(chunk);
162
- let offset = 0;
163
- while (offset < chunk.length) {
164
- const written = await destination.write(chunk, offset, chunk.length - offset, bytes + offset);
165
- if (!written.bytesWritten)
166
- throw new Error("Resource copy stopped making progress");
167
- offset += written.bytesWritten;
176
+ let sha256;
177
+ if (clone && "sha256" in asset) {
178
+ sha256 = asset.sha256;
179
+ }
180
+ else {
181
+ const source = await this.openRead(scope, asset.id, { signal: context.signal });
182
+ const hash = createHash("sha256");
183
+ let bytes = 0;
184
+ try {
185
+ for await (const chunk of source.body) {
186
+ await directory.verify();
187
+ if (bytes + chunk.length > asset.bytes)
188
+ throw new Error("Resource content changed");
189
+ hash.update(chunk);
190
+ let offset = 0;
191
+ while (offset < chunk.length) {
192
+ const written = await destination.write(chunk, offset, chunk.length - offset, bytes + offset);
193
+ if (!written.bytesWritten)
194
+ throw new Error("Resource copy stopped making progress");
195
+ offset += written.bytesWritten;
196
+ }
197
+ bytes += chunk.length;
168
198
  }
169
- bytes += chunk.length;
170
199
  }
200
+ finally {
201
+ source.body?.destroy();
202
+ }
203
+ sha256 = hash.digest("hex");
204
+ if (bytes !== asset.bytes || ("sha256" in asset && sha256 !== asset.sha256))
205
+ throw new Error("Resource content failed integrity validation");
171
206
  }
172
- finally {
173
- source.body?.destroy();
174
- }
175
- const sha256 = hash.digest("hex");
176
- if (bytes !== asset.bytes || ("sha256" in asset && sha256 !== asset.sha256))
177
- throw new Error("Resource content failed integrity validation");
178
207
  await destination.sync();
179
208
  // A tool may share the granted directory. Verify the actual destination,
180
209
  // not only the source stream, before publishing the materialized file.
@@ -195,6 +224,7 @@ export class PanelResourceService {
195
224
  verified.ctimeMs !== completed.ctimeMs ||
196
225
  copiedHash.digest("hex") !== sha256)
197
226
  throw new Error("Materialized resource failed integrity verification");
227
+ await clone?.verify();
198
228
  await destination.close();
199
229
  destination = undefined;
200
230
  await directory.verify();
@@ -213,15 +243,21 @@ export class PanelResourceService {
213
243
  if (current && sameResourceIdentity(current, identity))
214
244
  await rm(directory.path, { force: true }).catch(() => { });
215
245
  }
216
- throw error;
246
+ // Different bind mounts can share st_dev yet refuse hard links. Retry
247
+ // with a temporary file inside the target grant, after releasing this
248
+ // private clone and all held descriptors in finally.
249
+ if (!(clone && !published && error.code === "EXDEV"))
250
+ throw error;
217
251
  }
218
252
  finally {
219
253
  await destination?.close().catch(() => { });
220
254
  const current = await lstat(temporary).catch(() => undefined);
221
255
  if (current && identity && sameResourceIdentity(current, identity))
222
256
  await rm(temporary, { force: true }).catch(() => { });
257
+ await clone?.close();
223
258
  await directory.close();
224
259
  }
260
+ return this.materialize(scope, raw, context, false);
225
261
  }
226
262
  async capture(scope, raw, context) {
227
263
  const input = object(raw, [
@@ -39,9 +39,11 @@ export interface DiskSessionMeta {
39
39
  cwd: string;
40
40
  title: string;
41
41
  updatedAt: number;
42
- /** Sub-agents are returned only by an explicit parentSessionId query. */
42
+ /** Durable creation time, when supplied by the engine's state schema. */
43
+ createdAt?: number;
44
+ /** Sub-agents require an explicit parent query or the host's includeSubagents scan. */
43
45
  origin: "desktop" | "automation" | "subagent";
44
- /** Present only when listing the direct children of a session. */
46
+ /** Present for direct-child queries and the host's complete task scan. */
45
47
  parentSessionId?: string;
46
48
  /** Normalized durable status for Pet/display consumers. */
47
49
  status?: "active" | "paused" | "completed" | "failed" | "cancelled";
@@ -68,6 +70,8 @@ export declare function listDiskSessions(opts: {
68
70
  limit: number;
69
71
  cursor?: string;
70
72
  includeArchived?: boolean;
73
+ /** Host read models may include children; ordinary sidebar queries stay top-level. */
74
+ includeSubagents?: boolean;
71
75
  parentSessionId?: string;
72
76
  }, baseDir?: string): Promise<ListDiskSessionsResult>;
73
77
  /**
@@ -279,7 +279,7 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
279
279
  if (state.parentSessionId !== parentSessionId)
280
280
  continue;
281
281
  }
282
- else if (state.parentSessionId) {
282
+ else if (state.parentSessionId && !opts.includeSubagents) {
283
283
  continue;
284
284
  }
285
285
  // Only desktop + automation belong in the desktop sidebar. Targeted
@@ -288,7 +288,7 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
288
288
  const origin = state.origin;
289
289
  if (origin !== "desktop" &&
290
290
  origin !== "automation" &&
291
- !(parentSessionId !== undefined && origin === "subagent")) {
291
+ !((parentSessionId !== undefined || opts.includeSubagents) && origin === "subagent")) {
292
292
  continue;
293
293
  }
294
294
  // Skip sessions whose project root has been deleted. Listing them led the
@@ -300,7 +300,21 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
300
300
  const cwdStr = typeof state.cwd === "string" ? state.cwd : "";
301
301
  // A child transcript remains useful after its temporary worktree is gone;
302
302
  // targeted reads do not rebuild projects in the sidebar.
303
- if (parentSessionId === undefined && cwdStr && !(await pathExists(cwdStr)))
303
+ const childParentId = typeof state.parentSessionId === "string" && state.parentSessionId
304
+ ? state.parentSessionId
305
+ : undefined;
306
+ if (childParentId &&
307
+ (!SAFE_ID.test(childParentId) ||
308
+ childParentId.length > 128 ||
309
+ childParentId.includes("..") ||
310
+ childParentId === "."))
311
+ continue;
312
+ if (state.parentSessionId && !childParentId)
313
+ continue;
314
+ if (parentSessionId === undefined &&
315
+ !(opts.includeSubagents && childParentId) &&
316
+ cwdStr &&
317
+ !(await pathExists(cwdStr)))
304
318
  continue;
305
319
  // Archived sessions are hidden from the default catalog. Filtering happens
306
320
  // before the push (like every other skip above), so the mtime cursor —
@@ -328,8 +342,13 @@ export async function listDiskSessions(opts, baseDir = sessionsRoot()) {
328
342
  title: (typeof state.title === "string" && state.title ? state.title : undefined) ??
329
343
  (typeof state.summary === "string" && state.summary ? state.summary : id),
330
344
  updatedAt: mtime,
345
+ ...(typeof state.startedAt === "number" &&
346
+ Number.isFinite(state.startedAt) &&
347
+ state.startedAt >= 0
348
+ ? { createdAt: state.startedAt }
349
+ : {}),
331
350
  origin,
332
- ...(parentSessionId !== undefined ? { parentSessionId } : {}),
351
+ ...(childParentId ? { parentSessionId: childParentId } : {}),
333
352
  status: state.status === "active" || state.status === "paused" || state.status === "completed"
334
353
  ? state.status
335
354
  : state.status === "aborted_streaming" || state.status === "aborted_tools"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.9.23",
3
+ "version": "0.9.26",
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",
@@ -60,13 +60,13 @@
60
60
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
61
61
  },
62
62
  "dependencies": {
63
- "@cjhyy/code-shell-capability-coding": "0.9.23",
64
- "@cjhyy/code-shell-core": "0.9.23",
65
- "@cjhyy/code-shell-web": "0.9.23",
63
+ "@cjhyy/code-shell-capability-coding": "0.9.26",
64
+ "@cjhyy/code-shell-core": "0.9.26",
65
+ "@cjhyy/code-shell-web": "0.9.26",
66
66
  "@modelcontextprotocol/sdk": "^1.12.1",
67
67
  "@types/ws": "^8.18.1",
68
68
  "ws": "^8.21.0",
69
- "@cjhyy/code-shell-link": "0.9.23"
69
+ "@cjhyy/code-shell-link": "0.9.26"
70
70
  },
71
71
  "engines": {
72
72
  "node": ">=20.10"