@cjhyy/code-shell-server 0.9.9 → 0.9.11
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/index.panels.d.ts +8 -0
- package/dist/index.panels.js +8 -0
- package/dist/panels/bridge-contract.d.ts +72 -0
- package/dist/panels/bridge-contract.js +92 -0
- package/dist/panels/connections.d.ts +31 -0
- package/dist/panels/connections.js +118 -0
- package/dist/panels/process-entry.d.ts +2 -0
- package/dist/panels/process-entry.js +52 -0
- package/dist/panels/process-service.d.ts +61 -0
- package/dist/panels/process-service.js +367 -33
- package/dist/panels/process-state.d.ts +58 -0
- package/dist/panels/process-state.js +87 -0
- package/dist/panels/resources/directories.d.ts +12 -0
- package/dist/panels/resources/directories.js +89 -0
- package/dist/panels/resources/library.d.ts +63 -0
- package/dist/panels/resources/library.js +423 -0
- package/dist/panels/resources/service.d.ts +45 -0
- package/dist/panels/resources/service.js +317 -0
- package/dist/panels/resources/storage.d.ts +10 -0
- package/dist/panels/resources/storage.js +102 -0
- package/dist/panels/resources/types.d.ts +16 -0
- package/dist/panels/resources/types.js +1 -0
- package/dist/panels/resources/uploads.d.ts +64 -0
- package/dist/panels/resources/uploads.js +527 -0
- package/dist/panels/runtime.d.ts +1 -1
- package/dist/panels/runtime.js +137 -15
- package/dist/panels/tool-executor.d.ts +58 -0
- package/dist/panels/tool-executor.js +421 -0
- package/dist/panels/tool-jobs-storage.d.ts +16 -0
- package/dist/panels/tool-jobs-storage.js +169 -0
- package/dist/panels/tool-jobs.d.ts +103 -0
- package/dist/panels/tool-jobs.js +530 -0
- package/dist/sessions-service.d.ts +2 -0
- package/dist/sessions-service.js +8 -0
- package/package.json +5 -5
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { constants } from "node:fs";
|
|
3
|
+
import { constants, lstatSync, realpathSync } from "node:fs";
|
|
4
4
|
import { access, realpath, stat } from "node:fs/promises";
|
|
5
5
|
import { basename, delimiter, extname, join } from "node:path";
|
|
6
6
|
import { killProcessGroup } from "@cjhyy/code-shell-core/extension";
|
|
7
|
+
import { verifyProcessEntry } from "./process-entry.js";
|
|
8
|
+
import { appendProcessEvent, ProcessReceipts, processLimits, sameProcessOwner, } from "./process-state.js";
|
|
9
|
+
export { processLimits } from "./process-state.js";
|
|
7
10
|
const EXECUTABLE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._+-]{0,127}$/;
|
|
8
11
|
const FILE_ARGUMENT_NAME = /^--[a-zA-Z][a-zA-Z0-9-]{0,63}$/;
|
|
9
|
-
const MAX_ARGUMENTS =
|
|
10
|
-
const MAX_ARGUMENT_LENGTH =
|
|
11
|
-
const MAX_ARGUMENT_BYTES =
|
|
12
|
-
const MAX_CONCURRENT_PROCESSES =
|
|
13
|
-
const MAX_FILE_ARGUMENTS =
|
|
14
|
-
const MAX_EVENT_CHARS =
|
|
15
|
-
const MAX_PROCESS_OUTPUT_BYTES =
|
|
16
|
-
const PROCESS_LIFETIME_MS =
|
|
12
|
+
const MAX_ARGUMENTS = processLimits.maxArguments;
|
|
13
|
+
const MAX_ARGUMENT_LENGTH = processLimits.maxArgumentLength;
|
|
14
|
+
const MAX_ARGUMENT_BYTES = processLimits.maxArgumentBytes;
|
|
15
|
+
const MAX_CONCURRENT_PROCESSES = processLimits.maxConcurrentProcesses;
|
|
16
|
+
const MAX_FILE_ARGUMENTS = processLimits.maxFileArguments;
|
|
17
|
+
const MAX_EVENT_CHARS = processLimits.maxEventChars;
|
|
18
|
+
const MAX_PROCESS_OUTPUT_BYTES = processLimits.maxOutputBytes;
|
|
19
|
+
const PROCESS_LIFETIME_MS = processLimits.maxLifetimeMs;
|
|
17
20
|
export function panelProcessInfo(platform = process.platform, arch = process.arch, report = process.report?.getReport()) {
|
|
18
21
|
if (platform !== "linux")
|
|
19
22
|
return { platform, arch };
|
|
@@ -160,13 +163,20 @@ export class PanelAppProcessService {
|
|
|
160
163
|
executables = new Map();
|
|
161
164
|
directories = new Map();
|
|
162
165
|
fileArguments = new Map();
|
|
166
|
+
entries = new Map();
|
|
163
167
|
processes = new Map();
|
|
168
|
+
retiring = new Map();
|
|
169
|
+
receipts;
|
|
164
170
|
approvedExecutables = new Set();
|
|
165
171
|
guestEpochs = new Map();
|
|
166
172
|
reservations = new Map();
|
|
167
173
|
closed = false;
|
|
168
174
|
constructor(options) {
|
|
169
175
|
this.options = options;
|
|
176
|
+
this.receipts = new ProcessReceipts(options.now);
|
|
177
|
+
}
|
|
178
|
+
now() {
|
|
179
|
+
return this.options.now?.() ?? Date.now();
|
|
170
180
|
}
|
|
171
181
|
epoch(owner) {
|
|
172
182
|
if (this.closed)
|
|
@@ -202,7 +212,13 @@ export class PanelAppProcessService {
|
|
|
202
212
|
if (!path)
|
|
203
213
|
return { available: false, name };
|
|
204
214
|
const handle = randomUUID();
|
|
205
|
-
this.executables.set(handle, {
|
|
215
|
+
this.executables.set(handle, {
|
|
216
|
+
guestId: owner.guestId,
|
|
217
|
+
appId: owner.appId,
|
|
218
|
+
revision: owner.revision,
|
|
219
|
+
name,
|
|
220
|
+
path,
|
|
221
|
+
});
|
|
206
222
|
return { available: true, name, handle };
|
|
207
223
|
}
|
|
208
224
|
async grantDirectory(owner, path) {
|
|
@@ -217,7 +233,14 @@ export class PanelAppProcessService {
|
|
|
217
233
|
if (!info.isDirectory())
|
|
218
234
|
throw new Error("selected process directory is not a directory");
|
|
219
235
|
const handle = randomUUID();
|
|
220
|
-
this.directories.set(handle, {
|
|
236
|
+
this.directories.set(handle, {
|
|
237
|
+
guestId: owner.guestId,
|
|
238
|
+
appId: owner.appId,
|
|
239
|
+
revision: owner.revision,
|
|
240
|
+
path: resolved,
|
|
241
|
+
dev: info.dev,
|
|
242
|
+
ino: info.ino,
|
|
243
|
+
});
|
|
221
244
|
return { handle, path: resolved, name: basename(resolved) || resolved };
|
|
222
245
|
}
|
|
223
246
|
executableName(owner, handle) {
|
|
@@ -225,7 +248,7 @@ export class PanelAppProcessService {
|
|
|
225
248
|
if (typeof handle !== "string")
|
|
226
249
|
throw new Error("executable handle is required");
|
|
227
250
|
const executable = this.executables.get(handle);
|
|
228
|
-
if (!executable || executable
|
|
251
|
+
if (!executable || !sameProcessOwner(executable, owner)) {
|
|
229
252
|
throw new Error("executable handle is invalid or belongs to another Panel App");
|
|
230
253
|
}
|
|
231
254
|
return executable.name;
|
|
@@ -238,7 +261,7 @@ export class PanelAppProcessService {
|
|
|
238
261
|
throw new Error("sealed file arguments require an executable handle");
|
|
239
262
|
}
|
|
240
263
|
const executable = this.executables.get(input.executableHandle);
|
|
241
|
-
if (!executable || executable
|
|
264
|
+
if (!executable || !sameProcessOwner(executable, owner)) {
|
|
242
265
|
throw new Error("executable handle is invalid or belongs to another Panel App");
|
|
243
266
|
}
|
|
244
267
|
if (typeof input.argumentName !== "string" || !FILE_ARGUMENT_NAME.test(input.argumentName)) {
|
|
@@ -254,6 +277,8 @@ export class PanelAppProcessService {
|
|
|
254
277
|
const handle = randomUUID();
|
|
255
278
|
this.fileArguments.set(handle, {
|
|
256
279
|
guestId: owner.guestId,
|
|
280
|
+
appId: owner.appId,
|
|
281
|
+
revision: owner.revision,
|
|
257
282
|
executablePath: executable.path,
|
|
258
283
|
argumentName: input.argumentName,
|
|
259
284
|
path: resolved,
|
|
@@ -261,16 +286,85 @@ export class PanelAppProcessService {
|
|
|
261
286
|
});
|
|
262
287
|
return { handle };
|
|
263
288
|
}
|
|
289
|
+
/** Seal an already authorized directory as a long-option argument for one executable. */
|
|
290
|
+
async grantDirectoryArgument(owner, input) {
|
|
291
|
+
owner = { ...owner };
|
|
292
|
+
const epoch = this.epoch(owner);
|
|
293
|
+
await this.authorize(owner, epoch);
|
|
294
|
+
this.executableName(owner, input.executableHandle);
|
|
295
|
+
if (typeof input.argumentName !== "string" || !FILE_ARGUMENT_NAME.test(input.argumentName))
|
|
296
|
+
throw new Error("sealed directory arguments require a simple long-option name");
|
|
297
|
+
const path = this.directoryPath(owner, input.directoryHandle);
|
|
298
|
+
const executable = this.executables.get(input.executableHandle);
|
|
299
|
+
const handle = randomUUID();
|
|
300
|
+
this.fileArguments.set(handle, {
|
|
301
|
+
guestId: owner.guestId,
|
|
302
|
+
appId: owner.appId,
|
|
303
|
+
revision: owner.revision,
|
|
304
|
+
executablePath: executable.path,
|
|
305
|
+
argumentName: input.argumentName,
|
|
306
|
+
path,
|
|
307
|
+
directoryHandle: input.directoryHandle,
|
|
308
|
+
cleanup: () => { },
|
|
309
|
+
});
|
|
310
|
+
return { handle };
|
|
311
|
+
}
|
|
264
312
|
directoryPath(owner, handle) {
|
|
265
313
|
this.epoch(owner);
|
|
266
314
|
if (typeof handle !== "string")
|
|
267
315
|
throw new Error("directory handle is required");
|
|
268
316
|
const grant = this.directories.get(handle);
|
|
269
|
-
if (!grant || grant
|
|
317
|
+
if (!grant || !sameProcessOwner(grant, owner)) {
|
|
270
318
|
throw new Error("directory handle is invalid or belongs to another Panel App");
|
|
271
319
|
}
|
|
320
|
+
// Final synchronous identity check preserves the existing handle API and avoids
|
|
321
|
+
// an await between validating a cwd and spawning the child into that directory.
|
|
322
|
+
const current = lstatSync(grant.path);
|
|
323
|
+
if (!current.isDirectory() ||
|
|
324
|
+
current.isSymbolicLink() ||
|
|
325
|
+
current.dev !== grant.dev ||
|
|
326
|
+
current.ino !== grant.ino ||
|
|
327
|
+
realpathSync(grant.path) !== grant.path)
|
|
328
|
+
throw new Error("granted process directory changed; select it again");
|
|
272
329
|
return grant.path;
|
|
273
330
|
}
|
|
331
|
+
async resolveEntry(owner, params) {
|
|
332
|
+
owner = { ...owner };
|
|
333
|
+
const epoch = this.epoch(owner);
|
|
334
|
+
await this.authorize(owner, epoch);
|
|
335
|
+
const input = params;
|
|
336
|
+
if (!input || typeof input.name !== "string" || !EXECUTABLE_NAME.test(input.name))
|
|
337
|
+
throw new Error("process.resolveEntry requires a declared entry name");
|
|
338
|
+
this.executableName(owner, input.executableHandle);
|
|
339
|
+
if (!this.options.resolvePackageEntry)
|
|
340
|
+
throw new Error("package entries are unavailable");
|
|
341
|
+
const executableHandle = input.executableHandle;
|
|
342
|
+
const executable = this.executables.get(executableHandle);
|
|
343
|
+
const entry = await this.options.resolvePackageEntry(owner, input.name);
|
|
344
|
+
await this.authorize(owner, epoch);
|
|
345
|
+
const identity = await verifyProcessEntry(entry.path, entry.sha256);
|
|
346
|
+
const fingerprint = await executableFingerprint(executable.path);
|
|
347
|
+
await this.authorize(owner, epoch);
|
|
348
|
+
if (this.executables.get(executableHandle) !== executable)
|
|
349
|
+
throw new Error("Panel App process handles were revoked");
|
|
350
|
+
if ([...this.entries.values()].filter((grant) => grant.guestId === owner.guestId).length >=
|
|
351
|
+
processLimits.maxEntryHandlesPerGuest)
|
|
352
|
+
throw new Error("too many package entry handles");
|
|
353
|
+
const handle = randomUUID();
|
|
354
|
+
this.entries.set(handle, {
|
|
355
|
+
guestId: owner.guestId,
|
|
356
|
+
appId: owner.appId,
|
|
357
|
+
revision: owner.revision,
|
|
358
|
+
name: input.name,
|
|
359
|
+
executableHandle,
|
|
360
|
+
executablePath: executable.path,
|
|
361
|
+
executableFingerprint: fingerprint,
|
|
362
|
+
path: entry.path,
|
|
363
|
+
sha256: entry.sha256,
|
|
364
|
+
identity,
|
|
365
|
+
});
|
|
366
|
+
return { handle, name: input.name, sha256: entry.sha256 };
|
|
367
|
+
}
|
|
274
368
|
async start(owner, params) {
|
|
275
369
|
owner = { ...owner };
|
|
276
370
|
const epoch = this.epoch(owner);
|
|
@@ -279,24 +373,38 @@ export class PanelAppProcessService {
|
|
|
279
373
|
throw new Error("process.spawn requires an executable handle from process.find");
|
|
280
374
|
}
|
|
281
375
|
const executable = this.executables.get(input.executableHandle);
|
|
282
|
-
if (!executable || executable
|
|
376
|
+
if (!executable || !sameProcessOwner(executable, owner)) {
|
|
283
377
|
throw new Error("executable handle is invalid or belongs to another Panel App");
|
|
284
378
|
}
|
|
285
379
|
const cwd = this.directoryPath(owner, input.directoryHandle);
|
|
380
|
+
if (input.stdin !== undefined && input.stdin !== "ignore" && input.stdin !== "pipe")
|
|
381
|
+
throw new Error("process.spawn stdin must be ignore or pipe");
|
|
382
|
+
let entry;
|
|
383
|
+
if (input.entryHandle !== undefined) {
|
|
384
|
+
entry =
|
|
385
|
+
typeof input.entryHandle === "string" ? this.entries.get(input.entryHandle) : undefined;
|
|
386
|
+
if (!entry || !sameProcessOwner(entry, owner))
|
|
387
|
+
throw new Error("package entry handle is invalid or belongs to another Panel App");
|
|
388
|
+
if (entry.executableHandle !== input.executableHandle ||
|
|
389
|
+
entry.executablePath !== executable.path)
|
|
390
|
+
throw new Error("package entry is bound to a different executable");
|
|
391
|
+
}
|
|
286
392
|
const rawArgs = validateArguments(input.args);
|
|
287
393
|
const fileArgumentHandles = validateFileArgumentHandles(input.fileArgumentHandles);
|
|
288
394
|
const sealedArgs = [];
|
|
289
395
|
for (const handle of fileArgumentHandles) {
|
|
290
396
|
const grant = this.fileArguments.get(handle);
|
|
291
|
-
if (!grant || grant
|
|
397
|
+
if (!grant || !sameProcessOwner(grant, owner)) {
|
|
292
398
|
throw new Error("sealed file argument handle is invalid or belongs to another Panel App");
|
|
293
399
|
}
|
|
294
400
|
if (grant.executablePath !== executable.path) {
|
|
295
401
|
throw new Error("sealed file argument is bound to a different executable");
|
|
296
402
|
}
|
|
403
|
+
if (grant.directoryHandle && this.directoryPath(owner, grant.directoryHandle) !== grant.path)
|
|
404
|
+
throw new Error("sealed directory argument was replaced");
|
|
297
405
|
sealedArgs.push(grant.argumentName, grant.path);
|
|
298
406
|
}
|
|
299
|
-
const args = validateArguments([...sealedArgs, ...rawArgs]);
|
|
407
|
+
const args = validateArguments([...(entry ? [entry.path] : []), ...sealedArgs, ...rawArgs]);
|
|
300
408
|
const active = [...this.processes.values()].filter((process) => process.guestId === owner.guestId).length;
|
|
301
409
|
const reserved = this.reservations.get(owner.guestId) ?? 0;
|
|
302
410
|
if (active + reserved >= MAX_CONCURRENT_PROCESSES) {
|
|
@@ -312,6 +420,8 @@ export class PanelAppProcessService {
|
|
|
312
420
|
executablePath: executable.path,
|
|
313
421
|
executableFingerprint: await executableFingerprint(executable.path),
|
|
314
422
|
};
|
|
423
|
+
if (entry && scope.executableFingerprint !== entry.executableFingerprint)
|
|
424
|
+
throw new Error("package entry executable changed; resolve the entry again");
|
|
315
425
|
await this.authorize(owner, epoch);
|
|
316
426
|
const guestScoped = this.options.approvalScope === "guest";
|
|
317
427
|
const approvalKey = (guestScoped ? `${owner.guestId}\0${epoch}\0` : "") +
|
|
@@ -342,12 +452,29 @@ export class PanelAppProcessService {
|
|
|
342
452
|
throw new Error("resolved executable changed; find it and approve it again");
|
|
343
453
|
}
|
|
344
454
|
await this.authorize(owner, epoch);
|
|
455
|
+
if (entry) {
|
|
456
|
+
const current = await this.options.resolvePackageEntry(owner, entry.name);
|
|
457
|
+
if (current.path !== entry.path || current.sha256 !== entry.sha256)
|
|
458
|
+
throw new Error("reviewed package entry changed; resolve it again");
|
|
459
|
+
await this.authorize(owner, epoch);
|
|
460
|
+
if ((await verifyProcessEntry(entry.path, entry.sha256)) !== entry.identity)
|
|
461
|
+
throw new Error("reviewed package entry was replaced; resolve it again");
|
|
462
|
+
}
|
|
345
463
|
const env = safeProcessEnv(this.options.env ?? process.env, this.options.extraPathDirectories?.() ?? []);
|
|
346
464
|
this.assertLive(owner, epoch);
|
|
347
465
|
if (this.executables.get(input.executableHandle) !== executable ||
|
|
348
|
-
this.directoryPath(owner, input.directoryHandle) !== cwd
|
|
466
|
+
this.directoryPath(owner, input.directoryHandle) !== cwd ||
|
|
467
|
+
(entry && this.entries.get(input.entryHandle) !== entry)) {
|
|
349
468
|
throw new Error("Panel App process handles were revoked");
|
|
350
469
|
}
|
|
470
|
+
for (const handle of fileArgumentHandles) {
|
|
471
|
+
const grant = this.fileArguments.get(handle);
|
|
472
|
+
if (!grant || !sameProcessOwner(grant, owner))
|
|
473
|
+
throw new Error("sealed argument was revoked");
|
|
474
|
+
if (grant.directoryHandle &&
|
|
475
|
+
this.directoryPath(owner, grant.directoryHandle) !== grant.path)
|
|
476
|
+
throw new Error("sealed directory argument was replaced");
|
|
477
|
+
}
|
|
351
478
|
this.approvedExecutables.add(approvalKey);
|
|
352
479
|
const processId = randomUUID();
|
|
353
480
|
const child = spawn(executable.path, args, {
|
|
@@ -355,9 +482,28 @@ export class PanelAppProcessService {
|
|
|
355
482
|
detached: process.platform !== "win32",
|
|
356
483
|
env,
|
|
357
484
|
shell: false,
|
|
358
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
485
|
+
stdio: [input.stdin === "pipe" ? "pipe" : "ignore", "pipe", "pipe"],
|
|
359
486
|
windowsHide: true,
|
|
360
487
|
});
|
|
488
|
+
const record = {
|
|
489
|
+
owner: { guestId: owner.guestId, appId: owner.appId, revision: owner.revision },
|
|
490
|
+
processId,
|
|
491
|
+
status: "running",
|
|
492
|
+
startedAt: this.now(),
|
|
493
|
+
cancelRequested: false,
|
|
494
|
+
sequence: 0,
|
|
495
|
+
events: [],
|
|
496
|
+
eventBytes: 0,
|
|
497
|
+
};
|
|
498
|
+
const emit = (event, payload) => {
|
|
499
|
+
const item = appendProcessEvent(record, event, payload);
|
|
500
|
+
try {
|
|
501
|
+
owner.send(event, { ...item.payload });
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// The owner can recover a missed event through process.get.
|
|
505
|
+
}
|
|
506
|
+
};
|
|
361
507
|
const sendOutput = (stream, chunk) => {
|
|
362
508
|
const running = this.processes.get(processId);
|
|
363
509
|
if (running?.child !== child || running.outputTruncated)
|
|
@@ -366,7 +512,7 @@ export class PanelAppProcessService {
|
|
|
366
512
|
running.outputBytes += Buffer.byteLength(text, "utf8");
|
|
367
513
|
if (running.outputBytes > MAX_PROCESS_OUTPUT_BYTES) {
|
|
368
514
|
running.outputTruncated = true;
|
|
369
|
-
|
|
515
|
+
emit("process.output", {
|
|
370
516
|
processId,
|
|
371
517
|
stream: "stderr",
|
|
372
518
|
text: `Process output exceeded ${MAX_PROCESS_OUTPUT_BYTES} bytes; CodeShell stopped it.\n`,
|
|
@@ -375,19 +521,22 @@ export class PanelAppProcessService {
|
|
|
375
521
|
return;
|
|
376
522
|
}
|
|
377
523
|
for (let offset = 0; offset < text.length; offset += MAX_EVENT_CHARS) {
|
|
378
|
-
|
|
524
|
+
emit("process.output", {
|
|
379
525
|
processId,
|
|
380
526
|
stream,
|
|
381
527
|
text: text.slice(offset, offset + MAX_EVENT_CHARS),
|
|
382
528
|
});
|
|
383
529
|
}
|
|
384
530
|
};
|
|
385
|
-
child.stdout?.on("data", (chunk) => sendOutput("stdout", chunk));
|
|
386
|
-
child.stderr?.on("data", (chunk) => sendOutput("stderr", chunk));
|
|
531
|
+
child.stdout?.setEncoding("utf8").on("data", (chunk) => sendOutput("stdout", chunk));
|
|
532
|
+
child.stderr?.setEncoding("utf8").on("data", (chunk) => sendOutput("stderr", chunk));
|
|
533
|
+
child.stdin?.on("error", () => {
|
|
534
|
+
/* write/end report pipe errors to their caller */
|
|
535
|
+
});
|
|
387
536
|
child.once("error", (error) => {
|
|
388
537
|
if (this.processes.get(processId)?.child !== child)
|
|
389
538
|
return;
|
|
390
|
-
|
|
539
|
+
emit("process.output", {
|
|
391
540
|
processId,
|
|
392
541
|
stream: "stderr",
|
|
393
542
|
text: `${error.message}\n`,
|
|
@@ -399,19 +548,43 @@ export class PanelAppProcessService {
|
|
|
399
548
|
this.terminate(running);
|
|
400
549
|
}, PROCESS_LIFETIME_MS);
|
|
401
550
|
lifetime.unref();
|
|
402
|
-
|
|
551
|
+
let finishExit;
|
|
552
|
+
const running = {
|
|
403
553
|
guestId: owner.guestId,
|
|
404
554
|
child,
|
|
405
555
|
lifetime,
|
|
406
556
|
outputBytes: 0,
|
|
407
557
|
outputTruncated: false,
|
|
408
|
-
|
|
558
|
+
record,
|
|
559
|
+
stdinBytes: 0,
|
|
560
|
+
stdinPendingBytes: 0,
|
|
561
|
+
stdinPendingWrites: 0,
|
|
562
|
+
stdinEnded: input.stdin !== "pipe",
|
|
563
|
+
stdinTail: Promise.resolve(),
|
|
564
|
+
exit: new Promise((resolve) => {
|
|
565
|
+
finishExit = resolve;
|
|
566
|
+
}),
|
|
567
|
+
};
|
|
568
|
+
this.processes.set(processId, running);
|
|
409
569
|
child.once("close", (code, signal) => {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
570
|
+
void (async () => {
|
|
571
|
+
clearTimeout(lifetime);
|
|
572
|
+
// Cancellation also waits for the process group, not just its leader.
|
|
573
|
+
await running.termination;
|
|
574
|
+
finishExit({ code, signal });
|
|
575
|
+
this.retiring.delete(processId);
|
|
576
|
+
if (this.processes.get(processId)?.child !== child)
|
|
577
|
+
return;
|
|
578
|
+
this.processes.delete(processId);
|
|
579
|
+
record.status = "exited";
|
|
580
|
+
record.exitedAt = this.now();
|
|
581
|
+
record.code = code;
|
|
582
|
+
record.signal = signal;
|
|
583
|
+
emit("process.exit", { processId, code, signal });
|
|
584
|
+
// Keep the receipt only after the operating system reports the real close.
|
|
585
|
+
if (!this.closed && (this.guestEpochs.get(owner.guestId) ?? 0) === epoch)
|
|
586
|
+
this.receipts.add(record);
|
|
587
|
+
})();
|
|
415
588
|
});
|
|
416
589
|
return { processId, executable: executable.name };
|
|
417
590
|
}
|
|
@@ -424,15 +597,164 @@ export class PanelAppProcessService {
|
|
|
424
597
|
}
|
|
425
598
|
}
|
|
426
599
|
cancel(owner, params) {
|
|
600
|
+
this.epoch(owner);
|
|
427
601
|
const processId = params?.processId;
|
|
428
602
|
if (typeof processId !== "string")
|
|
429
603
|
throw new Error("process.cancel requires processId");
|
|
430
604
|
const running = this.processes.get(processId);
|
|
431
|
-
if (!running || running.
|
|
605
|
+
if (!running || !sameProcessOwner(running.record.owner, owner))
|
|
432
606
|
return { cancelled: false };
|
|
433
607
|
this.terminate(running);
|
|
434
608
|
return { cancelled: true };
|
|
435
609
|
}
|
|
610
|
+
/** Host-only wait captured while the owner is live; never exposes output after revocation. */
|
|
611
|
+
waitForExit(owner, params) {
|
|
612
|
+
const running = this.processes.get(params.processId) ?? this.retiring.get(params.processId);
|
|
613
|
+
if (running && sameProcessOwner(running.record.owner, owner))
|
|
614
|
+
return running.exit;
|
|
615
|
+
const receipt = this.receipts.get(params.processId);
|
|
616
|
+
if (receipt && sameProcessOwner(receipt.owner, owner))
|
|
617
|
+
return Promise.resolve({ code: receipt.code ?? null, signal: receipt.signal ?? null });
|
|
618
|
+
return Promise.reject(new Error("process is unavailable or belongs to another Panel App"));
|
|
619
|
+
}
|
|
620
|
+
async get(owner, params) {
|
|
621
|
+
owner = { ...owner };
|
|
622
|
+
const epoch = this.epoch(owner);
|
|
623
|
+
await this.authorize(owner, epoch);
|
|
624
|
+
const input = params;
|
|
625
|
+
if (typeof input?.processId !== "string" || input.processId.length > 160)
|
|
626
|
+
throw new Error("process.get requires a processId");
|
|
627
|
+
const after = input.afterSequence ?? 0;
|
|
628
|
+
const limit = input.limit ?? 128;
|
|
629
|
+
if (!Number.isSafeInteger(after) || after < 0)
|
|
630
|
+
throw new Error("process.get afterSequence must be a nonnegative integer");
|
|
631
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 256)
|
|
632
|
+
throw new Error("process.get limit must be between 1 and 256");
|
|
633
|
+
const record = this.processes.get(input.processId)?.record ?? this.receipts.get(input.processId);
|
|
634
|
+
if (!record || !sameProcessOwner(record.owner, owner))
|
|
635
|
+
return { found: false, processId: input.processId };
|
|
636
|
+
if (after > record.sequence)
|
|
637
|
+
throw new Error("process.get cursor is ahead of this process");
|
|
638
|
+
const events = record.events
|
|
639
|
+
.filter((event) => event.sequence > after)
|
|
640
|
+
.slice(0, limit);
|
|
641
|
+
const nextSequence = events.at(-1)?.sequence ?? after;
|
|
642
|
+
return {
|
|
643
|
+
found: true,
|
|
644
|
+
processId: record.processId,
|
|
645
|
+
status: record.status,
|
|
646
|
+
startedAt: record.startedAt,
|
|
647
|
+
...(record.exitedAt === undefined ? {} : { exitedAt: record.exitedAt }),
|
|
648
|
+
...(record.expiresAt === undefined ? {} : { expiresAt: record.expiresAt }),
|
|
649
|
+
...(record.code === undefined ? {} : { code: record.code }),
|
|
650
|
+
...(record.signal === undefined ? {} : { signal: record.signal }),
|
|
651
|
+
cancelRequested: record.cancelRequested,
|
|
652
|
+
sequence: record.sequence,
|
|
653
|
+
nextSequence,
|
|
654
|
+
events: structuredClone(events),
|
|
655
|
+
truncated: after < (record.events[0]?.sequence ?? 1) - 1,
|
|
656
|
+
hasMore: nextSequence < record.sequence,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
writableProcess(owner, processId) {
|
|
660
|
+
if (typeof processId !== "string")
|
|
661
|
+
throw new Error("process input requires a processId");
|
|
662
|
+
const running = this.processes.get(processId);
|
|
663
|
+
if (!running || !sameProcessOwner(running.record.owner, owner))
|
|
664
|
+
throw new Error("process is unavailable or belongs to another Panel App");
|
|
665
|
+
if (running.record.status !== "running" ||
|
|
666
|
+
!running.child.stdin ||
|
|
667
|
+
running.child.stdin.destroyed)
|
|
668
|
+
throw new Error("process stdin is unavailable");
|
|
669
|
+
return running;
|
|
670
|
+
}
|
|
671
|
+
async write(owner, params) {
|
|
672
|
+
owner = { ...owner };
|
|
673
|
+
const epoch = this.epoch(owner);
|
|
674
|
+
await this.authorize(owner, epoch);
|
|
675
|
+
const input = params;
|
|
676
|
+
if (typeof input?.text !== "string")
|
|
677
|
+
throw new Error("process.write requires UTF-8 text");
|
|
678
|
+
const bytes = Buffer.byteLength(input.text);
|
|
679
|
+
if (bytes > processLimits.maxStdinChunkBytes)
|
|
680
|
+
throw new Error("process stdin chunk exceeds limit");
|
|
681
|
+
const running = this.writableProcess(owner, input.processId);
|
|
682
|
+
if (running.stdinEnded)
|
|
683
|
+
throw new Error("process stdin has ended");
|
|
684
|
+
if (running.stdinBytes + bytes > processLimits.maxStdinBytes)
|
|
685
|
+
throw new Error("process stdin total exceeds limit");
|
|
686
|
+
if (running.stdinPendingBytes + bytes > processLimits.maxStdinPendingBytes ||
|
|
687
|
+
running.stdinPendingWrites >= processLimits.maxStdinPendingWrites)
|
|
688
|
+
throw new Error("process stdin is backpressured; await pending writes");
|
|
689
|
+
running.stdinBytes += bytes;
|
|
690
|
+
running.stdinPendingBytes += bytes;
|
|
691
|
+
running.stdinPendingWrites++;
|
|
692
|
+
const totalBytes = running.stdinBytes;
|
|
693
|
+
const operation = running.stdinTail
|
|
694
|
+
.then(async () => {
|
|
695
|
+
await this.authorize(owner, epoch);
|
|
696
|
+
this.writableProcess(owner, input.processId);
|
|
697
|
+
await this.sendInput(running, input.text);
|
|
698
|
+
})
|
|
699
|
+
.finally(() => {
|
|
700
|
+
running.stdinPendingBytes -= bytes;
|
|
701
|
+
running.stdinPendingWrites--;
|
|
702
|
+
});
|
|
703
|
+
running.stdinTail = operation.catch(() => {
|
|
704
|
+
running.stdinEnded = true;
|
|
705
|
+
});
|
|
706
|
+
await operation;
|
|
707
|
+
return { bytesWritten: bytes, totalBytes };
|
|
708
|
+
}
|
|
709
|
+
async end(owner, params) {
|
|
710
|
+
owner = { ...owner };
|
|
711
|
+
const epoch = this.epoch(owner);
|
|
712
|
+
await this.authorize(owner, epoch);
|
|
713
|
+
const processId = params?.processId;
|
|
714
|
+
const running = this.writableProcess(owner, processId);
|
|
715
|
+
if (running.stdinEnded)
|
|
716
|
+
throw new Error("process stdin has ended");
|
|
717
|
+
running.stdinEnded = true;
|
|
718
|
+
const operation = running.stdinTail.then(async () => {
|
|
719
|
+
await this.authorize(owner, epoch);
|
|
720
|
+
this.writableProcess(owner, processId);
|
|
721
|
+
await this.sendInput(running);
|
|
722
|
+
});
|
|
723
|
+
running.stdinTail = operation.catch(() => { });
|
|
724
|
+
await operation;
|
|
725
|
+
return { ended: true, totalBytes: running.stdinBytes };
|
|
726
|
+
}
|
|
727
|
+
sendInput(running, text) {
|
|
728
|
+
const stream = running.child.stdin;
|
|
729
|
+
return new Promise((resolve, reject) => {
|
|
730
|
+
let settled = false;
|
|
731
|
+
const finish = (error) => {
|
|
732
|
+
if (settled)
|
|
733
|
+
return;
|
|
734
|
+
settled = true;
|
|
735
|
+
clearTimeout(timer);
|
|
736
|
+
stream.off("error", onError);
|
|
737
|
+
running.child.off("close", onClose);
|
|
738
|
+
if (error)
|
|
739
|
+
reject(error);
|
|
740
|
+
else
|
|
741
|
+
resolve();
|
|
742
|
+
};
|
|
743
|
+
const onError = (error) => finish(error);
|
|
744
|
+
const onClose = () => finish(new Error("process exited before stdin completed"));
|
|
745
|
+
const timer = setTimeout(() => {
|
|
746
|
+
this.terminate(running);
|
|
747
|
+
finish(new Error("process stdin write timed out"));
|
|
748
|
+
}, processLimits.stdinWriteTimeoutMs);
|
|
749
|
+
timer.unref();
|
|
750
|
+
stream.once("error", onError);
|
|
751
|
+
running.child.once("close", onClose);
|
|
752
|
+
if (text === undefined)
|
|
753
|
+
stream.end(() => finish());
|
|
754
|
+
else
|
|
755
|
+
stream.write(text, "utf8", finish);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
436
758
|
revokeGuest(guestId) {
|
|
437
759
|
this.guestEpochs.set(guestId, (this.guestEpochs.get(guestId) ?? 0) + 1);
|
|
438
760
|
if (this.options.approvalScope === "guest") {
|
|
@@ -449,11 +771,17 @@ export class PanelAppProcessService {
|
|
|
449
771
|
if (grant.guestId === guestId)
|
|
450
772
|
this.directories.delete(handle);
|
|
451
773
|
}
|
|
774
|
+
for (const [handle, grant] of this.entries) {
|
|
775
|
+
if (grant.guestId === guestId)
|
|
776
|
+
this.entries.delete(handle);
|
|
777
|
+
}
|
|
778
|
+
this.receipts.revokeGuest(guestId);
|
|
452
779
|
for (const [processId, running] of this.processes) {
|
|
453
780
|
if (running.guestId !== guestId)
|
|
454
781
|
continue;
|
|
455
782
|
clearTimeout(running.lifetime);
|
|
456
783
|
this.terminate(running);
|
|
784
|
+
this.retiring.set(processId, running);
|
|
457
785
|
this.processes.delete(processId);
|
|
458
786
|
}
|
|
459
787
|
for (const [handle, grant] of this.fileArguments) {
|
|
@@ -479,16 +807,22 @@ export class PanelAppProcessService {
|
|
|
479
807
|
...[...this.executables.values()].map((grant) => grant.guestId),
|
|
480
808
|
...[...this.directories.values()].map((grant) => grant.guestId),
|
|
481
809
|
...[...this.fileArguments.values()].map((grant) => grant.guestId),
|
|
810
|
+
...[...this.entries.values()].map((grant) => grant.guestId),
|
|
482
811
|
...[...this.processes.values()].map((running) => running.guestId),
|
|
483
812
|
]);
|
|
484
813
|
for (const guestId of guests)
|
|
485
814
|
this.revokeGuest(guestId);
|
|
486
815
|
this.approvedExecutables.clear();
|
|
816
|
+
this.receipts.close();
|
|
487
817
|
}
|
|
488
818
|
terminate(running) {
|
|
819
|
+
if (running.termination)
|
|
820
|
+
return;
|
|
821
|
+
running.record.cancelRequested = true;
|
|
822
|
+
running.record.status = "stopping";
|
|
489
823
|
const pid = running.child.pid;
|
|
490
824
|
if (typeof pid === "number" && pid > 1) {
|
|
491
|
-
|
|
825
|
+
running.termination = killProcessGroup(pid).catch(() => { });
|
|
492
826
|
return;
|
|
493
827
|
}
|
|
494
828
|
try {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PanelProcessOwner } from "./process-service.js";
|
|
2
|
+
export declare const processLimits: Readonly<{
|
|
3
|
+
maxArguments: 256;
|
|
4
|
+
maxArgumentLength: 8192;
|
|
5
|
+
maxArgumentBytes: number;
|
|
6
|
+
maxConcurrentProcesses: 3;
|
|
7
|
+
maxFileArguments: 8;
|
|
8
|
+
maxEventChars: 16384;
|
|
9
|
+
maxOutputBytes: number;
|
|
10
|
+
maxLifetimeMs: number;
|
|
11
|
+
receiptTtlMs: number;
|
|
12
|
+
maxRetainedProcesses: 128;
|
|
13
|
+
maxRetainedProcessesPerGuest: 32;
|
|
14
|
+
maxEventsPerProcess: 256;
|
|
15
|
+
maxRetainedOutputBytes: number;
|
|
16
|
+
maxStdinChunkBytes: number;
|
|
17
|
+
maxStdinBytes: number;
|
|
18
|
+
maxStdinPendingBytes: number;
|
|
19
|
+
maxStdinPendingWrites: 4;
|
|
20
|
+
stdinWriteTimeoutMs: 10000;
|
|
21
|
+
maxEntryBytes: number;
|
|
22
|
+
maxEntryHandlesPerGuest: 64;
|
|
23
|
+
}>;
|
|
24
|
+
export type ProcessEvent = "process.output" | "process.exit";
|
|
25
|
+
export interface PanelProcessEvent {
|
|
26
|
+
event: ProcessEvent;
|
|
27
|
+
sequence: number;
|
|
28
|
+
payload: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
export interface ProcessRecord {
|
|
31
|
+
owner: Pick<PanelProcessOwner, "guestId" | "appId" | "revision">;
|
|
32
|
+
processId: string;
|
|
33
|
+
status: "running" | "stopping" | "exited";
|
|
34
|
+
startedAt: number;
|
|
35
|
+
exitedAt?: number;
|
|
36
|
+
expiresAt?: number;
|
|
37
|
+
code?: number | null;
|
|
38
|
+
signal?: string | null;
|
|
39
|
+
cancelRequested: boolean;
|
|
40
|
+
sequence: number;
|
|
41
|
+
events: PanelProcessEvent[];
|
|
42
|
+
eventBytes: number;
|
|
43
|
+
}
|
|
44
|
+
export declare function sameProcessOwner(a: Pick<PanelProcessOwner, "guestId" | "appId" | "revision">, b: Pick<PanelProcessOwner, "guestId" | "appId" | "revision">): boolean;
|
|
45
|
+
/** Only terminal receipts are retained here; live process admission has its own limit. */
|
|
46
|
+
export declare class ProcessReceipts {
|
|
47
|
+
private readonly now;
|
|
48
|
+
private readonly records;
|
|
49
|
+
private timer?;
|
|
50
|
+
constructor(now?: () => number);
|
|
51
|
+
get(id: string): ProcessRecord | undefined;
|
|
52
|
+
add(record: ProcessRecord): void;
|
|
53
|
+
revokeGuest(guestId: number): void;
|
|
54
|
+
close(): void;
|
|
55
|
+
private prune;
|
|
56
|
+
private arm;
|
|
57
|
+
}
|
|
58
|
+
export declare function appendProcessEvent(record: ProcessRecord, event: ProcessEvent, payload: Record<string, unknown>): PanelProcessEvent;
|