@cjhyy/code-shell-server 0.9.13 → 0.9.15

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.
@@ -33,6 +33,7 @@ export declare function panelRuntimeCapabilities(input: {
33
33
  }): {
34
34
  tasks?: {} | undefined;
35
35
  resources?: {
36
+ createReferences?: boolean | undefined;
36
37
  materialize: boolean;
37
38
  capture: boolean;
38
39
  } | undefined;
@@ -24,6 +24,10 @@ export const panelResourceMethods = [
24
24
  "resources.upload.cancel",
25
25
  "resources.materialize",
26
26
  "resources.capture",
27
+ "resources.references.create",
28
+ "resources.references.get",
29
+ "resources.references.relink",
30
+ "resources.references.forget",
27
31
  ];
28
32
  export const panelToolJobMethods = [
29
33
  "tasks.start",
@@ -84,6 +88,9 @@ export function panelRuntimeCapabilities(input) {
84
88
  ...input.resources,
85
89
  materialize: input.process,
86
90
  capture: input.process,
91
+ ...(input.resources.externalReferences
92
+ ? { createReferences: input.process }
93
+ : {}),
87
94
  },
88
95
  }
89
96
  : {}),
@@ -7,6 +7,11 @@ export declare function openResourceDirectory(root: string, path: string, create
7
7
  path: string;
8
8
  location: (file: string) => string;
9
9
  rootIdentity: Stats;
10
+ rootPath: string;
11
+ identities: {
12
+ dev: number;
13
+ ino: number;
14
+ }[];
10
15
  verify: () => Promise<void>;
11
16
  close: () => Promise<void>;
12
17
  }>;
@@ -48,7 +48,8 @@ export async function openResourceDirectory(root, path, create, authorize) {
48
48
  const original = await lstat(root);
49
49
  if (!original.isDirectory() || original.isSymbolicLink())
50
50
  throw new Error("Resource directory must not be a symlink");
51
- let current = await realpath(root);
51
+ const rootPath = await realpath(root);
52
+ let current = rootPath;
52
53
  for (let i = -1; i < parts.length; i++) {
53
54
  if (i >= 0) {
54
55
  await verify();
@@ -80,7 +81,16 @@ export async function openResourceDirectory(root, path, create, authorize) {
80
81
  const location = (file) => process.platform === "linux"
81
82
  ? `/proc/self/fd/${parent.handle.fd}/${file}`
82
83
  : join(parent.path, file);
83
- return { name, path: location(name), location, rootIdentity: held[0].identity, verify, close };
84
+ return {
85
+ name,
86
+ path: location(name),
87
+ location,
88
+ rootIdentity: held[0].identity,
89
+ rootPath,
90
+ identities: held.map(({ identity }) => ({ dev: identity.dev, ino: identity.ino })),
91
+ verify,
92
+ close,
93
+ };
84
94
  }
85
95
  catch (error) {
86
96
  await close();
@@ -1,5 +1,6 @@
1
1
  import type { Readable } from "node:stream";
2
2
  import type { MediaAsset, MediaScope } from "./types.js";
3
+ export declare function resourceMimeType(name: string, requested?: string): string;
3
4
  export interface MediaLibraryOptions {
4
5
  rootDirectory: string;
5
6
  maxFileBytes?: number;
@@ -32,6 +33,10 @@ export interface MediaReadResult {
32
33
  body: Readable | null;
33
34
  }
34
35
  export declare function resourceName(value: string): string;
36
+ export declare function rangeBounds(range: string, size: number): {
37
+ start: number;
38
+ end: number;
39
+ } | null;
35
40
  /**
36
41
  * Host-owned immutable, scope-local media. importFile accepts only paths already
37
42
  * authorized by the caller (for example native file selection); guest APIs must
@@ -50,6 +50,12 @@ const SAFE_MIME_TYPES = new Set([
50
50
  "audio/webm",
51
51
  "application/octet-stream",
52
52
  ]);
53
+ export function resourceMimeType(name, requested) {
54
+ const mime = requested?.split(";", 1)[0].toLowerCase();
55
+ return mime && SAFE_MIME_TYPES.has(mime)
56
+ ? mime
57
+ : (MIME_BY_EXTENSION[extname(name).toLowerCase()] ?? "application/octet-stream");
58
+ }
53
59
  export function mediaSourceIdentity(info) {
54
60
  return {
55
61
  dev: info.dev,
@@ -87,7 +93,7 @@ function assetRecord(raw, id) {
87
93
  createdAt: value.createdAt,
88
94
  };
89
95
  }
90
- function rangeBounds(range, size) {
96
+ export function rangeBounds(range, size) {
91
97
  const match = /^bytes=(\d*)-(\d*)$/.exec(range);
92
98
  if (!match || (!match[1] && !match[2]))
93
99
  return null;
@@ -227,10 +233,7 @@ export class ResourceLibrary {
227
233
  throw new Error("Resource digest does not match the expected digest");
228
234
  const id = `asset-${sha256}`;
229
235
  const name = resourceName(options.name ?? sourcePath);
230
- const requestedMime = options.mimeType?.split(";", 1)[0].toLowerCase();
231
- const mimeType = requestedMime && SAFE_MIME_TYPES.has(requestedMime)
232
- ? requestedMime
233
- : (MIME_BY_EXTENSION[extname(name).toLowerCase()] ?? "application/octet-stream");
236
+ const mimeType = resourceMimeType(name, options.mimeType);
234
237
  const record = {
235
238
  id,
236
239
  name,
@@ -0,0 +1,48 @@
1
+ import type { MediaReadResult } from "./library.js";
2
+ import type { ExternalResourceReference, ResourceScope } from "./types.js";
3
+ export interface ExternalReferenceOptions {
4
+ name?: string;
5
+ mimeType?: string;
6
+ expectedBytes?: number;
7
+ expectedLastModified?: number;
8
+ signal?: AbortSignal;
9
+ assertAuthorized?: () => void | Promise<void>;
10
+ }
11
+ interface ReferenceOptions {
12
+ rootDirectory: string;
13
+ maxFileBytes?: number;
14
+ isScopeAuthorized(scope: ResourceScope): boolean | Promise<boolean>;
15
+ }
16
+ /** Host-only source locations; public records contain no path and claim no content hash. */
17
+ export declare class ExternalResourceReferences {
18
+ private readonly options;
19
+ private closed;
20
+ private readonly queues;
21
+ private readonly streams;
22
+ constructor(options: ReferenceOptions);
23
+ private authorize;
24
+ private locked;
25
+ private directory;
26
+ private record;
27
+ private selected;
28
+ private checked;
29
+ /** Only call with a directory selected and authorized by the trusted Host. */
30
+ createFromDirectory(scope: ResourceScope, root: string, path: string, options?: ExternalReferenceOptions): Promise<ExternalResourceReference>;
31
+ createFromSelectedPath(scope: ResourceScope, path: string, options?: ExternalReferenceOptions): Promise<ExternalResourceReference>;
32
+ get(scope: ResourceScope, id: string): Promise<ExternalResourceReference>;
33
+ relinkFromDirectory(scope: ResourceScope, id: string, root: string, path: string, options?: ExternalReferenceOptions): Promise<ExternalResourceReference>;
34
+ relinkFromSelectedPath(scope: ResourceScope, id: string, path: string, options?: ExternalReferenceOptions): Promise<ExternalResourceReference>;
35
+ forget(scope: ResourceScope, id: string): Promise<{
36
+ forgotten: true;
37
+ }>;
38
+ openRead(scope: ResourceScope, id: string, options?: {
39
+ range?: string;
40
+ method?: string;
41
+ signal?: AbortSignal;
42
+ }): Promise<MediaReadResult>;
43
+ private stopStreams;
44
+ cancelScope(scope: ResourceScope): Promise<void>;
45
+ cancelApp(appId: string): Promise<void>;
46
+ shutdown(): Promise<void>;
47
+ }
48
+ export {};
@@ -0,0 +1,422 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, readdir, realpath, rm } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join } from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { openResourceDirectory, resourceRelativePath, sameResourceIdentity, } from "./directories.js";
7
+ import { mediaSourceIdentity, rangeBounds, resourceMimeType, resourceName } from "./library.js";
8
+ import { mediaDirectory, mediaScopeKey, readMediaJson, writeMediaJson } from "./storage.js";
9
+ const REFERENCE_ID = /^external-[a-f0-9]{64}$/;
10
+ const MAX_REFERENCES = 4096;
11
+ class ReferenceUnavailable extends Error {
12
+ state;
13
+ constructor(state) {
14
+ super(state === "missing"
15
+ ? "Referenced file is unavailable; reconnect it"
16
+ : "Referenced file changed; select it as a new reference");
17
+ this.state = state;
18
+ }
19
+ }
20
+ function referenceId(value) {
21
+ if (typeof value !== "string" || !REFERENCE_ID.test(value))
22
+ throw new Error("Invalid external reference ID");
23
+ return value;
24
+ }
25
+ function validIdentity(value) {
26
+ return value && Number.isSafeInteger(value.dev) && Number.isSafeInteger(value.ino);
27
+ }
28
+ function sameSource(a, b, relocating = false) {
29
+ return (a.dev === b.dev &&
30
+ a.ino === b.ino &&
31
+ a.size === b.size &&
32
+ a.mtimeMs === b.mtimeMs &&
33
+ (relocating || a.ctimeMs === b.ctimeMs));
34
+ }
35
+ function view(record, state = "available") {
36
+ return {
37
+ id: record.id,
38
+ kind: "external",
39
+ name: record.name,
40
+ mimeType: record.mimeType,
41
+ bytes: record.source.size,
42
+ lastModified: Math.trunc(record.source.mtimeMs),
43
+ createdAt: record.createdAt,
44
+ state,
45
+ };
46
+ }
47
+ /** Host-only source locations; public records contain no path and claim no content hash. */
48
+ export class ExternalResourceReferences {
49
+ options;
50
+ closed = false;
51
+ queues = new Map();
52
+ streams = new Set();
53
+ constructor(options) {
54
+ this.options = options;
55
+ }
56
+ async authorize(scope, options = {}) {
57
+ mediaScopeKey(scope);
58
+ options.signal?.throwIfAborted();
59
+ if (this.closed || !(await this.options.isScopeAuthorized(scope)))
60
+ throw new Error("External resource access is no longer authorized");
61
+ await options.assertAuthorized?.();
62
+ options.signal?.throwIfAborted();
63
+ }
64
+ locked(scope, work) {
65
+ const key = mediaScopeKey(scope);
66
+ const next = (this.queues.get(key) ?? Promise.resolve()).catch(() => { }).then(work);
67
+ this.queues.set(key, next);
68
+ return next.finally(() => {
69
+ if (this.queues.get(key) === next)
70
+ this.queues.delete(key);
71
+ });
72
+ }
73
+ directory(scope, create = false) {
74
+ return mediaDirectory(this.options.rootDirectory, ["scopes", mediaScopeKey(scope), "references"], create);
75
+ }
76
+ async record(scope, id) {
77
+ referenceId(id);
78
+ await this.authorize(scope);
79
+ const raw = await readMediaJson(join(await this.directory(scope), `${id}.json`));
80
+ if (!raw ||
81
+ raw.schemaVersion !== 1 ||
82
+ raw.id !== id ||
83
+ Object.keys(raw).some((key) => ![
84
+ "schemaVersion",
85
+ "id",
86
+ "name",
87
+ "mimeType",
88
+ "createdAt",
89
+ "root",
90
+ "path",
91
+ "ancestors",
92
+ "source",
93
+ ].includes(key)) ||
94
+ typeof raw.name !== "string" ||
95
+ raw.name !== resourceName(raw.name) ||
96
+ typeof raw.mimeType !== "string" ||
97
+ resourceMimeType(raw.name, raw.mimeType) !== raw.mimeType ||
98
+ !Number.isSafeInteger(raw.createdAt) ||
99
+ raw.createdAt < 0 ||
100
+ typeof raw.root !== "string" ||
101
+ !isAbsolute(raw.root) ||
102
+ raw.root.length > 4096 ||
103
+ raw.root.includes("\0") ||
104
+ !Array.isArray(raw.ancestors) ||
105
+ raw.ancestors.length < 1 ||
106
+ raw.ancestors.length > 16 ||
107
+ raw.ancestors.some((identity) => !validIdentity(identity)) ||
108
+ !validIdentity(raw.source) ||
109
+ !Number.isSafeInteger(raw.source.size) ||
110
+ raw.source.size < 1 ||
111
+ raw.source.size > (this.options.maxFileBytes ?? 20 * 1024 ** 3) ||
112
+ !Number.isFinite(raw.source.mtimeMs) ||
113
+ !Number.isFinite(raw.source.ctimeMs))
114
+ throw new Error("Invalid stored external reference");
115
+ resourceRelativePath(raw.path);
116
+ if (raw.ancestors.length !== raw.path.split("/").length)
117
+ throw new Error("Invalid external reference ancestry");
118
+ await this.authorize(scope);
119
+ return raw;
120
+ }
121
+ async selected(scope, root, path, options = {}) {
122
+ await this.authorize(scope, options);
123
+ const directory = await openResourceDirectory(root, path, false, () => this.authorize(scope, options));
124
+ let file;
125
+ try {
126
+ file = await open(directory.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
127
+ const info = await file.stat();
128
+ const initial = mediaSourceIdentity(info);
129
+ if (!info.isFile() ||
130
+ !validIdentity(initial) ||
131
+ !Number.isSafeInteger(initial.size) ||
132
+ initial.size < 1 ||
133
+ initial.size > (this.options.maxFileBytes ?? 20 * 1024 ** 3))
134
+ throw new Error("External source must be a nonempty regular file within the resource limit");
135
+ if (options.expectedBytes !== undefined &&
136
+ (!Number.isSafeInteger(options.expectedBytes) || initial.size !== options.expectedBytes))
137
+ throw new Error("External source size changed after selection");
138
+ if (options.expectedLastModified !== undefined &&
139
+ (!Number.isSafeInteger(options.expectedLastModified) ||
140
+ Math.trunc(initial.mtimeMs) !== options.expectedLastModified))
141
+ throw new Error("External source changed after selection");
142
+ const verify = async () => {
143
+ await directory.verify();
144
+ const named = await lstat(directory.path), held = await file.stat();
145
+ if (!named.isFile() ||
146
+ named.isSymbolicLink() ||
147
+ !sameSource(initial, mediaSourceIdentity(named)) ||
148
+ !sameSource(initial, mediaSourceIdentity(held)))
149
+ throw new ReferenceUnavailable("changed");
150
+ };
151
+ await verify();
152
+ let closing;
153
+ return {
154
+ directory,
155
+ file,
156
+ initial,
157
+ verify,
158
+ close: () => (closing ??= Promise.all([file.close(), directory.close()]).then(() => { })),
159
+ };
160
+ }
161
+ catch (error) {
162
+ await file?.close().catch(() => { });
163
+ await directory.close();
164
+ throw error;
165
+ }
166
+ }
167
+ async checked(scope, record, options = {}) {
168
+ await this.authorize(scope, options);
169
+ try {
170
+ if ((await realpath(record.root)) !== record.root)
171
+ throw new ReferenceUnavailable("changed");
172
+ const selected = await this.selected(scope, record.root, record.path, options);
173
+ if (!sameSource(record.source, selected.initial) ||
174
+ record.ancestors.length !== selected.directory.identities.length ||
175
+ record.ancestors.some((identity, index) => !sameResourceIdentity(identity, selected.directory.identities[index]))) {
176
+ await selected.close();
177
+ throw new ReferenceUnavailable("changed");
178
+ }
179
+ const verify = selected.verify;
180
+ selected.verify = async () => {
181
+ if ((await realpath(record.root)) !== record.root)
182
+ throw new ReferenceUnavailable("changed");
183
+ await verify();
184
+ };
185
+ return selected;
186
+ }
187
+ catch (error) {
188
+ await this.authorize(scope, options);
189
+ if (error instanceof ReferenceUnavailable)
190
+ throw error;
191
+ const code = error.code;
192
+ throw new ReferenceUnavailable(["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(code ?? "") ? "missing" : "changed");
193
+ }
194
+ }
195
+ /** Only call with a directory selected and authorized by the trusted Host. */
196
+ createFromDirectory(scope, root, path, options = {}) {
197
+ return this.locked(scope, async () => {
198
+ const selected = await this.selected(scope, root, resourceRelativePath(path), options);
199
+ try {
200
+ root = selected.directory.rootPath;
201
+ const id = `external-${createHash("sha256")
202
+ .update(JSON.stringify([
203
+ "external-reference-v1",
204
+ mediaScopeKey(scope),
205
+ root,
206
+ path,
207
+ selected.initial,
208
+ ]))
209
+ .digest("hex")}`;
210
+ const directory = await this.directory(scope, true), location = join(directory, `${id}.json`);
211
+ try {
212
+ const previous = await this.record(scope, id);
213
+ if (previous.root !== root ||
214
+ previous.path !== path ||
215
+ !sameSource(previous.source, selected.initial))
216
+ throw new Error("External reference identity mismatch");
217
+ await selected.verify();
218
+ return view(previous);
219
+ }
220
+ catch (error) {
221
+ if (error.code !== "ENOENT")
222
+ throw error;
223
+ }
224
+ if ((await readdir(directory)).filter((name) => REFERENCE_ID.test(name.replace(/\.json$/, ""))).length >= MAX_REFERENCES)
225
+ throw new Error("External reference count limit exceeded");
226
+ const record = {
227
+ schemaVersion: 1,
228
+ id,
229
+ name: resourceName(options.name ?? basename(path)),
230
+ mimeType: resourceMimeType(options.name ?? basename(path), options.mimeType),
231
+ createdAt: Date.now(),
232
+ root: selected.directory.rootPath,
233
+ path,
234
+ ancestors: selected.directory.identities,
235
+ source: selected.initial,
236
+ };
237
+ await selected.verify();
238
+ await writeMediaJson(location, record);
239
+ try {
240
+ await selected.verify();
241
+ }
242
+ catch (error) {
243
+ await rm(location, { force: true });
244
+ throw error;
245
+ }
246
+ return view(record);
247
+ }
248
+ finally {
249
+ await selected.close();
250
+ }
251
+ });
252
+ }
253
+ createFromSelectedPath(scope, path, options = {}) {
254
+ if (!isAbsolute(path) || path.includes("\0"))
255
+ throw new Error("Invalid Host-selected external file");
256
+ return this.createFromDirectory(scope, dirname(path), basename(path), options);
257
+ }
258
+ get(scope, id) {
259
+ return this.locked(scope, async () => {
260
+ const record = await this.record(scope, id);
261
+ try {
262
+ const source = await this.checked(scope, record);
263
+ await source.close();
264
+ return view(record);
265
+ }
266
+ catch (error) {
267
+ if (error instanceof ReferenceUnavailable)
268
+ return view(record, error.state);
269
+ throw error;
270
+ }
271
+ });
272
+ }
273
+ relinkFromDirectory(scope, id, root, path, options = {}) {
274
+ return this.locked(scope, async () => {
275
+ const previous = await this.record(scope, id);
276
+ const selected = await this.selected(scope, root, resourceRelativePath(path), options);
277
+ try {
278
+ // Metadata alone cannot establish that an unrelated replacement is the original file.
279
+ if (!sameSource(previous.source, selected.initial, true))
280
+ throw new Error("This is a different file; create a new external reference instead");
281
+ const record = {
282
+ ...previous,
283
+ root: selected.directory.rootPath,
284
+ path,
285
+ source: selected.initial,
286
+ ancestors: selected.directory.identities,
287
+ };
288
+ await selected.verify();
289
+ await this.stopStreams((stream) => stream.scopeKey === mediaScopeKey(scope) && stream.id === id);
290
+ const location = join(await this.directory(scope), `${id}.json`);
291
+ await writeMediaJson(location, record);
292
+ try {
293
+ await selected.verify();
294
+ }
295
+ catch (error) {
296
+ await writeMediaJson(location, previous);
297
+ throw error;
298
+ }
299
+ return view(record);
300
+ }
301
+ finally {
302
+ await selected.close();
303
+ }
304
+ });
305
+ }
306
+ relinkFromSelectedPath(scope, id, path, options = {}) {
307
+ if (!isAbsolute(path) || path.includes("\0"))
308
+ throw new Error("Invalid Host-selected external file");
309
+ return this.relinkFromDirectory(scope, id, dirname(path), basename(path), options);
310
+ }
311
+ forget(scope, id) {
312
+ return this.locked(scope, async () => {
313
+ await this.record(scope, id);
314
+ await this.stopStreams((stream) => stream.scopeKey === mediaScopeKey(scope) && stream.id === id);
315
+ await this.authorize(scope);
316
+ await rm(join(await this.directory(scope), `${id}.json`));
317
+ return { forgotten: true };
318
+ });
319
+ }
320
+ openRead(scope, id, options = {}) {
321
+ return this.locked(scope, async () => {
322
+ const record = await this.record(scope, id);
323
+ const source = await this.checked(scope, record, options);
324
+ const method = options.method ?? "GET";
325
+ if (!["GET", "HEAD"].includes(method)) {
326
+ await source.close();
327
+ return { status: 405, headers: { Allow: "GET, HEAD" }, body: null };
328
+ }
329
+ const headers = {
330
+ "Content-Type": record.mimeType,
331
+ "Accept-Ranges": "bytes",
332
+ "Cache-Control": "private, no-store",
333
+ "X-Content-Type-Options": "nosniff",
334
+ "Content-Security-Policy": "default-src 'none'; sandbox",
335
+ ETag: `"${record.id}"`,
336
+ };
337
+ if (!/^(?:audio|video|image)\//.test(record.mimeType))
338
+ headers["Content-Disposition"] =
339
+ `attachment; filename*=UTF-8''${encodeURIComponent(record.name).replace(/['()*!]/g, (value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`)}`;
340
+ const bounds = options.range
341
+ ? rangeBounds(options.range, record.source.size)
342
+ : { start: 0, end: record.source.size - 1 };
343
+ if (!bounds) {
344
+ await source.close();
345
+ return {
346
+ status: 416,
347
+ headers: { ...headers, "Content-Range": `bytes */${record.source.size}` },
348
+ body: null,
349
+ };
350
+ }
351
+ headers["Content-Length"] = String(bounds.end - bounds.start + 1);
352
+ if (options.range)
353
+ headers["Content-Range"] = `bytes ${bounds.start}-${bounds.end}/${record.source.size}`;
354
+ const status = options.range ? 206 : 200;
355
+ if (method === "HEAD") {
356
+ await source.close();
357
+ return { status, headers, body: null };
358
+ }
359
+ const stream = Readable.from((async function* () {
360
+ try {
361
+ for (let offset = bounds.start; offset <= bounds.end;) {
362
+ await source.verify();
363
+ const buffer = Buffer.allocUnsafe(Math.min(256 * 1024, bounds.end - offset + 1));
364
+ const { bytesRead } = await source.file.read(buffer, 0, buffer.length, offset);
365
+ if (bytesRead !== buffer.length)
366
+ throw new ReferenceUnavailable("changed");
367
+ await source.verify();
368
+ offset += bytesRead;
369
+ yield buffer;
370
+ }
371
+ }
372
+ finally {
373
+ await source.close();
374
+ }
375
+ })(), { objectMode: false });
376
+ let closed;
377
+ const entry = {
378
+ scopeKey: mediaScopeKey(scope),
379
+ appId: scope.appId,
380
+ id,
381
+ stream,
382
+ closed: new Promise((resolve) => {
383
+ closed = resolve;
384
+ }),
385
+ };
386
+ const abort = () => stream.destroy(new Error("External resource read was cancelled"));
387
+ stream.on("error", () => { });
388
+ stream.once("close", () => {
389
+ options.signal?.removeEventListener("abort", abort);
390
+ void source
391
+ .close()
392
+ .catch(() => { })
393
+ .finally(() => {
394
+ this.streams.delete(entry);
395
+ closed();
396
+ });
397
+ });
398
+ this.streams.add(entry);
399
+ options.signal?.addEventListener("abort", abort, { once: true });
400
+ if (options.signal?.aborted)
401
+ abort();
402
+ return { status, headers, body: stream };
403
+ });
404
+ }
405
+ async stopStreams(match) {
406
+ const streams = [...this.streams].filter(match);
407
+ for (const entry of streams)
408
+ entry.stream.destroy(new Error("External resource access was revoked"));
409
+ await Promise.all(streams.map((entry) => entry.closed));
410
+ }
411
+ cancelScope(scope) {
412
+ return this.stopStreams((stream) => stream.scopeKey === mediaScopeKey(scope));
413
+ }
414
+ cancelApp(appId) {
415
+ return this.stopStreams((stream) => stream.appId === appId);
416
+ }
417
+ async shutdown() {
418
+ this.closed = true;
419
+ await this.stopStreams(() => true);
420
+ await Promise.allSettled([...this.queues.values()]);
421
+ }
422
+ }
@@ -1,5 +1,7 @@
1
1
  import { ResourceLibrary } from "./library.js";
2
- import type { ResourceScope } from "./types.js";
2
+ import { ExternalResourceReferences } from "./references.js";
3
+ import type { MediaReadResult } from "./library.js";
4
+ import type { ExternalResourceReference, ResourceAsset, ResourceScope } from "./types.js";
3
5
  export interface PanelResourceServiceOptions {
4
6
  rootDirectory: string;
5
7
  isScopeAuthorized(scope: ResourceScope): boolean | Promise<boolean>;
@@ -18,6 +20,7 @@ export declare class PanelResourceError extends Error {
18
20
  export declare class PanelResourceService {
19
21
  private readonly options;
20
22
  readonly library: ResourceLibrary;
23
+ readonly references: ExternalResourceReferences;
21
24
  private readonly uploads;
22
25
  private readonly directoryIdentities;
23
26
  private closed;
@@ -29,10 +32,19 @@ export declare class PanelResourceService {
29
32
  materialize: boolean;
30
33
  capture: boolean;
31
34
  resumableUploads: boolean;
35
+ externalReferences: boolean;
36
+ createReferences: boolean;
32
37
  };
33
38
  initialize(): Promise<void>;
34
39
  private authorize;
35
40
  private directory;
41
+ get(scope: ResourceScope, id: string): Promise<ResourceAsset | ExternalResourceReference>;
42
+ openRead(scope: ResourceScope, id: string, options?: {
43
+ range?: string;
44
+ method?: string;
45
+ signal?: AbortSignal;
46
+ }): Promise<MediaReadResult>;
47
+ private reference;
36
48
  private materialize;
37
49
  private capture;
38
50
  private read;
@@ -5,6 +5,7 @@ import { ResourceLibrary, mediaSourceIdentity } from "./library.js";
5
5
  import { mediaScopeKey, normalizeMediaScope } from "./storage.js";
6
6
  import { openResourceDirectory, resourceRelativePath, sameResourceIdentity, } from "./directories.js";
7
7
  import { ResourceUploadIngest } from "./uploads.js";
8
+ import { ExternalResourceReferences } from "./references.js";
8
9
  const CHUNK_BYTES = 32768;
9
10
  export class PanelResourceError extends Error {
10
11
  code;
@@ -28,7 +29,7 @@ function integer(value, min, max) {
28
29
  return Number(value);
29
30
  }
30
31
  function assetId(value) {
31
- if (typeof value !== "string" || !/^asset-[a-f0-9]{64}$/.test(value))
32
+ if (typeof value !== "string" || !/^(?:asset|external)-[a-f0-9]{64}$/.test(value))
32
33
  throw new PanelResourceError("INVALID_RESOURCE_REQUEST", "Invalid resource ID");
33
34
  return value;
34
35
  }
@@ -36,12 +37,14 @@ function assetId(value) {
36
37
  export class PanelResourceService {
37
38
  options;
38
39
  library;
40
+ references;
39
41
  uploads;
40
42
  directoryIdentities = new Map();
41
43
  closed = false;
42
44
  constructor(options) {
43
45
  this.options = options;
44
46
  this.library = new ResourceLibrary(options);
47
+ this.references = new ExternalResourceReferences(options);
45
48
  this.uploads = new ResourceUploadIngest({ ...options, library: this.library });
46
49
  }
47
50
  capabilities() {
@@ -52,6 +55,8 @@ export class PanelResourceService {
52
55
  materialize: true,
53
56
  capture: true,
54
57
  resumableUploads: true,
58
+ externalReferences: true,
59
+ createReferences: true,
55
60
  };
56
61
  }
57
62
  initialize() {
@@ -90,11 +95,52 @@ export class PanelResourceService {
90
95
  throw new Error("Too many resource directory grants; reopen the panel");
91
96
  }
92
97
  this.directoryIdentities.set(key, { dev: held.rootIdentity.dev, ino: held.rootIdentity.ino });
93
- return held;
98
+ return { ...held, root };
99
+ }
100
+ async get(scope, id) {
101
+ await this.authorize(scope);
102
+ return assetId(id).startsWith("external-")
103
+ ? this.references.get(scope, id)
104
+ : this.library.get(scope, id);
105
+ }
106
+ async openRead(scope, id, options = {}) {
107
+ await this.authorize(scope, options);
108
+ return assetId(id).startsWith("external-")
109
+ ? this.references.openRead(scope, id, options)
110
+ : this.library.openRead(scope, id, options);
111
+ }
112
+ async reference(scope, raw, context, relink = false) {
113
+ const input = object(raw, relink
114
+ ? ["id", "directoryHandle", "path"]
115
+ : ["directoryHandle", "path", "name", "mimeType", "expectedBytes", "expectedLastModified"]);
116
+ if ((input.name !== undefined &&
117
+ (typeof input.name !== "string" || !input.name || input.name.length > 240)) ||
118
+ (input.mimeType !== undefined &&
119
+ (typeof input.mimeType !== "string" || !input.mimeType || input.mimeType.length > 200)))
120
+ throw new PanelResourceError("INVALID_RESOURCE_REQUEST", "Invalid external reference metadata");
121
+ const directory = await this.directory(scope, input, context, false);
122
+ try {
123
+ const options = {
124
+ name: input.name,
125
+ mimeType: input.mimeType,
126
+ expectedBytes: input.expectedBytes,
127
+ expectedLastModified: input.expectedLastModified,
128
+ signal: context.signal,
129
+ assertAuthorized: directory.verify,
130
+ };
131
+ return {
132
+ reference: relink
133
+ ? await this.references.relinkFromDirectory(scope, input.id, directory.root, input.path, options)
134
+ : await this.references.createFromDirectory(scope, directory.root, input.path, options),
135
+ };
136
+ }
137
+ finally {
138
+ await directory.close();
139
+ }
94
140
  }
95
141
  async materialize(scope, raw, context) {
96
142
  const input = object(raw, ["assetId", "directoryHandle", "path"]);
97
- const asset = await this.library.get(scope, assetId(input.assetId));
143
+ const asset = await this.get(scope, assetId(input.assetId));
98
144
  const directory = await this.directory(scope, input, context, true);
99
145
  const temporary = directory.location(`.${randomUUID()}.resource-partial`);
100
146
  let destination;
@@ -104,7 +150,7 @@ export class PanelResourceService {
104
150
  await directory.verify();
105
151
  destination = await open(temporary, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
106
152
  identity = await destination.stat();
107
- const source = await this.library.openRead(scope, asset.id);
153
+ const source = await this.openRead(scope, asset.id, { signal: context.signal });
108
154
  const hash = createHash("sha256");
109
155
  let bytes = 0;
110
156
  try {
@@ -126,7 +172,8 @@ export class PanelResourceService {
126
172
  finally {
127
173
  source.body?.destroy();
128
174
  }
129
- if (bytes !== asset.bytes || hash.digest("hex") !== asset.sha256)
175
+ const sha256 = hash.digest("hex");
176
+ if (bytes !== asset.bytes || ("sha256" in asset && sha256 !== asset.sha256))
130
177
  throw new Error("Resource content failed integrity validation");
131
178
  await destination.sync();
132
179
  // A tool may share the granted directory. Verify the actual destination,
@@ -146,7 +193,7 @@ export class PanelResourceService {
146
193
  if (verified.size !== asset.bytes ||
147
194
  verified.mtimeMs !== completed.mtimeMs ||
148
195
  verified.ctimeMs !== completed.ctimeMs ||
149
- copiedHash.digest("hex") !== asset.sha256)
196
+ copiedHash.digest("hex") !== sha256)
150
197
  throw new Error("Materialized resource failed integrity verification");
151
198
  await destination.close();
152
199
  destination = undefined;
@@ -158,7 +205,7 @@ export class PanelResourceService {
158
205
  const current = await lstat(directory.path);
159
206
  if (!current.isFile() || current.isSymbolicLink() || !sameResourceIdentity(current, identity))
160
207
  throw new Error("Resource output changed during publication");
161
- return { assetId: asset.id, path: input.path, bytes: asset.bytes, sha256: asset.sha256 };
208
+ return { assetId: asset.id, path: input.path, bytes: asset.bytes, sha256 };
162
209
  }
163
210
  catch (error) {
164
211
  if (published && identity) {
@@ -224,14 +271,15 @@ export class PanelResourceService {
224
271
  }
225
272
  async read(scope, raw, context) {
226
273
  const input = object(raw, ["assetId", "offset", "length"]);
227
- const asset = await this.library.get(scope, assetId(input.assetId));
274
+ const asset = await this.get(scope, assetId(input.assetId));
228
275
  const offset = integer(input.offset, 0, asset.bytes), length = integer(input.length, 1, CHUNK_BYTES);
229
276
  const expected = Math.min(length, asset.bytes - offset);
230
277
  const chunks = [];
231
278
  let bytes = 0;
232
279
  if (expected) {
233
- const result = await this.library.openRead(scope, asset.id, {
280
+ const result = await this.openRead(scope, asset.id, {
234
281
  range: `bytes=${offset}-${offset + expected - 1}`,
282
+ signal: context.signal,
235
283
  });
236
284
  try {
237
285
  for await (const chunk of result.body) {
@@ -249,7 +297,7 @@ export class PanelResourceService {
249
297
  }
250
298
  }
251
299
  else
252
- await this.library.resolvePath(scope, asset.id);
300
+ await this.openRead(scope, asset.id, { method: "HEAD", signal: context.signal });
253
301
  await this.authorize(scope, context);
254
302
  return {
255
303
  assetId: asset.id,
@@ -271,7 +319,15 @@ export class PanelResourceService {
271
319
  return { assets: assets.slice(offset, offset + limit), total: assets.length };
272
320
  }
273
321
  case "resources.get":
274
- return { asset: await this.library.get(scope, assetId(object(raw, ["id"]).id)) };
322
+ return { asset: await this.get(scope, assetId(object(raw, ["id"]).id)) };
323
+ case "resources.references.create":
324
+ return await this.reference(scope, raw, context);
325
+ case "resources.references.get":
326
+ return { reference: await this.references.get(scope, object(raw, ["id"]).id) };
327
+ case "resources.references.relink":
328
+ return await this.reference(scope, raw, context, true);
329
+ case "resources.references.forget":
330
+ return await this.references.forget(scope, object(raw, ["id"]).id);
275
331
  case "resources.read":
276
332
  return await this.read(scope, raw, context);
277
333
  case "resources.materialize":
@@ -303,14 +359,17 @@ export class PanelResourceService {
303
359
  releaseDirectory(scope, handle) {
304
360
  this.directoryIdentities.delete(`${mediaScopeKey(scope)}:${handle}`);
305
361
  }
306
- cancelScope(scope) {
307
- return this.uploads.cancelScope(scope);
362
+ async cancelScope(scope) {
363
+ await this.references.cancelScope(scope);
364
+ await this.uploads.cancelScope(scope);
308
365
  }
309
- cancelApp(appId) {
310
- return this.uploads.cancelApp(appId);
366
+ async cancelApp(appId) {
367
+ await this.references.cancelApp(appId);
368
+ await this.uploads.cancelApp(appId);
311
369
  }
312
370
  async shutdown() {
313
371
  this.closed = true;
372
+ await this.references.shutdown();
314
373
  await this.uploads.shutdown();
315
374
  this.directoryIdentities.clear();
316
375
  }
@@ -12,5 +12,16 @@ export interface ResourceAsset {
12
12
  sha256: string;
13
13
  createdAt: number;
14
14
  }
15
+ /** Mutable external storage is a reference, never an immutable content digest. */
16
+ export interface ExternalResourceReference {
17
+ id: string;
18
+ kind: "external";
19
+ name: string;
20
+ mimeType: string;
21
+ bytes: number;
22
+ lastModified: number;
23
+ createdAt: number;
24
+ state: "available" | "missing" | "changed";
25
+ }
15
26
  export type MediaScope = ResourceScope;
16
27
  export type MediaAsset = ResourceAsset;
@@ -681,6 +681,8 @@ export function createPanelRuntime(options) {
681
681
  if ([
682
682
  "resources.materialize",
683
683
  "resources.capture",
684
+ "resources.references.create",
685
+ "resources.references.relink",
684
686
  "credentials.connections.authorizeProcess",
685
687
  ].includes(method) &&
686
688
  !app.permissions.includes("process"))
@@ -786,7 +788,12 @@ export function createPanelRuntime(options) {
786
788
  send: (event, payload) => emit(grant, event, payload),
787
789
  };
788
790
  if (method.startsWith("resources.")) {
789
- if (["resources.materialize", "resources.capture"].includes(method) &&
791
+ if ([
792
+ "resources.materialize",
793
+ "resources.capture",
794
+ "resources.references.create",
795
+ "resources.references.relink",
796
+ ].includes(method) &&
790
797
  !grant.app.permissions.includes("process"))
791
798
  error(403, "资源工具交接需要process权限。");
792
799
  return resources.dispatch({ appId: grant.app.id, projectPath: options.bindingCwd ?? options.cwd }, method, params, { resolveDirectory: (handle) => processes.directoryPath(processOwner, handle) });
@@ -26,7 +26,7 @@ function toolInput(value) {
26
26
  for (const resource of input.resources ?? []) {
27
27
  if (!resource ||
28
28
  Object.keys(resource).some((key) => !["assetId", "path"].includes(key)) ||
29
- !/^asset-[a-f0-9]{64}$/.test(resource.assetId))
29
+ !/^(?:asset|external)-[a-f0-9]{64}$/.test(resource.assetId))
30
30
  throw new Error("Invalid tool resource");
31
31
  resourceRelativePath(resource.path);
32
32
  if (paths.has(resource.path))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
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",
@@ -51,13 +51,13 @@
51
51
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
52
52
  },
53
53
  "dependencies": {
54
- "@cjhyy/code-shell-capability-coding": "0.9.13",
55
- "@cjhyy/code-shell-core": "0.9.13",
56
- "@cjhyy/code-shell-web": "0.9.13",
54
+ "@cjhyy/code-shell-capability-coding": "0.9.15",
55
+ "@cjhyy/code-shell-core": "0.9.15",
56
+ "@cjhyy/code-shell-web": "0.9.15",
57
57
  "@modelcontextprotocol/sdk": "^1.12.1",
58
58
  "@types/ws": "^8.18.1",
59
59
  "ws": "^8.21.0",
60
- "@cjhyy/code-shell-link": "0.9.13"
60
+ "@cjhyy/code-shell-link": "0.9.15"
61
61
  },
62
62
  "engines": {
63
63
  "node": ">=20.10"