@cjhyy/code-shell-server 0.9.13 → 0.9.14
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/dist/panels/bridge-contract.d.ts +1 -0
- package/dist/panels/bridge-contract.js +7 -0
- package/dist/panels/resources/directories.d.ts +5 -0
- package/dist/panels/resources/directories.js +12 -2
- package/dist/panels/resources/library.d.ts +5 -0
- package/dist/panels/resources/library.js +8 -5
- package/dist/panels/resources/references.d.ts +48 -0
- package/dist/panels/resources/references.js +422 -0
- package/dist/panels/resources/service.d.ts +13 -1
- package/dist/panels/resources/service.js +74 -15
- package/dist/panels/resources/types.d.ts +11 -0
- package/dist/panels/runtime.js +8 -1
- package/dist/panels/tool-executor.js +1 -1
- package/package.json +5 -5
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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") !==
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
362
|
+
async cancelScope(scope) {
|
|
363
|
+
await this.references.cancelScope(scope);
|
|
364
|
+
await this.uploads.cancelScope(scope);
|
|
308
365
|
}
|
|
309
|
-
cancelApp(appId) {
|
|
310
|
-
|
|
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;
|
package/dist/panels/runtime.js
CHANGED
|
@@ -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 ([
|
|
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.
|
|
3
|
+
"version": "0.9.14",
|
|
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.
|
|
55
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
56
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
54
|
+
"@cjhyy/code-shell-capability-coding": "0.9.14",
|
|
55
|
+
"@cjhyy/code-shell-core": "0.9.14",
|
|
56
|
+
"@cjhyy/code-shell-web": "0.9.14",
|
|
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.
|
|
60
|
+
"@cjhyy/code-shell-link": "0.9.14"
|
|
61
61
|
},
|
|
62
62
|
"engines": {
|
|
63
63
|
"node": ">=20.10"
|