@cjhyy/code-shell-server 0.9.7 → 0.9.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -23
- package/dist/bin/code-shell-project-runtime.d.ts +2 -0
- package/dist/bin/code-shell-project-runtime.js +8 -0
- package/dist/desktop-web/http-api.d.ts +43 -0
- package/dist/desktop-web/http-api.js +376 -0
- package/dist/hub/approval-lease.d.ts +30 -0
- package/dist/hub/approval-lease.js +89 -0
- package/dist/hub/auth-http.d.ts +23 -0
- package/dist/hub/auth-http.js +259 -0
- package/dist/hub/auth-store.d.ts +52 -0
- package/dist/hub/auth-store.js +316 -0
- package/dist/hub/configuration.d.ts +64 -0
- package/dist/hub/configuration.js +555 -0
- package/dist/hub/files.d.ts +9 -0
- package/dist/hub/files.js +278 -0
- package/dist/hub/mcp-configuration.d.ts +51 -0
- package/dist/hub/mcp-configuration.js +558 -0
- package/dist/hub/mcp-probe.d.ts +24 -0
- package/dist/hub/mcp-probe.js +392 -0
- package/dist/hub/model-probe.d.ts +20 -0
- package/dist/hub/model-probe.js +193 -0
- package/dist/hub/outbound-transport.d.ts +36 -0
- package/dist/hub/outbound-transport.js +122 -0
- package/dist/hub/run-replay.d.ts +42 -0
- package/dist/hub/run-replay.js +89 -0
- package/dist/hub/session-management.d.ts +36 -0
- package/dist/hub/session-management.js +405 -0
- package/dist/hub/skills-management.d.ts +43 -0
- package/dist/hub/skills-management.js +487 -0
- package/dist/hub/uploads.d.ts +22 -0
- package/dist/hub/uploads.js +241 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.desktop-web.d.ts +2 -0
- package/dist/index.desktop-web.js +2 -0
- package/dist/index.js +3 -0
- package/dist/index.links.d.ts +6 -0
- package/dist/index.links.js +6 -0
- package/dist/index.panels.d.ts +9 -0
- package/dist/index.panels.js +8 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +2 -0
- package/dist/index.storage.js +2 -0
- package/dist/links/catalog.d.ts +10 -0
- package/dist/links/catalog.js +41 -0
- package/dist/links/device-oauth.d.ts +52 -0
- package/dist/links/device-oauth.js +255 -0
- package/dist/links/http.d.ts +34 -0
- package/dist/links/http.js +144 -0
- package/dist/links/service.d.ts +54 -0
- package/dist/links/service.js +578 -0
- package/dist/links/types.d.ts +6 -0
- package/dist/links/types.js +1 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
- package/dist/mobile-remote/pending-approvals.js +2 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +90 -15
- package/dist/mobile-remote/room-manager.js +17 -5
- package/dist/panels/agent-task-host.d.ts +32 -0
- package/dist/panels/agent-task-host.js +457 -0
- package/dist/panels/agent-task-models.d.ts +27 -0
- package/dist/panels/agent-task-models.js +51 -0
- package/dist/panels/agent-task-service.d.ts +105 -0
- package/dist/panels/agent-task-service.js +283 -0
- package/dist/panels/http.d.ts +34 -0
- package/dist/panels/http.js +37 -0
- package/dist/panels/hub-binding.d.ts +12 -0
- package/dist/panels/hub-binding.js +174 -0
- package/dist/panels/management-http.d.ts +29 -0
- package/dist/panels/management-http.js +119 -0
- package/dist/panels/management.d.ts +39 -0
- package/dist/panels/management.js +494 -0
- package/dist/panels/process-files.d.ts +14 -0
- package/dist/panels/process-files.js +299 -0
- package/dist/panels/process-service.d.ts +96 -0
- package/dist/panels/process-service.js +501 -0
- package/dist/panels/runtime-services.d.ts +24 -0
- package/dist/panels/runtime-services.js +462 -0
- package/dist/panels/runtime.d.ts +51 -0
- package/dist/panels/runtime.js +1063 -0
- package/dist/panels/storage-store.d.ts +9 -0
- package/dist/panels/storage-store.js +154 -0
- package/dist/panels/types.d.ts +52 -0
- package/dist/panels/types.js +1 -0
- package/dist/project-runtime/control-server.d.ts +19 -0
- package/dist/project-runtime/control-server.js +300 -0
- package/dist/project-runtime/docker-provider.d.ts +33 -0
- package/dist/project-runtime/docker-provider.js +487 -0
- package/dist/project-runtime/lock.d.ts +6 -0
- package/dist/project-runtime/lock.js +43 -0
- package/dist/project-runtime/managed-entry.d.ts +10 -0
- package/dist/project-runtime/managed-entry.js +113 -0
- package/dist/project-runtime/manager.d.ts +40 -0
- package/dist/project-runtime/manager.js +249 -0
- package/dist/project-runtime/proxy.d.ts +22 -0
- package/dist/project-runtime/proxy.js +706 -0
- package/dist/project-runtime/registry.d.ts +54 -0
- package/dist/project-runtime/registry.js +286 -0
- package/dist/project-runtime/types.d.ts +43 -0
- package/dist/project-runtime/types.js +1 -0
- package/dist/serve/cli.d.ts +5 -0
- package/dist/serve/cli.js +82 -23
- package/dist/serve/headless-server.d.ts +11 -0
- package/dist/serve/headless-server.js +737 -83
- package/dist/session-titles-store.d.ts +17 -0
- package/dist/session-titles-store.js +174 -0
- package/dist/static-files.js +1 -0
- package/dist/worker-bridge-core.js +17 -6
- package/package.json +19 -5
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { chmod, link, lstat, mkdir, open, opendir, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
|
|
5
|
+
import { acquireLockOnPath } from "@cjhyy/code-shell-core/internal";
|
|
6
|
+
import { DEFAULT_PANEL_APP_STORAGE_QUOTA_BYTES, panelAppStorageKey, panelAppStoragePath, preparePanelAppStorage, readPanelAppStorage, writePanelAppStorage, } from "./storage-store.js";
|
|
7
|
+
const MAX_READ_BYTES = 480 * 1024;
|
|
8
|
+
const MAX_WRITE_BYTES = 384 * 1024;
|
|
9
|
+
const MAX_LIST_ENTRIES = 200;
|
|
10
|
+
const TEXT_EXTENSIONS = new Set([
|
|
11
|
+
".css",
|
|
12
|
+
".csv",
|
|
13
|
+
".html",
|
|
14
|
+
".json",
|
|
15
|
+
".md",
|
|
16
|
+
".svg",
|
|
17
|
+
".tsv",
|
|
18
|
+
".txt",
|
|
19
|
+
".yaml",
|
|
20
|
+
".yml",
|
|
21
|
+
]);
|
|
22
|
+
const METHOD_PERMISSIONS = {
|
|
23
|
+
"storage.get": "storage",
|
|
24
|
+
"storage.set": "storage",
|
|
25
|
+
"storage.delete": "storage",
|
|
26
|
+
"workspace.info": "workspace.info",
|
|
27
|
+
"workspace.list": "workspace.read",
|
|
28
|
+
"workspace.readText": "workspace.read",
|
|
29
|
+
"workspace.writeText": "workspace.write",
|
|
30
|
+
};
|
|
31
|
+
const writeQueues = new Map();
|
|
32
|
+
function revision(bytes) {
|
|
33
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
34
|
+
}
|
|
35
|
+
function allowedSegment(segment) {
|
|
36
|
+
return (Boolean(segment) &&
|
|
37
|
+
segment !== "." &&
|
|
38
|
+
segment !== ".." &&
|
|
39
|
+
!segment.startsWith(".") &&
|
|
40
|
+
segment.toLowerCase() !== "node_modules" &&
|
|
41
|
+
!/[\\:\u0000-\u001f\u007f]/u.test(segment) &&
|
|
42
|
+
!/[. ]$/u.test(segment) &&
|
|
43
|
+
!/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/iu.test(segment.split(".", 1)[0].trimEnd()));
|
|
44
|
+
}
|
|
45
|
+
function relativePath(params, allowRoot = false) {
|
|
46
|
+
const path = params?.path;
|
|
47
|
+
if (allowRoot && (path === undefined || path === "" || path === "."))
|
|
48
|
+
return ".";
|
|
49
|
+
if (typeof path !== "string" ||
|
|
50
|
+
!path ||
|
|
51
|
+
path.length > 512 ||
|
|
52
|
+
isAbsolute(path) ||
|
|
53
|
+
!path.split("/").every(allowedSegment))
|
|
54
|
+
throw new Error("workspace path must be a safe relative path");
|
|
55
|
+
return path;
|
|
56
|
+
}
|
|
57
|
+
function requireText(path) {
|
|
58
|
+
if (!TEXT_EXTENSIONS.has(extname(path).toLowerCase())) {
|
|
59
|
+
throw new Error("workspace file type is not allowed");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function authorized(scope) {
|
|
63
|
+
if (!(await scope.isAuthorized()))
|
|
64
|
+
throw new Error("Panel App owner is no longer authorized");
|
|
65
|
+
}
|
|
66
|
+
async function queued(key, operation) {
|
|
67
|
+
const previous = writeQueues.get(key) ?? Promise.resolve();
|
|
68
|
+
let release;
|
|
69
|
+
const pending = new Promise((resolveQueue) => {
|
|
70
|
+
release = resolveQueue;
|
|
71
|
+
});
|
|
72
|
+
const tail = previous.then(() => pending);
|
|
73
|
+
writeQueues.set(key, tail);
|
|
74
|
+
await previous;
|
|
75
|
+
try {
|
|
76
|
+
return await operation();
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
release();
|
|
80
|
+
if (writeQueues.get(key) === tail)
|
|
81
|
+
writeQueues.delete(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Share Desktop's per-file lock without blocking the Node event loop during contention. */
|
|
85
|
+
async function storageLock(file) {
|
|
86
|
+
const deadline = Date.now() + 5_000;
|
|
87
|
+
for (;;) {
|
|
88
|
+
try {
|
|
89
|
+
return acquireLockOnPath(file, -1);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code !== "ELOCKED" || Date.now() >= deadline)
|
|
93
|
+
throw error;
|
|
94
|
+
await new Promise((done) => setTimeout(done, 20));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Node-only implementation of the existing Panel SDK's bounded disk operations. */
|
|
99
|
+
export class PanelRuntimeServices {
|
|
100
|
+
roots = new Map();
|
|
101
|
+
dataDir;
|
|
102
|
+
constructor(options) {
|
|
103
|
+
this.dataDir = resolve(options.dataDir);
|
|
104
|
+
}
|
|
105
|
+
async call(input, method, params) {
|
|
106
|
+
if (!input ||
|
|
107
|
+
!/^[a-z][a-z0-9-]{0,63}$/.test(input.appId) ||
|
|
108
|
+
!input.cwd ||
|
|
109
|
+
!input.projectPath ||
|
|
110
|
+
!isAbsolute(input.cwd) ||
|
|
111
|
+
!isAbsolute(input.projectPath) ||
|
|
112
|
+
!Array.isArray(input.permissions) ||
|
|
113
|
+
typeof input.isAuthorized !== "function")
|
|
114
|
+
throw new Error("Panel App scope is invalid");
|
|
115
|
+
const scope = { ...input, permissions: [...input.permissions] };
|
|
116
|
+
await authorized(scope);
|
|
117
|
+
const permission = METHOD_PERMISSIONS[method];
|
|
118
|
+
if (!permission)
|
|
119
|
+
throw new Error(`Panel App method is unsupported by this host: ${method}`);
|
|
120
|
+
if (!scope.permissions.includes(permission))
|
|
121
|
+
throw new Error(`Panel App permission denied: ${permission}`);
|
|
122
|
+
if (method.startsWith("workspace.") &&
|
|
123
|
+
method !== "workspace.info" &&
|
|
124
|
+
!scope.permissions.includes("context.workspace")) {
|
|
125
|
+
throw new Error("Panel App permission denied: context.workspace");
|
|
126
|
+
}
|
|
127
|
+
const result = method.startsWith("storage.")
|
|
128
|
+
? await this.storage(scope, method, params)
|
|
129
|
+
: await this.workspace(scope, method, params);
|
|
130
|
+
await authorized(scope);
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
async storage(scope, method, params) {
|
|
134
|
+
const key = panelAppStorageKey(params);
|
|
135
|
+
const file = panelAppStoragePath(this.dataDir, scope.appId, scope.projectPath);
|
|
136
|
+
const quota = DEFAULT_PANEL_APP_STORAGE_QUOTA_BYTES;
|
|
137
|
+
if (method === "storage.get") {
|
|
138
|
+
const state = await readPanelAppStorage(file, quota);
|
|
139
|
+
return Object.hasOwn(state, key) ? state[key] : null;
|
|
140
|
+
}
|
|
141
|
+
const encoded = method === "storage.set" ? JSON.stringify(params?.value) : null;
|
|
142
|
+
if (method === "storage.set" && encoded === undefined)
|
|
143
|
+
throw new Error("Panel App storage only accepts JSON values");
|
|
144
|
+
return queued(file, async () => {
|
|
145
|
+
await authorized(scope);
|
|
146
|
+
await preparePanelAppStorage(file);
|
|
147
|
+
const release = await storageLock(file);
|
|
148
|
+
try {
|
|
149
|
+
const state = await readPanelAppStorage(file, quota);
|
|
150
|
+
const existed = Object.hasOwn(state, key);
|
|
151
|
+
if (method === "storage.set")
|
|
152
|
+
state[key] = JSON.parse(encoded);
|
|
153
|
+
else
|
|
154
|
+
delete state[key];
|
|
155
|
+
await writePanelAppStorage(file, state, quota, () => authorized(scope));
|
|
156
|
+
return method === "storage.set" ? true : existed;
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
release();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
async root(scope) {
|
|
164
|
+
const lexical = resolve(scope.cwd);
|
|
165
|
+
const metadata = await lstat(lexical);
|
|
166
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
167
|
+
throw new Error("workspace root is unavailable");
|
|
168
|
+
const path = await realpath(lexical);
|
|
169
|
+
const previous = this.roots.get(lexical);
|
|
170
|
+
if (previous &&
|
|
171
|
+
(previous.path !== path ||
|
|
172
|
+
previous.identity.dev !== metadata.dev ||
|
|
173
|
+
previous.identity.ino !== metadata.ino)) {
|
|
174
|
+
throw new Error("workspace root changed; reopen the panel");
|
|
175
|
+
}
|
|
176
|
+
this.roots.set(lexical, { path, identity: { dev: metadata.dev, ino: metadata.ino } });
|
|
177
|
+
return path;
|
|
178
|
+
}
|
|
179
|
+
async openPath(root, path, directory) {
|
|
180
|
+
const held = [];
|
|
181
|
+
const parts = path === "." ? [] : path.split("/");
|
|
182
|
+
const close = async () => {
|
|
183
|
+
await Promise.all(held.map(({ handle }) => handle.close().catch(() => { })));
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
let current = root;
|
|
187
|
+
for (let index = -1; index < parts.length; index++) {
|
|
188
|
+
if (index >= 0)
|
|
189
|
+
current = join(current, parts[index]);
|
|
190
|
+
const parent = held.at(-1);
|
|
191
|
+
const location = process.platform === "linux" && parent
|
|
192
|
+
? `/proc/self/fd/${parent.handle.fd}/${parts[index]}`
|
|
193
|
+
: current;
|
|
194
|
+
const isDirectory = index < parts.length - 1 || directory;
|
|
195
|
+
const handle = await open(location, constants.O_RDONLY |
|
|
196
|
+
(constants.O_NOFOLLOW ?? 0) |
|
|
197
|
+
(constants.O_NONBLOCK ?? 0) |
|
|
198
|
+
(isDirectory ? (constants.O_DIRECTORY ?? 0) : 0));
|
|
199
|
+
const metadata = await handle.stat().catch(async (error) => {
|
|
200
|
+
await handle.close();
|
|
201
|
+
throw error;
|
|
202
|
+
});
|
|
203
|
+
held.push({ handle, path: current, identity: { dev: metadata.dev, ino: metadata.ino } });
|
|
204
|
+
if (isDirectory ? !metadata.isDirectory() : !metadata.isFile())
|
|
205
|
+
throw new Error("workspace path must be a regular file or directory");
|
|
206
|
+
}
|
|
207
|
+
const verify = async () => {
|
|
208
|
+
for (const entry of held) {
|
|
209
|
+
const actual = await lstat(entry.path);
|
|
210
|
+
if (actual.isSymbolicLink() ||
|
|
211
|
+
actual.dev !== entry.identity.dev ||
|
|
212
|
+
actual.ino !== entry.identity.ino) {
|
|
213
|
+
throw new Error("workspace path changed while the panel was accessing it");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
await verify();
|
|
218
|
+
const handle = held.at(-1).handle;
|
|
219
|
+
return {
|
|
220
|
+
handle,
|
|
221
|
+
verify,
|
|
222
|
+
close,
|
|
223
|
+
path: process.platform === "linux" ? `/proc/self/fd/${handle.fd}` : current,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
await close();
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async read(handle) {
|
|
232
|
+
const metadata = await handle.stat();
|
|
233
|
+
if (!metadata.isFile() || metadata.size > MAX_READ_BYTES)
|
|
234
|
+
throw new Error("workspace file is too large or is not a regular file");
|
|
235
|
+
const bytes = Buffer.allocUnsafe(MAX_READ_BYTES + 1);
|
|
236
|
+
let length = 0;
|
|
237
|
+
while (length < bytes.length) {
|
|
238
|
+
const chunk = await handle.read(bytes, length, bytes.length - length, length);
|
|
239
|
+
if (!chunk.bytesRead)
|
|
240
|
+
break;
|
|
241
|
+
length += chunk.bytesRead;
|
|
242
|
+
}
|
|
243
|
+
if (length > MAX_READ_BYTES)
|
|
244
|
+
throw new Error("workspace file is too large");
|
|
245
|
+
return { bytes: bytes.subarray(0, length), metadata };
|
|
246
|
+
}
|
|
247
|
+
async workspace(scope, method, params) {
|
|
248
|
+
const root = await this.root(scope);
|
|
249
|
+
if (method === "workspace.info") {
|
|
250
|
+
let gitBranch = null;
|
|
251
|
+
try {
|
|
252
|
+
const git = await this.openPath(root, ".git/HEAD", false);
|
|
253
|
+
try {
|
|
254
|
+
const { bytes } = await this.read(git.handle);
|
|
255
|
+
if (bytes.length <= 4096) {
|
|
256
|
+
const name = /^ref: refs\/heads\/(.+)$/m.exec(bytes.toString("utf8").trim())?.[1];
|
|
257
|
+
if (name && name.length <= 255 && !/[\u0000-\u001f\u007f]/u.test(name))
|
|
258
|
+
gitBranch = name;
|
|
259
|
+
}
|
|
260
|
+
await git.verify();
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await git.close();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
/* Git metadata is optional, and never exposed as file content. */
|
|
268
|
+
}
|
|
269
|
+
await this.root(scope);
|
|
270
|
+
return { name: basename(scope.cwd), root: scope.cwd, trusted: true, gitBranch };
|
|
271
|
+
}
|
|
272
|
+
const path = relativePath(params, method === "workspace.list");
|
|
273
|
+
if (method === "workspace.writeText")
|
|
274
|
+
return this.write(scope, root, path, params);
|
|
275
|
+
if (method === "workspace.readText")
|
|
276
|
+
requireText(path);
|
|
277
|
+
let opened;
|
|
278
|
+
try {
|
|
279
|
+
opened = await this.openPath(root, path, method === "workspace.list");
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (method === "workspace.list" && error.code === "ENOENT")
|
|
283
|
+
return { path, entries: [], truncated: false };
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
if (method === "workspace.readText") {
|
|
288
|
+
const { bytes, metadata } = await this.read(opened.handle);
|
|
289
|
+
const content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
290
|
+
await opened.verify();
|
|
291
|
+
await this.root(scope);
|
|
292
|
+
return {
|
|
293
|
+
path,
|
|
294
|
+
content,
|
|
295
|
+
size: bytes.length,
|
|
296
|
+
modifiedAt: metadata.mtimeMs,
|
|
297
|
+
revision: revision(bytes),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const entries = [];
|
|
301
|
+
let examined = 0, truncated = false;
|
|
302
|
+
const listing = await opendir(opened.path);
|
|
303
|
+
for await (const entry of listing) {
|
|
304
|
+
if (++examined > 4096) {
|
|
305
|
+
truncated = true;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
if (!allowedSegment(entry.name) || entry.isSymbolicLink())
|
|
309
|
+
continue;
|
|
310
|
+
const child = path === "." ? entry.name : `${path}/${entry.name}`;
|
|
311
|
+
if (child.length > 512 ||
|
|
312
|
+
(!entry.isDirectory() && !TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())))
|
|
313
|
+
continue;
|
|
314
|
+
const metadata = await lstat(join(opened.path, entry.name)).catch(() => null);
|
|
315
|
+
if (!metadata ||
|
|
316
|
+
metadata.isSymbolicLink() ||
|
|
317
|
+
(!metadata.isDirectory() && !metadata.isFile()))
|
|
318
|
+
continue;
|
|
319
|
+
if (entries.length >= MAX_LIST_ENTRIES) {
|
|
320
|
+
truncated = true;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
entries.push({
|
|
324
|
+
name: entry.name,
|
|
325
|
+
path: child,
|
|
326
|
+
kind: metadata.isDirectory() ? "directory" : "file",
|
|
327
|
+
...(metadata.isFile() ? { size: metadata.size, modifiedAt: metadata.mtimeMs } : {}),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
331
|
+
await opened.verify();
|
|
332
|
+
await this.root(scope);
|
|
333
|
+
return { path, entries, truncated };
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
await opened.close();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async ensureDirectory(root, path) {
|
|
340
|
+
let current = root;
|
|
341
|
+
for (const segment of path === "." ? [] : path.split("/")) {
|
|
342
|
+
current = join(current, segment);
|
|
343
|
+
try {
|
|
344
|
+
await mkdir(current, { mode: 0o755 });
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
if (error.code !== "EEXIST")
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
const metadata = await lstat(current);
|
|
351
|
+
if (!metadata.isDirectory() ||
|
|
352
|
+
metadata.isSymbolicLink() ||
|
|
353
|
+
!(await realpath(current)).startsWith(`${root}${sep}`)) {
|
|
354
|
+
throw new Error("workspace path contains an unsafe directory");
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async write(scope, root, path, params) {
|
|
359
|
+
requireText(path);
|
|
360
|
+
const { content, expectedModifiedAt, expectedRevision } = (params ?? {});
|
|
361
|
+
if (typeof content !== "string" || Buffer.byteLength(content) > MAX_WRITE_BYTES)
|
|
362
|
+
throw new Error(`workspace text must be at most ${MAX_WRITE_BYTES} bytes`);
|
|
363
|
+
if (expectedModifiedAt !== undefined &&
|
|
364
|
+
expectedModifiedAt !== null &&
|
|
365
|
+
(typeof expectedModifiedAt !== "number" || !Number.isFinite(expectedModifiedAt)))
|
|
366
|
+
throw new Error("expectedModifiedAt must be a number, null, or omitted");
|
|
367
|
+
if (expectedRevision !== undefined &&
|
|
368
|
+
(typeof expectedRevision !== "string" || !/^sha256:[0-9a-f]{64}$/.test(expectedRevision)))
|
|
369
|
+
throw new Error("expectedRevision must be a sha256 revision or omitted");
|
|
370
|
+
if (expectedRevision === undefined && expectedModifiedAt === undefined)
|
|
371
|
+
throw new Error("workspace.writeText requires expectedModifiedAt or expectedRevision to prevent blind overwrites");
|
|
372
|
+
const target = join(root, path);
|
|
373
|
+
return queued(target, async () => {
|
|
374
|
+
await authorized(scope);
|
|
375
|
+
await this.root(scope);
|
|
376
|
+
await this.ensureDirectory(root, dirname(path));
|
|
377
|
+
const parent = await this.openPath(root, dirname(path), true);
|
|
378
|
+
const stableTarget = join(parent.path, basename(path));
|
|
379
|
+
const temporary = join(parent.path, `.${basename(path)}.${randomUUID()}.tmp`);
|
|
380
|
+
try {
|
|
381
|
+
let modifiedAt = null, currentRevision = null, mode = 0o644;
|
|
382
|
+
let existing;
|
|
383
|
+
try {
|
|
384
|
+
existing = await this.openPath(root, path, false);
|
|
385
|
+
const metadata = await existing.handle.stat();
|
|
386
|
+
modifiedAt = metadata.mtimeMs;
|
|
387
|
+
mode = metadata.mode & 0o777;
|
|
388
|
+
if (expectedRevision !== undefined)
|
|
389
|
+
currentRevision = revision((await this.read(existing.handle)).bytes);
|
|
390
|
+
await existing.verify();
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
if (error.code !== "ENOENT")
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
await existing?.close();
|
|
398
|
+
}
|
|
399
|
+
if (expectedRevision !== undefined
|
|
400
|
+
? currentRevision !== expectedRevision
|
|
401
|
+
: expectedModifiedAt === null
|
|
402
|
+
? modifiedAt !== null
|
|
403
|
+
: modifiedAt === null || Math.abs(modifiedAt - expectedModifiedAt) > 0.001) {
|
|
404
|
+
throw new Error("workspace file changed since it was opened");
|
|
405
|
+
}
|
|
406
|
+
const bytes = Buffer.from(content, "utf8");
|
|
407
|
+
await writeFile(temporary, bytes, { mode: 0o600, flag: "wx" });
|
|
408
|
+
await parent.verify();
|
|
409
|
+
await this.root(scope);
|
|
410
|
+
await authorized(scope);
|
|
411
|
+
await parent.verify();
|
|
412
|
+
// The owner check may await transport state. A user or another host
|
|
413
|
+
// can edit the file during that await, so validate CAS again at commit.
|
|
414
|
+
if (expectedRevision !== undefined || expectedModifiedAt !== null) {
|
|
415
|
+
let finalTarget;
|
|
416
|
+
try {
|
|
417
|
+
finalTarget = await this.openPath(root, path, false);
|
|
418
|
+
const finalMetadata = await finalTarget.handle.stat();
|
|
419
|
+
const conflicts = expectedRevision !== undefined
|
|
420
|
+
? revision((await this.read(finalTarget.handle)).bytes) !== expectedRevision
|
|
421
|
+
: Math.abs(finalMetadata.mtimeMs - expectedModifiedAt) > 0.001;
|
|
422
|
+
if (conflicts)
|
|
423
|
+
throw new Error("workspace file changed since it was opened");
|
|
424
|
+
await finalTarget.verify();
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
if (error.code === "ENOENT")
|
|
428
|
+
throw new Error("workspace file changed since it was opened", { cause: error });
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
await finalTarget?.close();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (expectedModifiedAt === null && expectedRevision === undefined) {
|
|
436
|
+
try {
|
|
437
|
+
await link(temporary, stableTarget);
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (error.code === "EEXIST")
|
|
441
|
+
throw new Error("workspace file changed since it was opened", { cause: error });
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
else
|
|
446
|
+
await rename(temporary, stableTarget);
|
|
447
|
+
await chmod(stableTarget, mode).catch(() => { });
|
|
448
|
+
const metadata = await lstat(stableTarget);
|
|
449
|
+
return {
|
|
450
|
+
path,
|
|
451
|
+
size: bytes.length,
|
|
452
|
+
modifiedAt: metadata.mtimeMs,
|
|
453
|
+
revision: revision(bytes),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
await rm(temporary, { force: true }).catch(() => { });
|
|
458
|
+
await parent.close();
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { type InstalledPanelApp } from "@cjhyy/code-shell-core";
|
|
3
|
+
import { type PanelManagementOptions } from "./management.js";
|
|
4
|
+
import type { PanelSnapshot } from "./types.js";
|
|
5
|
+
export declare const panelWebCompatibility: NonNullable<PanelManagementOptions["compatibility"]>;
|
|
6
|
+
export interface PanelTaskScope {
|
|
7
|
+
instanceId: string;
|
|
8
|
+
ownerId: string;
|
|
9
|
+
appId: string;
|
|
10
|
+
appTitle: string;
|
|
11
|
+
projectPath: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
permissions: readonly string[];
|
|
14
|
+
availableSkills: readonly string[];
|
|
15
|
+
isAuthorized(): Promise<boolean>;
|
|
16
|
+
emit(event: string, payload: unknown): void;
|
|
17
|
+
}
|
|
18
|
+
export interface PanelTaskHost {
|
|
19
|
+
call(scope: PanelTaskScope, method: string, params?: unknown): Promise<unknown>;
|
|
20
|
+
revokeInstance(instanceId: string): void;
|
|
21
|
+
close(): void | Promise<void>;
|
|
22
|
+
activeTaskCount?(): number;
|
|
23
|
+
}
|
|
24
|
+
export interface PanelRuntimeOptions {
|
|
25
|
+
cwd: string;
|
|
26
|
+
bindingCwd?: string;
|
|
27
|
+
dataDir: string;
|
|
28
|
+
host: "hub" | "desktop";
|
|
29
|
+
publicPathPrefix?: string;
|
|
30
|
+
snapshot: () => Promise<PanelSnapshot>;
|
|
31
|
+
ownerId: (request: IncomingMessage) => Promise<string | undefined>;
|
|
32
|
+
isAuthorized: (request: IncomingMessage) => Promise<boolean>;
|
|
33
|
+
/** Host/test seam. HTTP callers never provide install paths or this catalog. */
|
|
34
|
+
listInstalled?: () => Promise<InstalledPanelApp[]>;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
agentTasks?: PanelTaskHost;
|
|
37
|
+
createAgentTasks?: (hooks: {
|
|
38
|
+
onPanelAction(scope: PanelTaskScope, input: Record<string, unknown>): Promise<unknown>;
|
|
39
|
+
}) => PanelTaskHost;
|
|
40
|
+
}
|
|
41
|
+
/** Owner-bound capability grants serve reviewed static bytes to an opaque-origin iframe. */
|
|
42
|
+
export declare function createPanelRuntime(options: PanelRuntimeOptions): {
|
|
43
|
+
activeTaskCount: () => number;
|
|
44
|
+
panelAction(ownerId: string, sessionId: string, input: Record<string, unknown>): Promise<unknown>;
|
|
45
|
+
ownsAssets(request: IncomingMessage): boolean;
|
|
46
|
+
cancelOwner: (owner: string) => void;
|
|
47
|
+
invalidate(appId?: string): void;
|
|
48
|
+
close(): void;
|
|
49
|
+
handle(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
|
|
50
|
+
handleAssets(request: IncomingMessage, response: ServerResponse): Promise<boolean>;
|
|
51
|
+
};
|