@volter/tabnode 0.5.4 → 0.5.6

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.
@@ -40,7 +40,7 @@ import { errname } from './uv';
40
40
  * the same shape here rather than widened there.
41
41
  */
42
42
  function createNodeError(code: string, syscall: string, path: string): Error {
43
- const known = ['EEXIST', 'EINVAL', 'EISDIR', 'ENOENT', 'ENOTDIR', 'ENOTEMPTY', 'ELOOP'];
43
+ const known = ['EEXIST', 'EINVAL', 'EISDIR', 'ENOENT', 'ENOTDIR', 'ENOTEMPTY', 'ELOOP', 'EROFS'];
44
44
  if (known.includes(code)) return vfsError(code as 'ENOENT', syscall, path);
45
45
  return Object.assign(new Error(`${code}: ${syscall} '${path}'`), { code, syscall, path, errno: -9 });
46
46
  }
@@ -2,6 +2,22 @@
2
2
  export interface ProcessIdentity {
3
3
  readonly pid: number;
4
4
  readonly ppid: number;
5
+ /** What the process was started as, where its starter said: `/proc/<pid>/cmdline`. */
6
+ readonly argv?: readonly string[];
7
+ /** The directory it was started in: `/proc/<pid>/cwd`. */
8
+ readonly cwd?: string;
9
+ /** When it started, in milliseconds since the epoch. */
10
+ readonly startedAt?: number;
11
+ }
12
+
13
+ /** The parts of an identity a starter may add, checked before they are kept. */
14
+ function describedIdentity(identity: ProcessIdentity): ProcessIdentity {
15
+ const { pid, ppid, argv, cwd, startedAt } = identity;
16
+ const described: { pid: number; ppid: number; argv?: readonly string[]; cwd?: string; startedAt?: number } = { pid, ppid };
17
+ if (Array.isArray(argv) && argv.length <= 4096 && argv.every(arg => typeof arg === 'string')) described.argv = Object.freeze([...argv]);
18
+ if (typeof cwd === 'string' && cwd.startsWith('/')) described.cwd = cwd;
19
+ if (typeof startedAt === 'number' && Number.isFinite(startedAt)) described.startedAt = startedAt;
20
+ return Object.freeze(described);
5
21
  }
6
22
 
7
23
  /** A run already admitted by the container owner into this realm's scope. */
@@ -38,7 +54,7 @@ export interface ProcessRegistryScope extends ProcessRegistry {
38
54
  * so identical local run tokens cannot overwrite or release another realm's
39
55
  * process. Allocation alone does not announce a live process.
40
56
  */
41
- export function createProcessRegistryOwner(): { createScope(): ProcessRegistryScope } {
57
+ export function createProcessRegistryOwner(): { createScope(): ProcessRegistryScope; table(): ProcessIdentity[] } {
42
58
  let nextPid = 1000 + Math.floor(Math.random() * 30000);
43
59
  const live = new Map<number, ProcessIdentity>();
44
60
  // Which realm's scope answers for each live pid, including after handoff.
@@ -104,7 +120,7 @@ export function createProcessRegistryOwner(): { createScope(): ProcessRegistrySc
104
120
  if (identity.ppid !== 0 && !Array.from(runs.values()).some(parent => parent.pid === identity.ppid)) {
105
121
  throw new Error('Parent process is not owned by this realm.');
106
122
  }
107
- const entry = Object.freeze({ pid: identity.pid, ppid: identity.ppid });
123
+ const entry = describedIdentity(identity);
108
124
  runs.set(token, entry);
109
125
  live.set(entry.pid, entry);
110
126
  receivers.set(entry.pid, receiver);
@@ -140,5 +156,9 @@ export function createProcessRegistryOwner(): { createScope(): ProcessRegistrySc
140
156
  scopes.set(scope, { allocated, runs, active });
141
157
  return scope;
142
158
  },
159
+ /** Every live process of the container, in pid order: what `/proc` lists. */
160
+ table() {
161
+ return [...live.values()].sort((a, b) => a.pid - b.pid);
162
+ },
143
163
  };
144
164
  }
@@ -17,7 +17,7 @@
17
17
 
18
18
  import type { Process } from './shims/process';
19
19
  import { AsyncLocalStorage } from './shims/async_hooks';
20
- import { createProcessRegistryOwner, type ProcessRegistry, type ProcessRegistryScope, type InitialProcessRegistration } from './process-registry';
20
+ import { createProcessRegistryOwner, type ProcessIdentity, type ProcessRegistry, type ProcessRegistryScope, type InitialProcessRegistration } from './process-registry';
21
21
 
22
22
  /** Whatever the embedding runtime uses to name one guest process. */
23
23
  export type ProcessToken = string;
@@ -113,6 +113,27 @@ export function __stopOwnedProcess(token: ProcessToken): void {
113
113
  runs.get(token)?.stopTimers();
114
114
  }
115
115
 
116
+ /**
117
+ * Another process's `kill(pid, signal)`, delivered to a named run's guest.
118
+ * On a machine the target's handlers run, or its default action ends it; the
119
+ * guest's own `process.kill(process.pid, signal)` is exactly that, so the
120
+ * signal is raised through it, as the run. False when the run is gone or the
121
+ * signal is not one Node names.
122
+ */
123
+ export function __signalOwnedProcess(token: ProcessToken, signal: string): boolean {
124
+ const run = runs.get(token);
125
+ if (!run) return false;
126
+ const guest = run.process as unknown as { pid: number; kill(pid: number, signal: string): unknown };
127
+ try { enterRun(token, () => guest.kill(guest.pid, signal)); }
128
+ catch (error) {
129
+ // A default action ends the process by `exit`, which a synchronous guest
130
+ // frame reports by throwing; that is the signal delivered.
131
+ if (error instanceof Error && error.message.startsWith("Process exited with code")) return true;
132
+ return false;
133
+ }
134
+ return true;
135
+ }
136
+
116
137
  /**
117
138
  * A process's own number.
118
139
  *
@@ -147,6 +168,12 @@ export function createProcessRegistryScope(): ProcessRegistryScope {
147
168
  return processRegistryOwner.createScope();
148
169
  }
149
170
 
171
+ /** Trusted container owner only: every live process of the container, from every realm. */
172
+ export function ownerProcessTable(): ProcessIdentity[] {
173
+ if (registryInstalled) throw new Error('This realm is a process registry client, not the container owner.');
174
+ return processRegistryOwner.table();
175
+ }
176
+
150
177
  /** Trusted container owner only; worker clients cannot expose their authority. */
151
178
  export function ownerProcessRegistryScope(): ProcessRegistryScope {
152
179
  if (registryInstalled) throw new Error('This realm is a process registry client, not the container owner.');
@@ -177,9 +204,9 @@ export function mintPid(): number {
177
204
  }
178
205
 
179
206
  /** Record the numbers a named run was started with, for the run to read back. */
180
- export function setRunPid(token: ProcessToken, pid: number, ppid: number): void {
207
+ export function setRunPid(token: ProcessToken, pid: number, ppid: number, started?: { argv?: readonly string[]; cwd?: string }): void {
181
208
  registryUsed = true;
182
- processRegistry.publish(token, { pid, ppid });
209
+ processRegistry.publish(token, { pid, ppid, ...started, startedAt: Date.now() });
183
210
  pidsOfRuns.set(token, { pid, ppid });
184
211
  }
185
212
 
package/src/runtime.ts CHANGED
@@ -343,23 +343,49 @@ function transformDynamicImportsRegex(code: string): string {
343
343
  * saw no import at all.
344
344
  */
345
345
  function __substrateRewriteDynamicImportsInScript(code: string): string {
346
- if (!/(?<![.$\w#])import\s*\(/.test(code)) return code;
346
+ if (!/(?<![.$\w#])(?:import|eval)\s*\(/.test(code)) return code;
347
347
  let ast;
348
348
  try {
349
349
  ast = acorn.parse(code, { ecmaVersion: "latest", sourceType: "script", allowReturnOutsideFunction: true, allowHashBang: true, allowAwaitOutsideFunction: true });
350
350
  } catch {
351
351
  return code;
352
352
  }
353
- const replacements: Array<[number, number]> = [];
353
+ const replacements: Array<[number, number, string]> = [];
354
354
  walkAst(ast, (node: any) => {
355
- if (node.type === "ImportExpression") replacements.push([node.start, node.start + 6]);
355
+ if (node.type === "ImportExpression") replacements.push([node.start, node.start + 6, "__dynamicImport"]);
356
+ collectDirectEval(node, replacements);
356
357
  });
357
358
  if (replacements.length === 0) return code;
358
- replacements.sort((a, b) => b[0] - a[0]);
359
+ return applyReplacements(code, replacements);
360
+ }
361
+
362
+ /**
363
+ * A direct `eval` runs source the module builds at run time, and Node runs an
364
+ * `import()` in it through the module's loader: Playwright loads an ES config
365
+ * with ``eval(`import(${url})`)``. The call stays a direct eval, so the
366
+ * source keeps the module's scope, `__dynamicImport` included; only the
367
+ * source it is given is rewritten the way the module's own was.
368
+ */
369
+ function collectDirectEval(node: any, replacements: Array<[number, number, string]>): void {
370
+ if (node.type !== "CallExpression" || node.optional || node.callee?.type !== "Identifier" || node.callee.name !== "eval") return;
371
+ const source = node.arguments?.[0];
372
+ if (!source || source.type === "SpreadElement") return;
373
+ replacements.push([source.start, source.start, "__substrateEvalSource("]);
374
+ replacements.push([source.end, source.end, ")"]);
375
+ }
376
+
377
+ /** Applies insertions and replacements from the end, so earlier offsets hold. */
378
+ function applyReplacements(code: string, replacements: Array<[number, number, string]>): string {
379
+ replacements.sort((a, b) => b[0] - a[0] || b[1] - a[1]);
359
380
  let out = code;
360
- for (const [start, end] of replacements) out = out.slice(0, start) + "__dynamicImport" + out.slice(end);
381
+ for (const [start, end, text] of replacements) out = out.slice(0, start) + text + out.slice(end);
361
382
  return out;
362
383
  }
384
+
385
+ Object.defineProperty(globalThis, "__substrateEvalSource", {
386
+ configurable: true,
387
+ value: (source: unknown): unknown => typeof source === "string" ? __substrateRewriteDynamicImportsInScript(source) : source,
388
+ });
363
389
  function transformEsmToCjs(code: string, filename: string): string {
364
390
  // Quick check: does the code have any ESM-like patterns?
365
391
  const maybeEsm = __substrateHasEsmSyntax(code);
@@ -401,6 +427,7 @@ function transformEsmToCjsAst(code: string, filename: string): string {
401
427
  // Replace just the 'import' keyword, preserving the (...) part
402
428
  deepReplacements.push([node.start, node.start + 6, '__dynamicImport']);
403
429
  }
430
+ collectDirectEval(node, deepReplacements);
404
431
  });
405
432
 
406
433
  // Check for actual import/export declarations
@@ -408,11 +435,7 @@ function transformEsmToCjsAst(code: string, filename: string): string {
408
435
  const hasExportDecl = ast.body.some((n: any) => n.type?.startsWith('Export'));
409
436
 
410
437
  // Apply deep replacements from end to start (preserves earlier positions)
411
- let transformed = code;
412
- deepReplacements.sort((a, b) => b[0] - a[0]);
413
- for (const [start, end, replacement] of deepReplacements) {
414
- transformed = transformed.slice(0, start) + replacement + transformed.slice(end);
415
- }
438
+ let transformed = applyReplacements(code, deepReplacements);
416
439
 
417
440
  // Transform import/export declarations (re-parses the modified code)
418
441
  if (hasImportDecl || hasExportDecl) {
@@ -199,7 +199,7 @@ const _runStreams = new Map<ProcessToken, RunStreams>();
199
199
  /** Submit before returning to a spawning parent; completion remains async. */
200
200
  async function runHostedNode(host: NodeProcessHost, launch: Omit<NodeProcessLaunch, 'identity' | 'inherited' | 'stdinStream'>): Promise<CommandOutcome> {
201
201
  const { token, streams } = launch;
202
- if (!runPid(token)) setRunPid(token, mintPid(), 0);
202
+ if (!runPid(token)) setRunPid(token, mintPid(), 0, { argv: ['node', ...launch.argv], cwd: launch.cwd });
203
203
  let input: ReturnType<typeof nodeProcessInput> | undefined;
204
204
  try {
205
205
  streams?.signal?.throwIfAborted();
@@ -578,7 +578,7 @@ export function initChildProcess(vfs: VirtualFS): void {
578
578
  // This run's numbers, recorded under its name: a child it spawns reads
579
579
  // them for its own `ppid`, and `process.kill(pid, 0)` asks this registry
580
580
  // whether a pid is a live process.
581
- if (runToken !== null) setRunPid(runToken, proc.pid, proc.ppid);
581
+ if (runToken !== null) setRunPid(runToken, proc.pid, proc.ppid, { argv: ['node', ...args], cwd: ctx.cwd });
582
582
  const releaseRun = runToken === null ? null : __recordRun(runToken, {
583
583
  process: proc,
584
584
  stdout: appendStdout,
@@ -1680,7 +1680,8 @@ function startChildRun(request: RunRequest): StartedRun {
1680
1680
  const pid = mintPid();
1681
1681
  // In a process realm the admitted guest is always the spawning parent.
1682
1682
  // Async bookkeeping for a routed child must not make that child its parent.
1683
- setRunPid(token, pid, runPid(nodeProcessRealmToken() ?? __currentProcessToken() ?? __lastLaunchedToken)?.pid ?? 0);
1683
+ setRunPid(token, pid, runPid(nodeProcessRealmToken() ?? __currentProcessToken() ?? __lastLaunchedToken)?.pid ?? 0,
1684
+ { argv: request.args.length ? request.args : [request.file], ...(request.cwd ? { cwd: request.cwd } : {}) });
1684
1685
  const controller = new AbortController();
1685
1686
  const pendingStdin: Array<Uint8Array | null> = [];
1686
1687
  // A host terminal consumes input incrementally. The old string-only
package/src/virtual-fs.ts CHANGED
@@ -44,6 +44,23 @@ export interface FSNode {
44
44
  * apart). Absent until something sets it, which is the write time.
45
45
  */
46
46
  atime?: number;
47
+ /**
48
+ * A tree mounted here: the paths below this node are the tree's, answered
49
+ * when they are read, and never this filesystem's to write. Linux's
50
+ * `/proc` is one.
51
+ */
52
+ mount?: MountedTree;
53
+ }
54
+
55
+ /**
56
+ * A read-only tree another party answers, mounted at a directory: `node`
57
+ * returns what is at a path relative to the mount point (`/` is the mount
58
+ * point itself), computed when it is asked, or undefined where nothing is.
59
+ * A directory's `children` names what a listing shows; a link's `target` is
60
+ * followed as any link is.
61
+ */
62
+ export interface MountedTree {
63
+ node(path: string): FSNode | undefined;
47
64
  }
48
65
 
49
66
  // Identity belongs to the node, so writes and renames preserve it while an
@@ -116,7 +133,7 @@ export interface NodeError extends Error {
116
133
  }
117
134
 
118
135
  export function createNodeError(
119
- code: 'ENOENT' | 'ENOTDIR' | 'EISDIR' | 'EEXIST' | 'ENOTEMPTY' | 'ELOOP' | 'EINVAL',
136
+ code: 'ENOENT' | 'ENOTDIR' | 'EISDIR' | 'EEXIST' | 'ENOTEMPTY' | 'ELOOP' | 'EINVAL' | 'EROFS',
120
137
  syscall: string,
121
138
  path: string,
122
139
  message?: string
@@ -127,6 +144,7 @@ export function createNodeError(
127
144
  EISDIR: -21,
128
145
  EEXIST: -17,
129
146
  ENOTEMPTY: -39,
147
+ EROFS: -30,
130
148
  };
131
149
 
132
150
  const messages: Record<string, string> = {
@@ -135,6 +153,7 @@ export function createNodeError(
135
153
  EISDIR: 'is a directory',
136
154
  EEXIST: 'file already exists',
137
155
  ENOTEMPTY: 'directory not empty',
156
+ EROFS: 'read-only file system',
138
157
  };
139
158
 
140
159
  const err = new Error(
@@ -216,6 +235,7 @@ export class VirtualFS {
216
235
  }
217
236
 
218
237
  private serializeNode(path: string, node: FSNode, files: VFSFileEntry[]): void {
238
+ if (node.mount) return; // a mounted tree is its answerer's, never the filesystem's contents
219
239
  if (node.type === 'file') {
220
240
  // Encode binary content as base64
221
241
  let content = '';
@@ -284,6 +304,7 @@ export class VirtualFS {
284
304
  */
285
305
  private writeFileSyncInternal(path: string, data: string | Uint8Array, emitEvent: boolean): void {
286
306
  const normalized = this.normalizePath(path);
307
+ this.assertWritable(normalized, 'open');
287
308
  const parentPath = this.getParentPath(normalized);
288
309
  const basename = this.getBasename(normalized);
289
310
 
@@ -377,11 +398,14 @@ export class VirtualFS {
377
398
  if (depth > 40) throw createNodeError('ELOOP', 'stat', path);
378
399
  const segments = this.getPathSegments(path);
379
400
  let current = this.root;
401
+ let mounted: { tree: MountedTree; at: number } | undefined;
380
402
  for (let index = 0; index < segments.length; index += 1) {
381
- if (current.type !== 'directory' || !current.children) {
403
+ if (current.type !== 'directory' || (!mounted && !current.children)) {
382
404
  return undefined;
383
405
  }
384
- const child = current.children.get(segments[index]);
406
+ const child = mounted
407
+ ? mounted.tree.node('/' + segments.slice(mounted.at + 1, index + 1).join('/'))
408
+ : current.children!.get(segments[index]);
385
409
  if (!child) {
386
410
  return undefined;
387
411
  }
@@ -391,16 +415,51 @@ export class VirtualFS {
391
415
  const target = this.__substrateLinkTarget(linkPath, child.target!);
392
416
  return this.__substrateNode(rest ? target + '/' + rest : target, follow, depth + 1);
393
417
  }
394
- current = child;
418
+ if (!mounted && child.mount) {
419
+ mounted = { tree: child.mount, at: index };
420
+ current = child.mount.node('/') ?? child;
421
+ } else {
422
+ current = child;
423
+ }
395
424
  }
396
425
  return current;
397
426
  }
427
+
428
+ /**
429
+ * Mount a read-only tree at a directory, as Linux mounts `/proc`: reads
430
+ * below it are the tree's answers, writes fail with `EROFS`, and a snapshot
431
+ * leaves it out. Answers the unmount.
432
+ */
433
+ mount(path: string, tree: MountedTree): () => void {
434
+ const normalized = this.normalizePath(path);
435
+ const basename = this.getBasename(normalized);
436
+ if (!basename) throw createNodeError('EINVAL', 'mount', path);
437
+ const parent = this.ensureDirectory(this.getParentPath(normalized));
438
+ const existing = parent.children!.get(basename);
439
+ if (existing && (existing.type !== 'directory' || existing.mount || existing.children?.size)) {
440
+ throw createNodeError('EEXIST', 'mount', path);
441
+ }
442
+ const node: FSNode = { type: 'directory', children: new Map(), mtime: Date.now(), mode: 0o555, mount: tree };
443
+ parent.children!.set(basename, node);
444
+ return () => { if (parent.children!.get(basename) === node) parent.children!.delete(basename); };
445
+ }
446
+
447
+ /** A write at or below a mount point is refused, as a read-only filesystem refuses it. */
448
+ private assertWritable(path: string, syscall: string): void {
449
+ let current: FSNode | undefined = this.root;
450
+ for (const segment of this.getPathSegments(path)) {
451
+ current = current?.children?.get(segment);
452
+ if (!current) return;
453
+ if (current.mount) throw createNodeError('EROFS', syscall, path);
454
+ }
455
+ }
398
456
  /** A link's target as an absolute path: absolute as written, or relative to the link's directory. */
399
457
  __substrateLinkTarget(linkPath: string, target: string): string {
400
458
  return this.normalizePath(target.startsWith('/') ? target : this.getParentPath(linkPath) + '/' + target);
401
459
  }
402
460
  symlinkSync(target: string, path: string): void {
403
461
  const normalized = this.normalizePath(path);
462
+ this.assertWritable(normalized, 'symlink');
404
463
  const parentPath = this.getParentPath(normalized);
405
464
  const basename = this.getBasename(normalized);
406
465
  const parent = this.getNode(parentPath);
@@ -472,12 +531,14 @@ export class VirtualFS {
472
531
  * Get stats for path
473
532
  */
474
533
  chmodSync(path: string, mode: number): void {
534
+ this.assertWritable(path, 'chmod');
475
535
  const node = this.getNode(path);
476
536
  if (!node) throw createNodeError('ENOENT', 'chmod', path);
477
537
  node.mode = mode & 0o7777;
478
538
  }
479
539
 
480
540
  utimesSync(path: string, atime: number | Date, mtime: number | Date): void {
541
+ this.assertWritable(path, 'utime');
481
542
  const node = this.getNode(path);
482
543
  if (!node) throw createNodeError('ENOENT', 'utimes', path);
483
544
  node.atime = atime instanceof Date ? atime.getTime() : atime * 1000;
@@ -600,6 +661,9 @@ export class VirtualFS {
600
661
  */
601
662
  mkdirSync(path: string, options?: { recursive?: boolean }): void {
602
663
  const normalized = this.normalizePath(path);
664
+ // `mkdir -p` of a directory that is there, a mount point among them, is done
665
+ if (options?.recursive && this.getNode(normalized)?.type === 'directory') return;
666
+ this.assertWritable(normalized, 'mkdir');
603
667
 
604
668
  if (options?.recursive) {
605
669
  this.ensureDirectory(normalized);
@@ -657,6 +721,7 @@ export class VirtualFS {
657
721
  */
658
722
  unlinkSync(path: string): void {
659
723
  const normalized = this.normalizePath(path);
724
+ this.assertWritable(normalized, 'unlink');
660
725
  const parentPath = this.getParentPath(normalized);
661
726
  const basename = this.getBasename(normalized);
662
727
 
@@ -689,6 +754,7 @@ export class VirtualFS {
689
754
  */
690
755
  rmdirSync(path: string): void {
691
756
  const normalized = this.normalizePath(path);
757
+ this.assertWritable(normalized, 'rmdir');
692
758
  const parentPath = this.getParentPath(normalized);
693
759
  const basename = this.getBasename(normalized);
694
760
 
@@ -727,6 +793,8 @@ export class VirtualFS {
727
793
  renameSync(oldPath: string, newPath: string): void {
728
794
  const normalizedOld = this.normalizePath(oldPath);
729
795
  const normalizedNew = this.normalizePath(newPath);
796
+ this.assertWritable(normalizedOld, 'rename');
797
+ this.assertWritable(normalizedNew, 'rename');
730
798
 
731
799
  const oldParentPath = this.getParentPath(normalizedOld);
732
800
  const oldBasename = this.getBasename(normalizedOld);
@@ -846,10 +914,14 @@ export class VirtualFS {
846
914
  for (let hops = 0; hops <= 40; hops += 1) {
847
915
  const segments = resolved.split('/').filter(Boolean);
848
916
  let current: FSNode | undefined = this.root;
917
+ let mounted: { tree: MountedTree; at: number } | undefined;
849
918
  let linked = false;
850
919
  for (let index = 0; index < segments.length; index += 1) {
851
- const child: FSNode | undefined = current && current.children ? current.children.get(segments[index]) : void 0;
920
+ const child: FSNode | undefined = mounted
921
+ ? mounted.tree.node('/' + segments.slice(mounted.at + 1, index + 1).join('/'))
922
+ : current && current.children ? current.children.get(segments[index]) : void 0;
852
923
  if (!child) throw createNodeError('ENOENT', 'realpath', path);
924
+ if (!mounted && child.mount) mounted = { tree: child.mount, at: index };
853
925
  if (child.type === 'symlink') {
854
926
  const linkPath = '/' + segments.slice(0, index + 1).join('/');
855
927
  const rest = segments.slice(index + 1).join('/');