@nimbus-sh/core 0.4.0 → 0.5.0

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 (121) hide show
  1. package/README.md +35 -7
  2. package/dist/_shared/error-text.d.ts +15 -0
  3. package/dist/_shared/error-text.d.ts.map +1 -0
  4. package/dist/_shared/error-text.js +21 -0
  5. package/dist/_shared/exports-resolver.d.ts +19 -7
  6. package/dist/_shared/exports-resolver.d.ts.map +1 -1
  7. package/dist/_shared/exports-resolver.js +1 -1
  8. package/dist/_shared/retry.d.ts.map +1 -1
  9. package/dist/_shared/retry.js +3 -1
  10. package/dist/_shared/w7-frame.js +2 -1
  11. package/dist/constants.d.ts.map +1 -1
  12. package/dist/constants.js +30 -16
  13. package/dist/observability/oom-classify.d.ts +1 -1
  14. package/dist/observability/oom-classify.d.ts.map +1 -1
  15. package/dist/observability/oom-classify.js +7 -0
  16. package/dist/observability/oom-discriminator.d.ts +1 -1
  17. package/dist/runtime/esbuild-service.d.ts +4 -3
  18. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  19. package/dist/runtime/esbuild-service.js +9 -4
  20. package/dist/runtime/installed-runtimes.d.ts +4 -3
  21. package/dist/runtime/installed-runtimes.d.ts.map +1 -1
  22. package/dist/runtime/long-running-handle.d.ts +3 -3
  23. package/dist/runtime/long-running-handle.d.ts.map +1 -1
  24. package/dist/runtime/os-contracts.d.ts +7 -5
  25. package/dist/runtime/os-contracts.d.ts.map +1 -1
  26. package/dist/runtime/port-registry.d.ts +28 -0
  27. package/dist/runtime/port-registry.d.ts.map +1 -1
  28. package/dist/runtime/port-registry.js +79 -9
  29. package/dist/runtime/process-logs.d.ts +9 -1
  30. package/dist/runtime/process-logs.d.ts.map +1 -1
  31. package/dist/runtime/project-detect.d.ts +6 -1
  32. package/dist/runtime/project-detect.d.ts.map +1 -1
  33. package/dist/runtime/require-resolver.d.ts.map +1 -1
  34. package/dist/runtime/require-resolver.js +3 -4
  35. package/dist/runtime/runtime-registry.d.ts +4 -3
  36. package/dist/runtime/runtime-registry.d.ts.map +1 -1
  37. package/dist/runtime/runtime-registry.js +2 -1
  38. package/dist/runtime/sqlite-runtime-fs-bridge.js +1 -5
  39. package/dist/runtime/virtual-socket-kernel.d.ts.map +1 -1
  40. package/dist/runtime/virtual-socket-kernel.generated.d.ts +1 -1
  41. package/dist/runtime/virtual-socket-kernel.generated.d.ts.map +1 -1
  42. package/dist/runtime/virtual-socket-kernel.generated.js +2 -2
  43. package/dist/runtime/virtual-socket-kernel.js +10 -5
  44. package/dist/runtime/wasm-runner.d.ts.map +1 -1
  45. package/dist/runtime/wasm-runner.js +22 -14
  46. package/dist/shell/features.d.ts.map +1 -1
  47. package/dist/shell/features.js +5 -4
  48. package/dist/shell/unix-commands.d.ts +14 -1
  49. package/dist/shell/unix-commands.d.ts.map +1 -1
  50. package/dist/shell/unix-commands.js +115 -69
  51. package/dist/substrate/lifo/commands/net/ip.d.ts.map +1 -1
  52. package/dist/substrate/lifo/commands/system/ps.d.ts +2 -1
  53. package/dist/substrate/lifo/commands/system/ps.d.ts.map +1 -1
  54. package/dist/substrate/lifo/kernel/ServiceManager.d.ts.map +1 -1
  55. package/dist/substrate/lifo/kernel/ServiceManager.js +0 -2
  56. package/dist/substrate/lifo/kernel/network/NetworkStack.d.ts +22 -11
  57. package/dist/substrate/lifo/kernel/network/NetworkStack.d.ts.map +1 -1
  58. package/dist/substrate/lifo/kernel/network/NetworkStack.js +0 -2
  59. package/dist/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.d.ts +2 -1
  60. package/dist/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.d.ts.map +1 -1
  61. package/dist/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.js +3 -4
  62. package/dist/substrate/lifo/kernel/vfs/VFS.d.ts +17 -0
  63. package/dist/substrate/lifo/kernel/vfs/VFS.d.ts.map +1 -1
  64. package/dist/substrate/lifo/kernel/vfs/VFS.js +33 -0
  65. package/dist/substrate/lifo/node-compat/esbuild.d.ts.map +1 -1
  66. package/dist/substrate/lifo/node-compat/esbuild.js +2 -4
  67. package/dist/substrate/lifo/node-compat/index.d.ts.map +1 -1
  68. package/dist/substrate/lifo/node-compat/index.js +3 -2
  69. package/dist/substrate/lifo/node-compat/loose-equality.d.ts +19 -0
  70. package/dist/substrate/lifo/node-compat/loose-equality.d.ts.map +1 -0
  71. package/dist/substrate/lifo/node-compat/loose-equality.js +91 -0
  72. package/dist/substrate/lifo/node-compat/process.d.ts.map +1 -1
  73. package/dist/substrate/lifo/node-compat/process.js +6 -3
  74. package/dist/substrate/lifo/shell/Shell.d.ts +19 -0
  75. package/dist/substrate/lifo/shell/Shell.d.ts.map +1 -1
  76. package/dist/substrate/lifo/shell/Shell.js +86 -75
  77. package/dist/substrate/lifo/shell/lexer.d.ts +9 -0
  78. package/dist/substrate/lifo/shell/lexer.d.ts.map +1 -1
  79. package/dist/substrate/lifo/shell/lexer.js +40 -0
  80. package/dist/vfs/seed-project.d.ts.map +1 -1
  81. package/dist/vfs/seed-project.js +8 -2
  82. package/dist/vfs/sqlite-vfs.d.ts +61 -1
  83. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  84. package/dist/vfs/sqlite-vfs.js +199 -35
  85. package/package.json +1 -1
  86. package/src/_shared/error-text.ts +21 -0
  87. package/src/_shared/exports-resolver.ts +24 -4
  88. package/src/_shared/retry.ts +4 -2
  89. package/src/_shared/w7-frame.ts +2 -1
  90. package/src/constants.ts +30 -16
  91. package/src/observability/oom-classify.ts +7 -0
  92. package/src/observability/oom-discriminator.ts +1 -1
  93. package/src/runtime/esbuild-service.ts +16 -11
  94. package/src/runtime/installed-runtimes.ts +4 -3
  95. package/src/runtime/long-running-handle.ts +3 -3
  96. package/src/runtime/os-contracts.ts +7 -5
  97. package/src/runtime/port-registry.ts +97 -9
  98. package/src/runtime/process-logs.ts +12 -3
  99. package/src/runtime/project-detect.ts +7 -1
  100. package/src/runtime/require-resolver.ts +7 -7
  101. package/src/runtime/runtime-registry.ts +9 -7
  102. package/src/runtime/sqlite-runtime-fs-bridge.ts +9 -6
  103. package/src/runtime/virtual-socket-kernel.generated.ts +2 -2
  104. package/src/runtime/virtual-socket-kernel.ts +10 -5
  105. package/src/runtime/wasm-runner.ts +55 -29
  106. package/src/shell/features.ts +5 -4
  107. package/src/shell/unix-commands.ts +273 -128
  108. package/src/substrate/lifo/commands/net/ip.ts +15 -15
  109. package/src/substrate/lifo/commands/system/ps.ts +2 -1
  110. package/src/substrate/lifo/kernel/ServiceManager.ts +0 -2
  111. package/src/substrate/lifo/kernel/network/NetworkStack.ts +24 -17
  112. package/src/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.ts +37 -12
  113. package/src/substrate/lifo/kernel/vfs/VFS.ts +34 -0
  114. package/src/substrate/lifo/node-compat/esbuild.ts +16 -8
  115. package/src/substrate/lifo/node-compat/index.ts +3 -2
  116. package/src/substrate/lifo/node-compat/loose-equality.ts +81 -0
  117. package/src/substrate/lifo/node-compat/process.ts +5 -2
  118. package/src/substrate/lifo/shell/Shell.ts +92 -70
  119. package/src/substrate/lifo/shell/lexer.ts +38 -0
  120. package/src/vfs/seed-project.ts +10 -3
  121. package/src/vfs/sqlite-vfs.ts +209 -36
@@ -83,6 +83,26 @@ const CONTENT_ID_ALLOCATION_ATTEMPTS = 8;
83
83
  // generateGitNetworkFacetCode (git-network-facet.ts) and the parallel
84
84
  // preamble (parallel/generated-workers.ts).
85
85
 
86
+ /**
87
+ * The Node `process` global as far as this file probes it. workerd provides
88
+ * no `process`, so every member stays optional and every read stays guarded.
89
+ */
90
+ interface NodeProcessLike {
91
+ env?: Record<string, string | undefined>;
92
+ memoryUsage?: () => { heapUsed: number };
93
+ }
94
+
95
+ /**
96
+ * Live view of the global object. `process` is not in the Workers lib, so its
97
+ * shape is declared here rather than assumed present.
98
+ */
99
+ const nodeHost = globalThis as { process?: NodeProcessLike };
100
+
101
+ /** Single gate for the W2.5b install-pipeline diagnostics below. */
102
+ function installPipelineDiagEnabled(): boolean {
103
+ return nodeHost.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1';
104
+ }
105
+
86
106
  // ── Types ───────────────────────────────────────────────────────────────────
87
107
 
88
108
  export type VfsInodeKind = 'file' | 'directory' | 'symlink';
@@ -254,6 +274,8 @@ const CHUNK_ROWS_PER_SQL_EXEC = 33;
254
274
  const CONTENT_IDS_PER_SQL_EXEC = 50;
255
275
  const TRANSACTION_DURATION_SAMPLE_COUNT = 128;
256
276
  const CONTENT_SCHEMA_MIGRATION = 'content_generations_v1';
277
+ /** Storage key of the shared scratch tree. `normalizeVfsPath` drops the slash. */
278
+ const TMP_ROOT = 'tmp';
257
279
  export const VFS_APPEND_RECEIPT_LIMIT = 2048;
258
280
  const INODE_KIND_FILE = 0;
259
281
  const INODE_KIND_DIRECTORY = 1;
@@ -679,6 +701,9 @@ export class SqliteVFS {
679
701
  private readonly activeStagingContentIds = new Set<string>();
680
702
  /** True only while durable GC work or a known abandoned staging row exists. */
681
703
  private maintenancePending = false;
704
+ // Keyset cursor for the orphan scan: each maintenance call reads one bounded
705
+ // page of distinct content ids past this bound, wrapping to '' at the end.
706
+ private orphanScanCursor = '';
682
707
 
683
708
  // Stage 2 transaction/phase telemetry. Scalar writes stay cheap; the
684
709
  // percentile is computed from the fixed ring only when diagnostics read it.
@@ -1317,7 +1342,7 @@ export class SqliteVFS {
1317
1342
  private blobToUint8Array(blob: unknown): Uint8Array {
1318
1343
  if (blob instanceof Uint8Array) return blob;
1319
1344
  if (blob instanceof ArrayBuffer) return new Uint8Array(blob);
1320
- if (ArrayBuffer.isView(blob)) return new Uint8Array((blob as any).buffer, (blob as any).byteOffset, (blob as any).byteLength);
1345
+ if (ArrayBuffer.isView(blob)) return new Uint8Array(blob.buffer, blob.byteOffset, blob.byteLength);
1321
1346
  return new Uint8Array(0);
1322
1347
  }
1323
1348
 
@@ -1338,6 +1363,82 @@ export class SqliteVFS {
1338
1363
  return this.blobToUint8Array(rows[0].data);
1339
1364
  }
1340
1365
 
1366
+ /**
1367
+ * Principals whose `/tmp` is private, keyed by uid, valued by the storage
1368
+ * root their `/tmp` resolves to.
1369
+ *
1370
+ * `/tmp` keeps its path in every view and only the bytes behind it differ,
1371
+ * so nothing has to be told which principal it is. The credential is the
1372
+ * only per-process state visible where paths are RESOLVED — there is no pid
1373
+ * and no cwd down here — so it is what the private view is keyed on.
1374
+ *
1375
+ * Resolution rather than a mount, because a mount diverges the two planes:
1376
+ * the shell writing `/tmp/a` and the file API writing `/tmp/b` landed in
1377
+ * different trees under the same name. `resolvePath` already takes `cred`,
1378
+ * and every plane goes through it.
1379
+ *
1380
+ * Registration is also what makes a principal CONFINED for `chmod`. The two
1381
+ * properties travel together because they answer one question: is this
1382
+ * principal a guest in this filesystem. Nothing here applies to an
1383
+ * unregistered credential, so the ordinary session user is untouched.
1384
+ */
1385
+ private confinedTmpRoots = new Map<number, string>();
1386
+
1387
+ /**
1388
+ * Confine a principal. `tmpRoot` is a storage key, not a logical path — the
1389
+ * caller owns creating and chowning it, because a per-principal `chown` is
1390
+ * uid-0 only and a guest cannot provision its own.
1391
+ */
1392
+ confinePrincipal(uid: number, tmpRoot: string): void {
1393
+ const root = normalizeVfsPath(tmpRoot);
1394
+ if (root === '') throw vfsError('EINVAL', 'a private /tmp root cannot be the filesystem root');
1395
+ this.confinedTmpRoots.set(uid, root);
1396
+ }
1397
+
1398
+ /** Drop a confinement. A principal's `/tmp` dies with it; its home does not. */
1399
+ releasePrincipal(uid: number): void {
1400
+ this.confinedTmpRoots.delete(uid);
1401
+ }
1402
+
1403
+ /**
1404
+ * Logical path -> storage key, for one credential.
1405
+ *
1406
+ * Everything under `/tmp` belongs to the caller's own private root, which is
1407
+ * what makes the same path mean different bytes per principal. Idempotent: a
1408
+ * key already inside that root is returned untouched, so the several methods
1409
+ * that derive a key before handing it on cannot stack the rewrite.
1410
+ */
1411
+ private storageKey(path: string, cred: VfsCred): string {
1412
+ const key = normalizeVfsPath(path);
1413
+ const root = this.confinedTmpRoots.get(cred.uid);
1414
+ if (root === undefined) return key;
1415
+ if (key === root || key.startsWith(`${root}/`)) return key;
1416
+ if (key === TMP_ROOT) return root;
1417
+ if (!key.startsWith(`${TMP_ROOT}/`)) return key;
1418
+ return `${root}/${key.slice(TMP_ROOT.length + 1)}`;
1419
+ }
1420
+
1421
+ /**
1422
+ * Storage key -> the name this credential knows it by, or `null` when it has
1423
+ * none. The inverse of {@link storageKey}, for the one surface that reports
1424
+ * paths it was not asked about: {@link list}.
1425
+ *
1426
+ * A confined caller has no name for the shared scratch tree — `/tmp` is its
1427
+ * own root — nor for another principal's, so both answer `null` and are
1428
+ * omitted. An unconfined caller sees storage as it is, which is what the
1429
+ * kernel and the session user need.
1430
+ */
1431
+ private logicalPath(key: string, cred: VfsCred): string | null {
1432
+ const root = this.confinedTmpRoots.get(cred.uid);
1433
+ if (root === undefined) return key;
1434
+ if (key === root) return TMP_ROOT;
1435
+ if (key.startsWith(`${root}/`)) return `${TMP_ROOT}/${key.slice(root.length + 1)}`;
1436
+ // The SHARED scratch root has no name here — this caller's `/tmp` is its
1437
+ // own root, which already supplied that entry. Returning it too would
1438
+ // enumerate one name twice, for two different directories.
1439
+ return key === TMP_ROOT || key.startsWith(`${TMP_ROOT}/`) ? null : key;
1440
+ }
1441
+
1341
1442
  // ── Filesystem operations ─────────────────────────────────────────────
1342
1443
 
1343
1444
  as(cred: VfsCred): CredentialedVfs {
@@ -1399,7 +1500,7 @@ export class SqliteVFS {
1399
1500
  writeBatch: (payload) => this.writeBatch(payload, bound),
1400
1501
  writeStream: (stream, options) => this.writeStream(stream, options, bound),
1401
1502
  mkdirBatch: (paths) => this.mkdirBatch(paths, bound),
1402
- revision: (path) => this.revision(path),
1503
+ revision: (path) => this.revision(path, bound),
1403
1504
  epoch: this._epoch,
1404
1505
  };
1405
1506
  }
@@ -1430,7 +1531,7 @@ export class SqliteVFS {
1430
1531
  followLeaf: boolean,
1431
1532
  allowMissing: boolean,
1432
1533
  ): { path: string; inode: INode | undefined } {
1433
- let current = normalizeVfsPath(path);
1534
+ let current = this.storageKey(path, cred);
1434
1535
  const seen = new Set<string>();
1435
1536
  for (let hops = 0; hops <= 40; hops++) {
1436
1537
  const parts = current.split('/').filter(Boolean);
@@ -1449,8 +1550,13 @@ export class SqliteVFS {
1449
1550
  seen.add(prefix);
1450
1551
  const target = dec.decode(this.readInodeBytes(prefix, inode));
1451
1552
  const suffix = parts.slice(index + 1).join('/');
1553
+ // An ABSOLUTE target is a logical path the same way the caller's
1554
+ // was, so it goes through the same rewrite: otherwise a symlink to
1555
+ // /tmp/y stored inside a private tree would read the shared one,
1556
+ // which is the one way out of the confinement. A RELATIVE target
1557
+ // resolves against a key that is already private, so it stays there.
1452
1558
  const resolvedTarget = target.startsWith('/')
1453
- ? normalizeVfsPath(target)
1559
+ ? this.storageKey(target, cred)
1454
1560
  : normalizeVfsPath(`${this.parentPath(prefix)}/${target}`);
1455
1561
  current = suffix ? normalizeVfsPath(`${resolvedTarget}/${suffix}`) : resolvedTarget;
1456
1562
  restarted = true;
@@ -1496,6 +1602,27 @@ export class SqliteVFS {
1496
1602
  if (resolved.inode?.kind !== 'directory') throw vfsError('ENOTDIR', parent);
1497
1603
  }
1498
1604
 
1605
+ /**
1606
+ * POSIX sticky-bit restriction on a shared directory.
1607
+ *
1608
+ * Write permission on a directory is normally enough to remove or rename
1609
+ * anything inside it, which is why `/tmp` is `1777` and not `0777`: the
1610
+ * sticky bit narrows that to the entry's owner, the directory's owner, and
1611
+ * root. Nothing enforced it here, so a world-writable shared directory gave
1612
+ * every principal the ability to delete every other principal's files —
1613
+ * the mode said one thing and the filesystem did another.
1614
+ */
1615
+ private checkStickyParentMutation(path: string, inode: INode, cred: VfsCred): void {
1616
+ if (cred.uid === 0) return;
1617
+ const parent = this.parentPath(normalizeVfsPath(path));
1618
+ if (parent === '') return;
1619
+ const parentInode = this.inodes.get(parent);
1620
+ if (!parentInode || (parentInode.mode & 0o1000) === 0) return;
1621
+ if (cred.uid !== parentInode.uid && cred.uid !== inode.uid) {
1622
+ throw vfsError('EPERM', normalizeVfsPath(path));
1623
+ }
1624
+ }
1625
+
1499
1626
  /**
1500
1627
  * Shared resolver for the boolean probes (exists/isDirectory/isFile/
1501
1628
  * isSymlink). Resolution-structure failures — a missing or non-directory
@@ -1536,9 +1663,11 @@ export class SqliteVFS {
1536
1663
  * under it changed in this DO lifetime). `revision('')` equals the
1537
1664
  * global clock by construction (every mutation stamps all ancestors).
1538
1665
  */
1539
- revision(path?: string): number {
1666
+ revision(path?: string, cred?: VfsCred): number {
1540
1667
  if (path === undefined) return this._revision;
1541
- const p = normalizeVfsPath(path);
1668
+ // A credentialed caller asks about its own view; a confined one asking
1669
+ // after /tmp/x means its own file, so the counter must be that file's.
1670
+ const p = cred === undefined ? normalizeVfsPath(path) : this.storageKey(path, cred);
1542
1671
  if (p === '') return this._revision;
1543
1672
  return this._pathRevisions.get(p) ?? 0;
1544
1673
  }
@@ -1726,7 +1855,7 @@ export class SqliteVFS {
1726
1855
  }
1727
1856
 
1728
1857
  private mkdir(path: string, options: { recursive?: boolean; mode?: number } | undefined, cred: VfsCred): void {
1729
- const normalized = normalizeVfsPath(path);
1858
+ const normalized = this.storageKey(path, cred);
1730
1859
  this.assertMutationsAllowed([normalized]);
1731
1860
  if (this.exists(normalized, cred)) return;
1732
1861
 
@@ -1832,7 +1961,7 @@ export class SqliteVFS {
1832
1961
 
1833
1962
  private symlink(target: string, path: string, cred: VfsCred): void {
1834
1963
  this.assertMutationsAllowed([path]);
1835
- const normalized = normalizeVfsPath(path);
1964
+ const normalized = this.storageKey(path, cred);
1836
1965
  const prior = this.checkAccess(normalized, 0, cred, { followLeaf: false, allowMissingLeaf: true });
1837
1966
  if (prior.inode) throw vfsError('EEXIST', normalized);
1838
1967
  this.checkParentAccess(normalized, cred);
@@ -2816,6 +2945,25 @@ export class SqliteVFS {
2816
2945
  const inode = resolved.inode;
2817
2946
  if (!inode) throw vfsError('ENOENT', path);
2818
2947
  if (cred.uid !== 0 && cred.uid !== inode.uid) throw vfsError('EPERM', resolved.path);
2948
+ // A confined principal owns its own triad and nothing else. Refusing chmod
2949
+ // outright would be simpler and wrong: execution is gated on the x bit, so
2950
+ // a guest that writes build.sh and cannot chmod it cannot run it. What
2951
+ // must not happen is WIDENING past its own principal, so the group, other
2952
+ // and setuid/setgid/sticky bits keep the value they were provisioned with
2953
+ // while the owner triad moves freely. `u+x` works; `+x` and `777` do not.
2954
+ //
2955
+ // Refused, never clamped. Quietly narrowing a mutation to the part that
2956
+ // was allowed reports success for something other than what was asked, so
2957
+ // the refusal names the spelling that works instead.
2958
+ if (cred.uid !== 0 && this.confinedTmpRoots.has(cred.uid)) {
2959
+ const beyondOwner = (mode & 0o7777) & ~0o700;
2960
+ if (beyondOwner !== ((inode.mode & 0o7777) & ~0o700)) {
2961
+ throw vfsError(
2962
+ 'EPERM',
2963
+ `${resolved.path}: mode change would grant permission outside your own principal; use u+x`,
2964
+ );
2965
+ }
2966
+ }
2819
2967
  this.assertMutationsAllowed([inode.path]);
2820
2968
  const full = inodeTypeBits(inode.kind) | (mode & 0o7777);
2821
2969
  inode.mode = full;
@@ -2886,7 +3034,7 @@ export class SqliteVFS {
2886
3034
  // Before the walk, deliberately — see VfsListPage.
2887
3035
  const epoch = this._epoch;
2888
3036
  const rev = this._revision;
2889
- const from = after === null || after === undefined ? '' : normalizeVfsPath(after);
3037
+ const from = after === null || after === undefined ? '' : this.storageKey(after, cred);
2890
3038
  // this.inodes is insertion-ordered, not path-ordered, so the sort is what
2891
3039
  // makes `after` a resume key at all. Paid once per page against a map the
2892
3040
  // DO already holds whole.
@@ -2908,8 +3056,15 @@ export class SqliteVFS {
2908
3056
  } catch {
2909
3057
  continue;
2910
3058
  }
3059
+ // Reported in the caller's OWN path space. Enumerating raw storage keys
3060
+ // would name a private root the caller cannot address and does not know
3061
+ // it has, and a caller feeding such a path back would be asking about
3062
+ // someone else's tree. `null` means the path has no name for this
3063
+ // caller, which is the same OMIT the access check above performs.
3064
+ const logical = this.logicalPath(path, cred);
3065
+ if (logical === null) continue;
2911
3066
  entries.push({
2912
- path,
3067
+ path: logical,
2913
3068
  kind: inode.kind,
2914
3069
  size: inode.size,
2915
3070
  rev: this._pathRevisions.get(path) ?? 0,
@@ -2919,7 +3074,7 @@ export class SqliteVFS {
2919
3074
  }
2920
3075
 
2921
3076
  private readdir(path: string, cred: VfsCred): { name: string; type: VfsInodeKind }[] {
2922
- const np = path.replace(/^\/+/, '').replace(/\/+$/, '');
3077
+ const np = this.storageKey(path, cred);
2923
3078
  const resolved = np ? this.checkAccess(np, 0o4, cred) : { path: '', inode: undefined };
2924
3079
  const inode = resolved.inode;
2925
3080
  if (inode && inode.kind !== 'directory') throw vfsError('ENOTDIR', path);
@@ -2927,7 +3082,7 @@ export class SqliteVFS {
2927
3082
  if (!kids) {
2928
3083
  // W2.5b diagnostic: empty children-set for a directory we expected
2929
3084
  // to be populated.
2930
- if ((globalThis as any).process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1') {
3085
+ if (installPipelineDiagEnabled()) {
2931
3086
  // eslint-disable-next-line no-console
2932
3087
  console.warn(
2933
3088
  '[sqlite-vfs/W2.5b] readdir miss path=' + np +
@@ -2949,7 +3104,7 @@ export class SqliteVFS {
2949
3104
  // This distinguishes (a) "children index broken" from (b) "inodes
2950
3105
  // map lost entries".
2951
3106
  if (
2952
- (globalThis as any).process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1' &&
3107
+ installPipelineDiagEnabled() &&
2953
3108
  kids.size !== results.length
2954
3109
  ) {
2955
3110
  // eslint-disable-next-line no-console
@@ -2977,22 +3132,24 @@ export class SqliteVFS {
2977
3132
  const inode = resolved.inode;
2978
3133
  if (!inode) throw vfsError('ENOENT', path);
2979
3134
  this.checkParentAccess(resolved.path, cred);
3135
+ this.checkStickyParentMutation(resolved.path, inode, cred);
2980
3136
  if (inode.isDir) throw vfsError('EISDIR', resolved.path);
2981
3137
  this.writeBatch({ inodes: [], chunks: [], deletePaths: [resolved.path] }, cred);
2982
3138
  }
2983
3139
 
2984
3140
  private rmdir(path: string, cred: VfsCred): void {
2985
3141
  this.assertMutationsAllowed([path]);
2986
- const np = path.replace(/^\/+/, '').replace(/\/+$/, '');
3142
+ const np = this.storageKey(path, cred);
2987
3143
  const resolved = this.checkAccess(np, 0, cred, { followLeaf: false });
2988
3144
  this.checkParentAccess(resolved.path, cred);
3145
+ const inode = this.inodes.get(np);
3146
+ if (!inode) throw vfsError('ENOENT', path);
3147
+ this.checkStickyParentMutation(resolved.path, inode, cred);
2989
3148
  // Check if empty using children index (O(1) instead of O(N))
2990
3149
  const kids = this.children.get(np);
2991
3150
  if (kids && kids.size > 0) {
2992
3151
  throw vfsError('ENOTEMPTY', path);
2993
3152
  }
2994
- const inode = this.inodes.get(np);
2995
- if (!inode) throw vfsError('ENOENT', path);
2996
3153
  if (!inode.isDir) throw vfsError('ENOTDIR', path);
2997
3154
  this.writeBatch({ inodes: [], chunks: [], deletePaths: [np] }, cred);
2998
3155
  }
@@ -3018,6 +3175,7 @@ export class SqliteVFS {
3018
3175
  const resolved = this.checkAccess(path, 0, cred, { followLeaf: false });
3019
3176
  if (!resolved.inode) throw vfsError('ENOENT', normalizeVfsPath(path));
3020
3177
  this.checkParentAccess(resolved.path, cred);
3178
+ this.checkStickyParentMutation(resolved.path, resolved.inode, cred);
3021
3179
 
3022
3180
  const removable = this.collectSubtreeInodes([resolved.path]);
3023
3181
  // The directories the recursive walk used to enumerate are exactly the
@@ -3067,6 +3225,7 @@ export class SqliteVFS {
3067
3225
  }
3068
3226
  const inode = this.inodes.get(oldPath);
3069
3227
  if (!inode) throw new Error("ENOENT: " + oldPath);
3228
+ this.checkStickyParentMutation(oldPath, inode, cred);
3070
3229
 
3071
3230
  // W-3 (WASI filesystem WASI): if newPath already exists, unlink it first so the
3072
3231
  // SQL UPDATE doesn't conflict on inodes.path uniqueness. POSIX rename(2)
@@ -3077,6 +3236,7 @@ export class SqliteVFS {
3077
3236
  // (the latter is rare but POSIX permits it for empty dirs).
3078
3237
  const destInode = this.inodes.get(newPath);
3079
3238
  if (destInode) {
3239
+ this.checkStickyParentMutation(newPath, destInode, cred);
3080
3240
  if (destInode.isDir) {
3081
3241
  // POSIX semantics: rename onto a non-empty dir is an error
3082
3242
  // (ENOTEMPTY); rename onto an empty dir is allowed. We surface
@@ -3283,14 +3443,14 @@ export class SqliteVFS {
3283
3443
  // ── Batch write (npm install fast path) ───────────────────────────────
3284
3444
 
3285
3445
  private normalizeBatchInode(entry: BatchInodeEntry, cred: VfsCred): NormalizedBatchInodeEntry {
3286
- const path = normalizeVfsPath(entry.path);
3446
+ const path = this.storageKey(entry.path, cred);
3287
3447
  const prior = this.inodes.get(path);
3288
3448
  const newUid = cred.uid === 0 ? (entry.uid ?? 1000) : cred.uid;
3289
3449
  const newGid = cred.uid === 0 ? (entry.gid ?? 1000) : cred.gid;
3290
3450
  return {
3291
3451
  ...entry,
3292
3452
  path,
3293
- parentPath: normalizeVfsPath(entry.parentPath),
3453
+ parentPath: this.storageKey(entry.parentPath, cred),
3294
3454
  mode: prior
3295
3455
  ? prior.mode
3296
3456
  : inodeKind(entry) === 'symlink'
@@ -3324,7 +3484,7 @@ export class SqliteVFS {
3324
3484
  };
3325
3485
 
3326
3486
  for (const path of payload.deletePaths ?? []) {
3327
- const normalized = normalizeVfsPath(path);
3487
+ const normalized = this.storageKey(path, cred);
3328
3488
  const existing = this.checkAccess(normalized, 0, cred, {
3329
3489
  followLeaf: false,
3330
3490
  allowMissingLeaf: true,
@@ -3337,13 +3497,13 @@ export class SqliteVFS {
3337
3497
  else checkParent(entry.path);
3338
3498
  }
3339
3499
  for (const chunk of payload.chunks) {
3340
- const path = normalizeVfsPath(chunk.path);
3500
+ const path = this.storageKey(chunk.path, cred);
3341
3501
  if (!pending.has(path)) this.checkAccess(path, 0o2, cred, { followLeaf: false });
3342
3502
  }
3343
3503
  return {
3344
3504
  ...payload,
3345
3505
  inodes,
3346
- deletePaths: payload.deletePaths?.map(normalizeVfsPath),
3506
+ deletePaths: payload.deletePaths?.map((path) => this.storageKey(path, cred)),
3347
3507
  };
3348
3508
  }
3349
3509
 
@@ -4118,21 +4278,34 @@ export class SqliteVFS {
4118
4278
  let stagingScanComplete = true;
4119
4279
 
4120
4280
  if (transactions < maximum) {
4121
- const orphanRows = [...this.sql.exec(
4122
- `SELECT chunks.content_id
4123
- FROM file_chunks AS chunks
4124
- WHERE NOT EXISTS (
4125
- SELECT 1 FROM content_lifecycle AS lifecycle
4126
- WHERE lifecycle.content_id = chunks.content_id
4127
- )
4128
- AND ${sqlNoInodeContentReference('chunks.content_id')}
4129
- GROUP BY chunks.content_id
4130
- ORDER BY chunks.content_id
4131
- LIMIT ?`,
4281
+ // The page is selected before the orphan predicates so a scan that finds
4282
+ // nothing still reads only one page, not the whole covering index.
4283
+ const pageRows = [...this.sql.exec(
4284
+ `SELECT page.content_id AS content_id,
4285
+ (NOT EXISTS (
4286
+ SELECT 1 FROM content_lifecycle AS lifecycle
4287
+ WHERE lifecycle.content_id = page.content_id
4288
+ )
4289
+ AND ${sqlNoInodeContentReference('page.content_id')}) AS orphaned
4290
+ FROM (
4291
+ SELECT chunks.content_id
4292
+ FROM file_chunks AS chunks
4293
+ WHERE chunks.content_id > ?
4294
+ GROUP BY chunks.content_id
4295
+ ORDER BY chunks.content_id
4296
+ LIMIT ?
4297
+ ) AS page
4298
+ ORDER BY page.content_id`,
4299
+ this.orphanScanCursor,
4132
4300
  CONTENT_IDS_PER_SQL_EXEC,
4133
4301
  )];
4134
- orphanScanComplete = orphanRows.length < CONTENT_IDS_PER_SQL_EXEC;
4135
- const contentIds = orphanRows.map((row) => String(row.content_id));
4302
+ orphanScanComplete = pageRows.length < CONTENT_IDS_PER_SQL_EXEC;
4303
+ this.orphanScanCursor = orphanScanComplete
4304
+ ? ''
4305
+ : String(pageRows[pageRows.length - 1].content_id);
4306
+ const contentIds = pageRows
4307
+ .filter((row) => Number(row.orphaned) === 1)
4308
+ .map((row) => String(row.content_id));
4136
4309
  if (contentIds.length > 0) {
4137
4310
  const candidates = contentIds
4138
4311
  .map((_, index) => index === 0 ? 'SELECT ? AS content_id' : 'SELECT ?')
@@ -4341,7 +4514,7 @@ export class SqliteVFS {
4341
4514
  /** Best-effort process.memoryUsage().heapUsed; 0 in DO contexts. */
4342
4515
  private _safeHeapUsed(): number {
4343
4516
  try {
4344
- const mu = (globalThis as any).process?.memoryUsage?.();
4517
+ const mu = nodeHost.process?.memoryUsage?.();
4345
4518
  return Number(mu?.heapUsed) || 0;
4346
4519
  } catch {
4347
4520
  return 0;
@@ -4536,7 +4709,7 @@ export class SqliteVFS {
4536
4709
  // `_addToChildrenIndex` uses Set.add so repeated calls are idempotent;
4537
4710
  // gating it on `prior === undefined` was the bug. Counters remain
4538
4711
  // gated correctly so they don't double-count.
4539
- const __diag = ((globalThis as any).process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1');
4712
+ const __diag = installPipelineDiagEnabled();
4540
4713
  const replacedPaths = new Set<string>();
4541
4714
  for (const entry of plan.inodes) {
4542
4715
  const prior = this.inodes.get(entry.path);