@cjhyy/code-shell-server 0.9.10 → 0.9.12

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.
Files changed (33) hide show
  1. package/dist/index.panels.d.ts +8 -0
  2. package/dist/index.panels.js +8 -0
  3. package/dist/panels/bridge-contract.d.ts +72 -0
  4. package/dist/panels/bridge-contract.js +92 -0
  5. package/dist/panels/connections.d.ts +31 -0
  6. package/dist/panels/connections.js +118 -0
  7. package/dist/panels/process-entry.d.ts +2 -0
  8. package/dist/panels/process-entry.js +52 -0
  9. package/dist/panels/process-service.d.ts +61 -0
  10. package/dist/panels/process-service.js +367 -33
  11. package/dist/panels/process-state.d.ts +58 -0
  12. package/dist/panels/process-state.js +87 -0
  13. package/dist/panels/resources/directories.d.ts +12 -0
  14. package/dist/panels/resources/directories.js +89 -0
  15. package/dist/panels/resources/library.d.ts +63 -0
  16. package/dist/panels/resources/library.js +423 -0
  17. package/dist/panels/resources/service.d.ts +45 -0
  18. package/dist/panels/resources/service.js +317 -0
  19. package/dist/panels/resources/storage.d.ts +10 -0
  20. package/dist/panels/resources/storage.js +102 -0
  21. package/dist/panels/resources/types.d.ts +16 -0
  22. package/dist/panels/resources/types.js +1 -0
  23. package/dist/panels/resources/uploads.d.ts +64 -0
  24. package/dist/panels/resources/uploads.js +527 -0
  25. package/dist/panels/runtime.d.ts +1 -1
  26. package/dist/panels/runtime.js +137 -15
  27. package/dist/panels/tool-executor.d.ts +58 -0
  28. package/dist/panels/tool-executor.js +421 -0
  29. package/dist/panels/tool-jobs-storage.d.ts +16 -0
  30. package/dist/panels/tool-jobs-storage.js +169 -0
  31. package/dist/panels/tool-jobs.d.ts +103 -0
  32. package/dist/panels/tool-jobs.js +530 -0
  33. 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 = 256;
10
- const MAX_ARGUMENT_LENGTH = 8_192;
11
- const MAX_ARGUMENT_BYTES = 64 * 1024;
12
- const MAX_CONCURRENT_PROCESSES = 3;
13
- const MAX_FILE_ARGUMENTS = 8;
14
- const MAX_EVENT_CHARS = 16_384;
15
- const MAX_PROCESS_OUTPUT_BYTES = 4 * 1024 * 1024;
16
- const PROCESS_LIFETIME_MS = 24 * 60 * 60 * 1_000;
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, { guestId: owner.guestId, name, path });
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, { guestId: owner.guestId, path: resolved });
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.guestId !== owner.guestId) {
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.guestId !== owner.guestId) {
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.guestId !== owner.guestId) {
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.guestId !== owner.guestId) {
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.guestId !== owner.guestId) {
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
- owner.send("process.output", {
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
- owner.send("process.output", {
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
- owner.send("process.output", {
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
- this.processes.set(processId, {
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
- clearTimeout(lifetime);
411
- if (this.processes.get(processId)?.child !== child)
412
- return;
413
- this.processes.delete(processId);
414
- owner.send("process.exit", { processId, code, signal });
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.guestId !== owner.guestId)
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
- void killProcessGroup(pid);
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;