@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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
offset
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
/**
|
package/dist/sessions-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
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.
|
|
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.
|
|
64
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
65
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
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.
|
|
69
|
+
"@cjhyy/code-shell-link": "0.9.26"
|
|
70
70
|
},
|
|
71
71
|
"engines": {
|
|
72
72
|
"node": ">=20.10"
|