@nimbus-sh/core 0.4.0 → 0.6.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 (156) hide show
  1. package/README.md +35 -7
  2. package/dist/_shared/cache-stats.d.ts +23 -1
  3. package/dist/_shared/cache-stats.d.ts.map +1 -1
  4. package/dist/_shared/cache-stats.js +13 -1
  5. package/dist/_shared/error-text.d.ts +15 -0
  6. package/dist/_shared/error-text.d.ts.map +1 -0
  7. package/dist/_shared/error-text.js +21 -0
  8. package/dist/_shared/exports-resolver.d.ts +19 -7
  9. package/dist/_shared/exports-resolver.d.ts.map +1 -1
  10. package/dist/_shared/exports-resolver.js +1 -1
  11. package/dist/_shared/retry.d.ts.map +1 -1
  12. package/dist/_shared/retry.js +4 -2
  13. package/dist/constants.d.ts +5 -11
  14. package/dist/constants.d.ts.map +1 -1
  15. package/dist/constants.js +23 -49
  16. package/dist/runtime/assets-loader.js +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 +2 -2
  41. package/dist/runtime/virtual-socket-kernel.generated.d.ts.map +1 -1
  42. package/dist/runtime/virtual-socket-kernel.generated.js +3 -3
  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 +9 -3
  82. package/dist/vfs/sqlite-vfs.d.ts +63 -31
  83. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  84. package/dist/vfs/sqlite-vfs.js +206 -41
  85. package/package.json +3 -2
  86. package/src/_shared/cache-stats.ts +27 -1
  87. package/src/_shared/error-text.ts +21 -0
  88. package/src/_shared/exports-resolver.ts +24 -4
  89. package/src/_shared/retry.ts +5 -3
  90. package/src/constants.ts +37 -53
  91. package/src/runtime/assets-loader.ts +1 -1
  92. package/src/runtime/esbuild-service.ts +16 -11
  93. package/src/runtime/installed-runtimes.ts +4 -3
  94. package/src/runtime/long-running-handle.ts +3 -3
  95. package/src/runtime/os-contracts.ts +7 -5
  96. package/src/runtime/port-registry.ts +97 -9
  97. package/src/runtime/process-logs.ts +12 -3
  98. package/src/runtime/project-detect.ts +7 -1
  99. package/src/runtime/require-resolver.ts +7 -7
  100. package/src/runtime/runtime-registry.ts +9 -7
  101. package/src/runtime/sqlite-runtime-fs-bridge.ts +9 -6
  102. package/src/runtime/virtual-socket-kernel.generated.ts +3 -3
  103. package/src/runtime/virtual-socket-kernel.ts +10 -5
  104. package/src/runtime/wasm-runner.ts +55 -29
  105. package/src/shell/features.ts +5 -4
  106. package/src/shell/unix-commands.ts +273 -128
  107. package/src/substrate/lifo/commands/net/ip.ts +15 -15
  108. package/src/substrate/lifo/commands/system/ps.ts +2 -1
  109. package/src/substrate/lifo/kernel/ServiceManager.ts +0 -2
  110. package/src/substrate/lifo/kernel/network/NetworkStack.ts +24 -17
  111. package/src/substrate/lifo/kernel/network/tunnel/WebSocketTunnel.ts +37 -12
  112. package/src/substrate/lifo/kernel/vfs/VFS.ts +34 -0
  113. package/src/substrate/lifo/node-compat/esbuild.ts +16 -8
  114. package/src/substrate/lifo/node-compat/index.ts +3 -2
  115. package/src/substrate/lifo/node-compat/loose-equality.ts +81 -0
  116. package/src/substrate/lifo/node-compat/process.ts +5 -2
  117. package/src/substrate/lifo/shell/Shell.ts +92 -70
  118. package/src/substrate/lifo/shell/lexer.ts +38 -0
  119. package/src/vfs/seed-project.ts +13 -6
  120. package/src/vfs/sqlite-vfs.ts +233 -77
  121. package/dist/_shared/install-phase.d.ts +0 -47
  122. package/dist/_shared/install-phase.d.ts.map +0 -1
  123. package/dist/_shared/install-phase.js +0 -1
  124. package/dist/_shared/rpc-dispose.d.ts +0 -4
  125. package/dist/_shared/rpc-dispose.d.ts.map +0 -1
  126. package/dist/_shared/rpc-dispose.js +0 -30
  127. package/dist/_shared/w7-frame.d.ts +0 -79
  128. package/dist/_shared/w7-frame.d.ts.map +0 -1
  129. package/dist/_shared/w7-frame.js +0 -839
  130. package/dist/_shared/weighted-credit-pool.d.ts +0 -61
  131. package/dist/_shared/weighted-credit-pool.d.ts.map +0 -1
  132. package/dist/_shared/weighted-credit-pool.js +0 -196
  133. package/dist/observability/diag-counters.d.ts +0 -236
  134. package/dist/observability/diag-counters.d.ts.map +0 -1
  135. package/dist/observability/diag-counters.js +0 -195
  136. package/dist/observability/heap-estimate.d.ts +0 -225
  137. package/dist/observability/heap-estimate.d.ts.map +0 -1
  138. package/dist/observability/heap-estimate.js +0 -185
  139. package/dist/observability/heavy-alloc-coord.d.ts +0 -93
  140. package/dist/observability/heavy-alloc-coord.d.ts.map +0 -1
  141. package/dist/observability/heavy-alloc-coord.js +0 -181
  142. package/dist/observability/oom-classify.d.ts +0 -138
  143. package/dist/observability/oom-classify.d.ts.map +0 -1
  144. package/dist/observability/oom-classify.js +0 -261
  145. package/dist/observability/oom-discriminator.d.ts +0 -176
  146. package/dist/observability/oom-discriminator.d.ts.map +0 -1
  147. package/dist/observability/oom-discriminator.js +0 -272
  148. package/src/_shared/install-phase.ts +0 -56
  149. package/src/_shared/rpc-dispose.ts +0 -31
  150. package/src/_shared/w7-frame.ts +0 -1012
  151. package/src/_shared/weighted-credit-pool.ts +0 -244
  152. package/src/observability/diag-counters.ts +0 -343
  153. package/src/observability/heap-estimate.ts +0 -331
  154. package/src/observability/heavy-alloc-coord.ts +0 -241
  155. package/src/observability/oom-classify.ts +0 -253
  156. package/src/observability/oom-discriminator.ts +0 -405
@@ -43,27 +43,33 @@
43
43
  import { VfsEventEmitter, type VfsEventType } from './events.js';
44
44
  import { normalizeVfsPath } from './path.js';
45
45
  import {
46
- CHUNK_SIZE,
47
46
  LRU_MAX_ENTRIES,
48
47
  BATCH_SIZE,
48
+ FS_LIST_PAGE_LIMIT,
49
+ } from '../constants.js';
50
+ import {
51
+ CHUNK_SIZE,
49
52
  MAX_TX_BLOB_BYTES,
50
53
  MAX_TX_LOGICAL_ROWS,
51
54
  MAX_TX_SQL_EXECS,
52
55
  MAX_GLOBAL_WRITE_STREAM_CREDIT_BYTES,
53
- FS_LIST_PAGE_LIMIT,
54
- } from '../constants.js';
55
- import { recordFailure } from '../observability/oom-discriminator.js';
56
- import { classifyError } from '../observability/oom-classify.js';
57
- import { acquireSupervisorAllocation } from '../observability/heavy-alloc-coord.js';
56
+ } from '@nimbus-sh/platform/limits.js';
57
+ import { recordFailure } from '@nimbus-sh/platform/oom-discriminator.js';
58
+ import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
59
+ import { acquireSupervisorAllocation } from '@nimbus-sh/platform/heavy-alloc-coord.js';
58
60
  import { enc, dec } from '../_shared/bytes.js';
59
61
  import {
60
62
  decodeWriteBatchStream,
63
+ type BatchChunkEntry,
64
+ type BatchInodeEntry,
65
+ type BatchWritePayload,
66
+ type VfsInodeKind,
61
67
  type W7DecodedRecord,
62
- } from '../_shared/w7-frame.js';
68
+ } from '@nimbus-sh/platform/w7-frame.js';
63
69
  import {
64
70
  WeightedCreditPool,
65
71
  type CreditLease,
66
- } from '../_shared/weighted-credit-pool.js';
72
+ } from '@nimbus-sh/platform/weighted-credit-pool.js';
67
73
  import { LEGACY_SYMLINK_REGISTRY_PATH } from './symlink-registry.js';
68
74
  import {
69
75
  CRED_KERNEL,
@@ -83,9 +89,37 @@ const CONTENT_ID_ALLOCATION_ATTEMPTS = 8;
83
89
  // generateGitNetworkFacetCode (git-network-facet.ts) and the parallel
84
90
  // preamble (parallel/generated-workers.ts).
85
91
 
86
- // ── Types ───────────────────────────────────────────────────────────────────
92
+ /**
93
+ * The Node `process` global as far as this file probes it. workerd provides
94
+ * no `process`, so every member stays optional and every read stays guarded.
95
+ */
96
+ interface NodeProcessLike {
97
+ env?: Record<string, string | undefined>;
98
+ memoryUsage?: () => { heapUsed: number };
99
+ }
100
+
101
+ /**
102
+ * Live view of the global object. `process` is not in the Workers lib, so its
103
+ * shape is declared here rather than assumed present.
104
+ */
105
+ const nodeHost = globalThis as { process?: NodeProcessLike };
106
+
107
+ /** Single gate for the W2.5b install-pipeline diagnostics below. */
108
+ function installPipelineDiagEnabled(): boolean {
109
+ return nodeHost.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1';
110
+ }
87
111
 
88
- export type VfsInodeKind = 'file' | 'directory' | 'symlink';
112
+ // ── Types ───────────────────────────────────────────────────────────────────
113
+ // VfsInodeKind and the writeBatch payload types (BatchInodeEntry,
114
+ // BatchChunkEntry, BatchWritePayload) live with the wire format that encodes
115
+ // them: @nimbus-sh/platform/w7-frame.js. Forwarded here because core@0.5.0
116
+ // published them from this module; platform stays the definition.
117
+ export type {
118
+ BatchChunkEntry,
119
+ BatchInodeEntry,
120
+ BatchWritePayload,
121
+ VfsInodeKind,
122
+ } from '@nimbus-sh/platform/w7-frame.js';
89
123
 
90
124
  export interface ExclusiveMutationLease {
91
125
  readonly root: string;
@@ -113,22 +147,6 @@ interface INode {
113
147
  contentId: string | null;
114
148
  }
115
149
 
116
- /** Entry for bulk inode creation via writeBatch(). */
117
- export interface BatchInodeEntry {
118
- path: string;
119
- parentPath: string;
120
- /** Defaults to isDir ? directory : file for legacy/non-symlink producers. */
121
- kind?: VfsInodeKind;
122
- isDir: boolean;
123
- size: number;
124
- atime?: number;
125
- mtime: number;
126
- mode: number;
127
- uid?: number;
128
- gid?: number;
129
- chunkCount: number;
130
- }
131
-
132
150
  export interface VfsStat {
133
151
  type: VfsInodeKind;
134
152
  size: number;
@@ -213,21 +231,6 @@ export interface CredentialedVfs {
213
231
  readonly epoch: string;
214
232
  }
215
233
 
216
- /** Entry for bulk chunk creation via writeBatch(). */
217
- export interface BatchChunkEntry {
218
- path: string;
219
- chunkId: number;
220
- data: Uint8Array;
221
- }
222
-
223
- /** Payload for writeBatch() — all inodes + chunks written in ONE transactionSync(). */
224
- export interface BatchWritePayload {
225
- inodes: BatchInodeEntry[];
226
- chunks: BatchChunkEntry[];
227
- /** Paths to delete before writing (for clean reinstall). */
228
- deletePaths?: string[];
229
- }
230
-
231
234
  export interface WriteBatchStreamProgress {
232
235
  /** 1-based sequence of the last durable publish group; zero means none. */
233
236
  committedGroupSequence: number;
@@ -254,6 +257,8 @@ const CHUNK_ROWS_PER_SQL_EXEC = 33;
254
257
  const CONTENT_IDS_PER_SQL_EXEC = 50;
255
258
  const TRANSACTION_DURATION_SAMPLE_COUNT = 128;
256
259
  const CONTENT_SCHEMA_MIGRATION = 'content_generations_v1';
260
+ /** Storage key of the shared scratch tree. `normalizeVfsPath` drops the slash. */
261
+ const TMP_ROOT = 'tmp';
257
262
  export const VFS_APPEND_RECEIPT_LIMIT = 2048;
258
263
  const INODE_KIND_FILE = 0;
259
264
  const INODE_KIND_DIRECTORY = 1;
@@ -679,6 +684,9 @@ export class SqliteVFS {
679
684
  private readonly activeStagingContentIds = new Set<string>();
680
685
  /** True only while durable GC work or a known abandoned staging row exists. */
681
686
  private maintenancePending = false;
687
+ // Keyset cursor for the orphan scan: each maintenance call reads one bounded
688
+ // page of distinct content ids past this bound, wrapping to '' at the end.
689
+ private orphanScanCursor = '';
682
690
 
683
691
  // Stage 2 transaction/phase telemetry. Scalar writes stay cheap; the
684
692
  // percentile is computed from the fixed ring only when diagnostics read it.
@@ -1317,7 +1325,7 @@ export class SqliteVFS {
1317
1325
  private blobToUint8Array(blob: unknown): Uint8Array {
1318
1326
  if (blob instanceof Uint8Array) return blob;
1319
1327
  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);
1328
+ if (ArrayBuffer.isView(blob)) return new Uint8Array(blob.buffer, blob.byteOffset, blob.byteLength);
1321
1329
  return new Uint8Array(0);
1322
1330
  }
1323
1331
 
@@ -1338,6 +1346,82 @@ export class SqliteVFS {
1338
1346
  return this.blobToUint8Array(rows[0].data);
1339
1347
  }
1340
1348
 
1349
+ /**
1350
+ * Principals whose `/tmp` is private, keyed by uid, valued by the storage
1351
+ * root their `/tmp` resolves to.
1352
+ *
1353
+ * `/tmp` keeps its path in every view and only the bytes behind it differ,
1354
+ * so nothing has to be told which principal it is. The credential is the
1355
+ * only per-process state visible where paths are RESOLVED — there is no pid
1356
+ * and no cwd down here — so it is what the private view is keyed on.
1357
+ *
1358
+ * Resolution rather than a mount, because a mount diverges the two planes:
1359
+ * the shell writing `/tmp/a` and the file API writing `/tmp/b` landed in
1360
+ * different trees under the same name. `resolvePath` already takes `cred`,
1361
+ * and every plane goes through it.
1362
+ *
1363
+ * Registration is also what makes a principal CONFINED for `chmod`. The two
1364
+ * properties travel together because they answer one question: is this
1365
+ * principal a guest in this filesystem. Nothing here applies to an
1366
+ * unregistered credential, so the ordinary session user is untouched.
1367
+ */
1368
+ private confinedTmpRoots = new Map<number, string>();
1369
+
1370
+ /**
1371
+ * Confine a principal. `tmpRoot` is a storage key, not a logical path — the
1372
+ * caller owns creating and chowning it, because a per-principal `chown` is
1373
+ * uid-0 only and a guest cannot provision its own.
1374
+ */
1375
+ confinePrincipal(uid: number, tmpRoot: string): void {
1376
+ const root = normalizeVfsPath(tmpRoot);
1377
+ if (root === '') throw vfsError('EINVAL', 'a private /tmp root cannot be the filesystem root');
1378
+ this.confinedTmpRoots.set(uid, root);
1379
+ }
1380
+
1381
+ /** Drop a confinement. A principal's `/tmp` dies with it; its home does not. */
1382
+ releasePrincipal(uid: number): void {
1383
+ this.confinedTmpRoots.delete(uid);
1384
+ }
1385
+
1386
+ /**
1387
+ * Logical path -> storage key, for one credential.
1388
+ *
1389
+ * Everything under `/tmp` belongs to the caller's own private root, which is
1390
+ * what makes the same path mean different bytes per principal. Idempotent: a
1391
+ * key already inside that root is returned untouched, so the several methods
1392
+ * that derive a key before handing it on cannot stack the rewrite.
1393
+ */
1394
+ private storageKey(path: string, cred: VfsCred): string {
1395
+ const key = normalizeVfsPath(path);
1396
+ const root = this.confinedTmpRoots.get(cred.uid);
1397
+ if (root === undefined) return key;
1398
+ if (key === root || key.startsWith(`${root}/`)) return key;
1399
+ if (key === TMP_ROOT) return root;
1400
+ if (!key.startsWith(`${TMP_ROOT}/`)) return key;
1401
+ return `${root}/${key.slice(TMP_ROOT.length + 1)}`;
1402
+ }
1403
+
1404
+ /**
1405
+ * Storage key -> the name this credential knows it by, or `null` when it has
1406
+ * none. The inverse of {@link storageKey}, for the one surface that reports
1407
+ * paths it was not asked about: {@link list}.
1408
+ *
1409
+ * A confined caller has no name for the shared scratch tree — `/tmp` is its
1410
+ * own root — nor for another principal's, so both answer `null` and are
1411
+ * omitted. An unconfined caller sees storage as it is, which is what the
1412
+ * kernel and the session user need.
1413
+ */
1414
+ private logicalPath(key: string, cred: VfsCred): string | null {
1415
+ const root = this.confinedTmpRoots.get(cred.uid);
1416
+ if (root === undefined) return key;
1417
+ if (key === root) return TMP_ROOT;
1418
+ if (key.startsWith(`${root}/`)) return `${TMP_ROOT}/${key.slice(root.length + 1)}`;
1419
+ // The SHARED scratch root has no name here — this caller's `/tmp` is its
1420
+ // own root, which already supplied that entry. Returning it too would
1421
+ // enumerate one name twice, for two different directories.
1422
+ return key === TMP_ROOT || key.startsWith(`${TMP_ROOT}/`) ? null : key;
1423
+ }
1424
+
1341
1425
  // ── Filesystem operations ─────────────────────────────────────────────
1342
1426
 
1343
1427
  as(cred: VfsCred): CredentialedVfs {
@@ -1399,7 +1483,7 @@ export class SqliteVFS {
1399
1483
  writeBatch: (payload) => this.writeBatch(payload, bound),
1400
1484
  writeStream: (stream, options) => this.writeStream(stream, options, bound),
1401
1485
  mkdirBatch: (paths) => this.mkdirBatch(paths, bound),
1402
- revision: (path) => this.revision(path),
1486
+ revision: (path) => this.revision(path, bound),
1403
1487
  epoch: this._epoch,
1404
1488
  };
1405
1489
  }
@@ -1430,7 +1514,7 @@ export class SqliteVFS {
1430
1514
  followLeaf: boolean,
1431
1515
  allowMissing: boolean,
1432
1516
  ): { path: string; inode: INode | undefined } {
1433
- let current = normalizeVfsPath(path);
1517
+ let current = this.storageKey(path, cred);
1434
1518
  const seen = new Set<string>();
1435
1519
  for (let hops = 0; hops <= 40; hops++) {
1436
1520
  const parts = current.split('/').filter(Boolean);
@@ -1449,8 +1533,13 @@ export class SqliteVFS {
1449
1533
  seen.add(prefix);
1450
1534
  const target = dec.decode(this.readInodeBytes(prefix, inode));
1451
1535
  const suffix = parts.slice(index + 1).join('/');
1536
+ // An ABSOLUTE target is a logical path the same way the caller's
1537
+ // was, so it goes through the same rewrite: otherwise a symlink to
1538
+ // /tmp/y stored inside a private tree would read the shared one,
1539
+ // which is the one way out of the confinement. A RELATIVE target
1540
+ // resolves against a key that is already private, so it stays there.
1452
1541
  const resolvedTarget = target.startsWith('/')
1453
- ? normalizeVfsPath(target)
1542
+ ? this.storageKey(target, cred)
1454
1543
  : normalizeVfsPath(`${this.parentPath(prefix)}/${target}`);
1455
1544
  current = suffix ? normalizeVfsPath(`${resolvedTarget}/${suffix}`) : resolvedTarget;
1456
1545
  restarted = true;
@@ -1496,6 +1585,27 @@ export class SqliteVFS {
1496
1585
  if (resolved.inode?.kind !== 'directory') throw vfsError('ENOTDIR', parent);
1497
1586
  }
1498
1587
 
1588
+ /**
1589
+ * POSIX sticky-bit restriction on a shared directory.
1590
+ *
1591
+ * Write permission on a directory is normally enough to remove or rename
1592
+ * anything inside it, which is why `/tmp` is `1777` and not `0777`: the
1593
+ * sticky bit narrows that to the entry's owner, the directory's owner, and
1594
+ * root. Nothing enforced it here, so a world-writable shared directory gave
1595
+ * every principal the ability to delete every other principal's files —
1596
+ * the mode said one thing and the filesystem did another.
1597
+ */
1598
+ private checkStickyParentMutation(path: string, inode: INode, cred: VfsCred): void {
1599
+ if (cred.uid === 0) return;
1600
+ const parent = this.parentPath(normalizeVfsPath(path));
1601
+ if (parent === '') return;
1602
+ const parentInode = this.inodes.get(parent);
1603
+ if (!parentInode || (parentInode.mode & 0o1000) === 0) return;
1604
+ if (cred.uid !== parentInode.uid && cred.uid !== inode.uid) {
1605
+ throw vfsError('EPERM', normalizeVfsPath(path));
1606
+ }
1607
+ }
1608
+
1499
1609
  /**
1500
1610
  * Shared resolver for the boolean probes (exists/isDirectory/isFile/
1501
1611
  * isSymlink). Resolution-structure failures — a missing or non-directory
@@ -1536,9 +1646,11 @@ export class SqliteVFS {
1536
1646
  * under it changed in this DO lifetime). `revision('')` equals the
1537
1647
  * global clock by construction (every mutation stamps all ancestors).
1538
1648
  */
1539
- revision(path?: string): number {
1649
+ revision(path?: string, cred?: VfsCred): number {
1540
1650
  if (path === undefined) return this._revision;
1541
- const p = normalizeVfsPath(path);
1651
+ // A credentialed caller asks about its own view; a confined one asking
1652
+ // after /tmp/x means its own file, so the counter must be that file's.
1653
+ const p = cred === undefined ? normalizeVfsPath(path) : this.storageKey(path, cred);
1542
1654
  if (p === '') return this._revision;
1543
1655
  return this._pathRevisions.get(p) ?? 0;
1544
1656
  }
@@ -1726,7 +1838,7 @@ export class SqliteVFS {
1726
1838
  }
1727
1839
 
1728
1840
  private mkdir(path: string, options: { recursive?: boolean; mode?: number } | undefined, cred: VfsCred): void {
1729
- const normalized = normalizeVfsPath(path);
1841
+ const normalized = this.storageKey(path, cred);
1730
1842
  this.assertMutationsAllowed([normalized]);
1731
1843
  if (this.exists(normalized, cred)) return;
1732
1844
 
@@ -1832,7 +1944,7 @@ export class SqliteVFS {
1832
1944
 
1833
1945
  private symlink(target: string, path: string, cred: VfsCred): void {
1834
1946
  this.assertMutationsAllowed([path]);
1835
- const normalized = normalizeVfsPath(path);
1947
+ const normalized = this.storageKey(path, cred);
1836
1948
  const prior = this.checkAccess(normalized, 0, cred, { followLeaf: false, allowMissingLeaf: true });
1837
1949
  if (prior.inode) throw vfsError('EEXIST', normalized);
1838
1950
  this.checkParentAccess(normalized, cred);
@@ -2816,6 +2928,25 @@ export class SqliteVFS {
2816
2928
  const inode = resolved.inode;
2817
2929
  if (!inode) throw vfsError('ENOENT', path);
2818
2930
  if (cred.uid !== 0 && cred.uid !== inode.uid) throw vfsError('EPERM', resolved.path);
2931
+ // A confined principal owns its own triad and nothing else. Refusing chmod
2932
+ // outright would be simpler and wrong: execution is gated on the x bit, so
2933
+ // a guest that writes build.sh and cannot chmod it cannot run it. What
2934
+ // must not happen is WIDENING past its own principal, so the group, other
2935
+ // and setuid/setgid/sticky bits keep the value they were provisioned with
2936
+ // while the owner triad moves freely. `u+x` works; `+x` and `777` do not.
2937
+ //
2938
+ // Refused, never clamped. Quietly narrowing a mutation to the part that
2939
+ // was allowed reports success for something other than what was asked, so
2940
+ // the refusal names the spelling that works instead.
2941
+ if (cred.uid !== 0 && this.confinedTmpRoots.has(cred.uid)) {
2942
+ const beyondOwner = (mode & 0o7777) & ~0o700;
2943
+ if (beyondOwner !== ((inode.mode & 0o7777) & ~0o700)) {
2944
+ throw vfsError(
2945
+ 'EPERM',
2946
+ `${resolved.path}: mode change would grant permission outside your own principal; use u+x`,
2947
+ );
2948
+ }
2949
+ }
2819
2950
  this.assertMutationsAllowed([inode.path]);
2820
2951
  const full = inodeTypeBits(inode.kind) | (mode & 0o7777);
2821
2952
  inode.mode = full;
@@ -2886,7 +3017,7 @@ export class SqliteVFS {
2886
3017
  // Before the walk, deliberately — see VfsListPage.
2887
3018
  const epoch = this._epoch;
2888
3019
  const rev = this._revision;
2889
- const from = after === null || after === undefined ? '' : normalizeVfsPath(after);
3020
+ const from = after === null || after === undefined ? '' : this.storageKey(after, cred);
2890
3021
  // this.inodes is insertion-ordered, not path-ordered, so the sort is what
2891
3022
  // makes `after` a resume key at all. Paid once per page against a map the
2892
3023
  // DO already holds whole.
@@ -2908,8 +3039,15 @@ export class SqliteVFS {
2908
3039
  } catch {
2909
3040
  continue;
2910
3041
  }
3042
+ // Reported in the caller's OWN path space. Enumerating raw storage keys
3043
+ // would name a private root the caller cannot address and does not know
3044
+ // it has, and a caller feeding such a path back would be asking about
3045
+ // someone else's tree. `null` means the path has no name for this
3046
+ // caller, which is the same OMIT the access check above performs.
3047
+ const logical = this.logicalPath(path, cred);
3048
+ if (logical === null) continue;
2911
3049
  entries.push({
2912
- path,
3050
+ path: logical,
2913
3051
  kind: inode.kind,
2914
3052
  size: inode.size,
2915
3053
  rev: this._pathRevisions.get(path) ?? 0,
@@ -2919,7 +3057,7 @@ export class SqliteVFS {
2919
3057
  }
2920
3058
 
2921
3059
  private readdir(path: string, cred: VfsCred): { name: string; type: VfsInodeKind }[] {
2922
- const np = path.replace(/^\/+/, '').replace(/\/+$/, '');
3060
+ const np = this.storageKey(path, cred);
2923
3061
  const resolved = np ? this.checkAccess(np, 0o4, cred) : { path: '', inode: undefined };
2924
3062
  const inode = resolved.inode;
2925
3063
  if (inode && inode.kind !== 'directory') throw vfsError('ENOTDIR', path);
@@ -2927,7 +3065,7 @@ export class SqliteVFS {
2927
3065
  if (!kids) {
2928
3066
  // W2.5b diagnostic: empty children-set for a directory we expected
2929
3067
  // to be populated.
2930
- if ((globalThis as any).process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1') {
3068
+ if (installPipelineDiagEnabled()) {
2931
3069
  // eslint-disable-next-line no-console
2932
3070
  console.warn(
2933
3071
  '[sqlite-vfs/W2.5b] readdir miss path=' + np +
@@ -2949,7 +3087,7 @@ export class SqliteVFS {
2949
3087
  // This distinguishes (a) "children index broken" from (b) "inodes
2950
3088
  // map lost entries".
2951
3089
  if (
2952
- (globalThis as any).process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1' &&
3090
+ installPipelineDiagEnabled() &&
2953
3091
  kids.size !== results.length
2954
3092
  ) {
2955
3093
  // eslint-disable-next-line no-console
@@ -2977,22 +3115,24 @@ export class SqliteVFS {
2977
3115
  const inode = resolved.inode;
2978
3116
  if (!inode) throw vfsError('ENOENT', path);
2979
3117
  this.checkParentAccess(resolved.path, cred);
3118
+ this.checkStickyParentMutation(resolved.path, inode, cred);
2980
3119
  if (inode.isDir) throw vfsError('EISDIR', resolved.path);
2981
3120
  this.writeBatch({ inodes: [], chunks: [], deletePaths: [resolved.path] }, cred);
2982
3121
  }
2983
3122
 
2984
3123
  private rmdir(path: string, cred: VfsCred): void {
2985
3124
  this.assertMutationsAllowed([path]);
2986
- const np = path.replace(/^\/+/, '').replace(/\/+$/, '');
3125
+ const np = this.storageKey(path, cred);
2987
3126
  const resolved = this.checkAccess(np, 0, cred, { followLeaf: false });
2988
3127
  this.checkParentAccess(resolved.path, cred);
3128
+ const inode = this.inodes.get(np);
3129
+ if (!inode) throw vfsError('ENOENT', path);
3130
+ this.checkStickyParentMutation(resolved.path, inode, cred);
2989
3131
  // Check if empty using children index (O(1) instead of O(N))
2990
3132
  const kids = this.children.get(np);
2991
3133
  if (kids && kids.size > 0) {
2992
3134
  throw vfsError('ENOTEMPTY', path);
2993
3135
  }
2994
- const inode = this.inodes.get(np);
2995
- if (!inode) throw vfsError('ENOENT', path);
2996
3136
  if (!inode.isDir) throw vfsError('ENOTDIR', path);
2997
3137
  this.writeBatch({ inodes: [], chunks: [], deletePaths: [np] }, cred);
2998
3138
  }
@@ -3018,6 +3158,7 @@ export class SqliteVFS {
3018
3158
  const resolved = this.checkAccess(path, 0, cred, { followLeaf: false });
3019
3159
  if (!resolved.inode) throw vfsError('ENOENT', normalizeVfsPath(path));
3020
3160
  this.checkParentAccess(resolved.path, cred);
3161
+ this.checkStickyParentMutation(resolved.path, resolved.inode, cred);
3021
3162
 
3022
3163
  const removable = this.collectSubtreeInodes([resolved.path]);
3023
3164
  // The directories the recursive walk used to enumerate are exactly the
@@ -3067,6 +3208,7 @@ export class SqliteVFS {
3067
3208
  }
3068
3209
  const inode = this.inodes.get(oldPath);
3069
3210
  if (!inode) throw new Error("ENOENT: " + oldPath);
3211
+ this.checkStickyParentMutation(oldPath, inode, cred);
3070
3212
 
3071
3213
  // W-3 (WASI filesystem WASI): if newPath already exists, unlink it first so the
3072
3214
  // SQL UPDATE doesn't conflict on inodes.path uniqueness. POSIX rename(2)
@@ -3077,6 +3219,7 @@ export class SqliteVFS {
3077
3219
  // (the latter is rare but POSIX permits it for empty dirs).
3078
3220
  const destInode = this.inodes.get(newPath);
3079
3221
  if (destInode) {
3222
+ this.checkStickyParentMutation(newPath, destInode, cred);
3080
3223
  if (destInode.isDir) {
3081
3224
  // POSIX semantics: rename onto a non-empty dir is an error
3082
3225
  // (ENOTEMPTY); rename onto an empty dir is allowed. We surface
@@ -3283,14 +3426,14 @@ export class SqliteVFS {
3283
3426
  // ── Batch write (npm install fast path) ───────────────────────────────
3284
3427
 
3285
3428
  private normalizeBatchInode(entry: BatchInodeEntry, cred: VfsCred): NormalizedBatchInodeEntry {
3286
- const path = normalizeVfsPath(entry.path);
3429
+ const path = this.storageKey(entry.path, cred);
3287
3430
  const prior = this.inodes.get(path);
3288
3431
  const newUid = cred.uid === 0 ? (entry.uid ?? 1000) : cred.uid;
3289
3432
  const newGid = cred.uid === 0 ? (entry.gid ?? 1000) : cred.gid;
3290
3433
  return {
3291
3434
  ...entry,
3292
3435
  path,
3293
- parentPath: normalizeVfsPath(entry.parentPath),
3436
+ parentPath: this.storageKey(entry.parentPath, cred),
3294
3437
  mode: prior
3295
3438
  ? prior.mode
3296
3439
  : inodeKind(entry) === 'symlink'
@@ -3324,7 +3467,7 @@ export class SqliteVFS {
3324
3467
  };
3325
3468
 
3326
3469
  for (const path of payload.deletePaths ?? []) {
3327
- const normalized = normalizeVfsPath(path);
3470
+ const normalized = this.storageKey(path, cred);
3328
3471
  const existing = this.checkAccess(normalized, 0, cred, {
3329
3472
  followLeaf: false,
3330
3473
  allowMissingLeaf: true,
@@ -3337,13 +3480,13 @@ export class SqliteVFS {
3337
3480
  else checkParent(entry.path);
3338
3481
  }
3339
3482
  for (const chunk of payload.chunks) {
3340
- const path = normalizeVfsPath(chunk.path);
3483
+ const path = this.storageKey(chunk.path, cred);
3341
3484
  if (!pending.has(path)) this.checkAccess(path, 0o2, cred, { followLeaf: false });
3342
3485
  }
3343
3486
  return {
3344
3487
  ...payload,
3345
3488
  inodes,
3346
- deletePaths: payload.deletePaths?.map(normalizeVfsPath),
3489
+ deletePaths: payload.deletePaths?.map((path) => this.storageKey(path, cred)),
3347
3490
  };
3348
3491
  }
3349
3492
 
@@ -4118,21 +4261,34 @@ export class SqliteVFS {
4118
4261
  let stagingScanComplete = true;
4119
4262
 
4120
4263
  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 ?`,
4264
+ // The page is selected before the orphan predicates so a scan that finds
4265
+ // nothing still reads only one page, not the whole covering index.
4266
+ const pageRows = [...this.sql.exec(
4267
+ `SELECT page.content_id AS content_id,
4268
+ (NOT EXISTS (
4269
+ SELECT 1 FROM content_lifecycle AS lifecycle
4270
+ WHERE lifecycle.content_id = page.content_id
4271
+ )
4272
+ AND ${sqlNoInodeContentReference('page.content_id')}) AS orphaned
4273
+ FROM (
4274
+ SELECT chunks.content_id
4275
+ FROM file_chunks AS chunks
4276
+ WHERE chunks.content_id > ?
4277
+ GROUP BY chunks.content_id
4278
+ ORDER BY chunks.content_id
4279
+ LIMIT ?
4280
+ ) AS page
4281
+ ORDER BY page.content_id`,
4282
+ this.orphanScanCursor,
4132
4283
  CONTENT_IDS_PER_SQL_EXEC,
4133
4284
  )];
4134
- orphanScanComplete = orphanRows.length < CONTENT_IDS_PER_SQL_EXEC;
4135
- const contentIds = orphanRows.map((row) => String(row.content_id));
4285
+ orphanScanComplete = pageRows.length < CONTENT_IDS_PER_SQL_EXEC;
4286
+ this.orphanScanCursor = orphanScanComplete
4287
+ ? ''
4288
+ : String(pageRows[pageRows.length - 1].content_id);
4289
+ const contentIds = pageRows
4290
+ .filter((row) => Number(row.orphaned) === 1)
4291
+ .map((row) => String(row.content_id));
4136
4292
  if (contentIds.length > 0) {
4137
4293
  const candidates = contentIds
4138
4294
  .map((_, index) => index === 0 ? 'SELECT ? AS content_id' : 'SELECT ?')
@@ -4341,7 +4497,7 @@ export class SqliteVFS {
4341
4497
  /** Best-effort process.memoryUsage().heapUsed; 0 in DO contexts. */
4342
4498
  private _safeHeapUsed(): number {
4343
4499
  try {
4344
- const mu = (globalThis as any).process?.memoryUsage?.();
4500
+ const mu = nodeHost.process?.memoryUsage?.();
4345
4501
  return Number(mu?.heapUsed) || 0;
4346
4502
  } catch {
4347
4503
  return 0;
@@ -4536,7 +4692,7 @@ export class SqliteVFS {
4536
4692
  // `_addToChildrenIndex` uses Set.add so repeated calls are idempotent;
4537
4693
  // gating it on `prior === undefined` was the bug. Counters remain
4538
4694
  // gated correctly so they don't double-count.
4539
- const __diag = ((globalThis as any).process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1');
4695
+ const __diag = installPipelineDiagEnabled();
4540
4696
  const replacedPaths = new Set<string>();
4541
4697
  for (const entry of plan.inodes) {
4542
4698
  const prior = this.inodes.get(entry.path);
@@ -1,47 +0,0 @@
1
- /**
2
- * _shared/install-phase.ts — single source of truth for the npm-install
3
- * state-machine phase enum.
4
- *
5
- * History: CLN-1 (2026-05-11) — consolidated from two diverged copies.
6
- * - `npm/installer.ts:93-95` was the progress-event variant. Included
7
- * 'lock-check' (a real progress event emitted while parsing
8
- * package-lock.json) but lacked 'idle'.
9
- * - `observability/diag-counters.ts:33-35` was the counter-state variant.
10
- * Included 'idle' (the resting state when no install is in flight)
11
- * but lacked 'lock-check'. The diag-counter's `setInstallPhase()`
12
- * was actually called with 'idle' (installer.ts:184,201) — passing
13
- * a value not declared in installer.ts's local type. The type check
14
- * fell through to diag-counters' type because installer.ts imports
15
- * setInstallPhase from there; the local `InstallPhase` definition
16
- * in installer.ts was only used by `InstallProgress.phase`.
17
- *
18
- * Going forward both consumers import from this module; any new phase
19
- * value added here is automatically available everywhere.
20
- *
21
- * Phase semantics (chronological order for a single install run):
22
- *
23
- * idle resting state, no install in flight
24
- * ↓
25
- * lock-check parsing existing package-lock.json (if present)
26
- * ↓
27
- * resolve walking the dep tree, fetching packuments
28
- * ↓
29
- * hoist hoisting peer-deps + flattening node_modules
30
- * ↓
31
- * diff comparing target tree against current install state
32
- * ↓
33
- * fetch downloading missing tarballs
34
- * ↓
35
- * write extracting tarballs into the VFS
36
- * ↓
37
- * link-bins creating bin/ symlinks
38
- * ↓
39
- * bundle (post-install) pre-bundling for esbuild fast path
40
- * ↓
41
- * done install complete; about to return to 'idle'
42
- *
43
- * The phase strings are surfaced via /api/_diag/install-pipeline and
44
- * displayed in the install-progress UI; treat them as a public API.
45
- */
46
- export type InstallPhase = 'idle' | 'lock-check' | 'resolve' | 'hoist' | 'diff' | 'fetch' | 'write' | 'link-bins' | 'bundle' | 'done';
47
- //# sourceMappingURL=install-phase.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"install-phase.d.ts","sourceRoot":"","sources":["../../src/_shared/install-phase.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,MAAM,MAAM,YAAY,GACpB,MAAM,GACN,YAAY,GACZ,SAAS,GACT,OAAO,GACP,MAAM,GACN,OAAO,GACP,OAAO,GACP,WAAW,GACX,QAAQ,GACR,MAAM,CAAC"}
@@ -1 +0,0 @@
1
- export {};
@@ -1,4 +0,0 @@
1
- export declare function disposeRpcResource(value: unknown): boolean;
2
- export declare function disposeRpcResources(values: Iterable<unknown>): void;
3
- export declare function useRpcResource<T, R>(promise: Promise<T>, use: (value: T) => R | Promise<R>): Promise<R>;
4
- //# sourceMappingURL=rpc-dispose.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"rpc-dispose.d.ts","sourceRoot":"","sources":["../../src/_shared/rpc-dispose.ts"],"names":[],"mappings":"AAEA,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAY1D;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,IAAI,CAEnE;AAED,wBAAsB,cAAc,CAAC,CAAC,EAAE,CAAC,EACvC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,GAChC,OAAO,CAAC,CAAC,CAAC,CAOZ"}
@@ -1,30 +0,0 @@
1
- export function disposeRpcResource(value) {
2
- if ((typeof value !== 'object' && typeof value !== 'function') || value === null)
3
- return false;
4
- const disposerKey = Symbol.dispose;
5
- if (!disposerKey)
6
- return false;
7
- const disposer = Reflect.get(value, disposerKey);
8
- if (typeof disposer !== 'function')
9
- return false;
10
- try {
11
- Reflect.apply(disposer, value, []);
12
- return true;
13
- }
14
- catch {
15
- return false;
16
- }
17
- }
18
- export function disposeRpcResources(values) {
19
- for (const value of values)
20
- disposeRpcResource(value);
21
- }
22
- export async function useRpcResource(promise, use) {
23
- const value = await promise;
24
- try {
25
- return await use(value);
26
- }
27
- finally {
28
- disposeRpcResource(value);
29
- }
30
- }