@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
@@ -41,21 +41,33 @@
41
41
  */
42
42
  import { VfsEventEmitter } from './events.js';
43
43
  import { normalizeVfsPath } from './path.js';
44
- import { CHUNK_SIZE, LRU_MAX_ENTRIES, MAX_TX_BLOB_BYTES, MAX_TX_LOGICAL_ROWS, MAX_TX_SQL_EXECS, MAX_GLOBAL_WRITE_STREAM_CREDIT_BYTES, FS_LIST_PAGE_LIMIT, } from '../constants.js';
45
- import { recordFailure } from '../observability/oom-discriminator.js';
46
- import { classifyError } from '../observability/oom-classify.js';
47
- import { acquireSupervisorAllocation } from '../observability/heavy-alloc-coord.js';
44
+ import { LRU_MAX_ENTRIES, FS_LIST_PAGE_LIMIT, } from '../constants.js';
45
+ import { CHUNK_SIZE, MAX_TX_BLOB_BYTES, MAX_TX_LOGICAL_ROWS, MAX_TX_SQL_EXECS, MAX_GLOBAL_WRITE_STREAM_CREDIT_BYTES, } from '@nimbus-sh/platform/limits.js';
46
+ import { recordFailure } from '@nimbus-sh/platform/oom-discriminator.js';
47
+ import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
48
+ import { acquireSupervisorAllocation } from '@nimbus-sh/platform/heavy-alloc-coord.js';
48
49
  import { enc, dec } from '../_shared/bytes.js';
49
- import { decodeWriteBatchStream, } from '../_shared/w7-frame.js';
50
- import { WeightedCreditPool, } from '../_shared/weighted-credit-pool.js';
50
+ import { decodeWriteBatchStream, } from '@nimbus-sh/platform/w7-frame.js';
51
+ import { WeightedCreditPool, } from '@nimbus-sh/platform/weighted-credit-pool.js';
51
52
  import { LEGACY_SYMLINK_REGISTRY_PATH } from './symlink-registry.js';
52
53
  import { CRED_KERNEL, } from '../runtime/os-contracts.js';
53
54
  const CONTENT_ID_ALLOCATION_ATTEMPTS = 8;
55
+ /**
56
+ * Live view of the global object. `process` is not in the Workers lib, so its
57
+ * shape is declared here rather than assumed present.
58
+ */
59
+ const nodeHost = globalThis;
60
+ /** Single gate for the W2.5b install-pipeline diagnostics below. */
61
+ function installPipelineDiagEnabled() {
62
+ return nodeHost.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1';
63
+ }
54
64
  const INODE_ROWS_PER_SQL_EXEC = 9;
55
65
  const CHUNK_ROWS_PER_SQL_EXEC = 33;
56
66
  const CONTENT_IDS_PER_SQL_EXEC = 50;
57
67
  const TRANSACTION_DURATION_SAMPLE_COUNT = 128;
58
68
  const CONTENT_SCHEMA_MIGRATION = 'content_generations_v1';
69
+ /** Storage key of the shared scratch tree. `normalizeVfsPath` drops the slash. */
70
+ const TMP_ROOT = 'tmp';
59
71
  export const VFS_APPEND_RECEIPT_LIMIT = 2048;
60
72
  const INODE_KIND_FILE = 0;
61
73
  const INODE_KIND_DIRECTORY = 1;
@@ -357,6 +369,9 @@ export class SqliteVFS {
357
369
  activeStagingContentIds = new Set();
358
370
  /** True only while durable GC work or a known abandoned staging row exists. */
359
371
  maintenancePending = false;
372
+ // Keyset cursor for the orphan scan: each maintenance call reads one bounded
373
+ // page of distinct content ids past this bound, wrapping to '' at the end.
374
+ orphanScanCursor = '';
360
375
  // Stage 2 transaction/phase telemetry. Scalar writes stay cheap; the
361
376
  // percentile is computed from the fixed ring only when diagnostics read it.
362
377
  _activeTransaction = null;
@@ -939,6 +954,85 @@ export class SqliteVFS {
939
954
  return null;
940
955
  return this.blobToUint8Array(rows[0].data);
941
956
  }
957
+ /**
958
+ * Principals whose `/tmp` is private, keyed by uid, valued by the storage
959
+ * root their `/tmp` resolves to.
960
+ *
961
+ * `/tmp` keeps its path in every view and only the bytes behind it differ,
962
+ * so nothing has to be told which principal it is. The credential is the
963
+ * only per-process state visible where paths are RESOLVED — there is no pid
964
+ * and no cwd down here — so it is what the private view is keyed on.
965
+ *
966
+ * Resolution rather than a mount, because a mount diverges the two planes:
967
+ * the shell writing `/tmp/a` and the file API writing `/tmp/b` landed in
968
+ * different trees under the same name. `resolvePath` already takes `cred`,
969
+ * and every plane goes through it.
970
+ *
971
+ * Registration is also what makes a principal CONFINED for `chmod`. The two
972
+ * properties travel together because they answer one question: is this
973
+ * principal a guest in this filesystem. Nothing here applies to an
974
+ * unregistered credential, so the ordinary session user is untouched.
975
+ */
976
+ confinedTmpRoots = new Map();
977
+ /**
978
+ * Confine a principal. `tmpRoot` is a storage key, not a logical path — the
979
+ * caller owns creating and chowning it, because a per-principal `chown` is
980
+ * uid-0 only and a guest cannot provision its own.
981
+ */
982
+ confinePrincipal(uid, tmpRoot) {
983
+ const root = normalizeVfsPath(tmpRoot);
984
+ if (root === '')
985
+ throw vfsError('EINVAL', 'a private /tmp root cannot be the filesystem root');
986
+ this.confinedTmpRoots.set(uid, root);
987
+ }
988
+ /** Drop a confinement. A principal's `/tmp` dies with it; its home does not. */
989
+ releasePrincipal(uid) {
990
+ this.confinedTmpRoots.delete(uid);
991
+ }
992
+ /**
993
+ * Logical path -> storage key, for one credential.
994
+ *
995
+ * Everything under `/tmp` belongs to the caller's own private root, which is
996
+ * what makes the same path mean different bytes per principal. Idempotent: a
997
+ * key already inside that root is returned untouched, so the several methods
998
+ * that derive a key before handing it on cannot stack the rewrite.
999
+ */
1000
+ storageKey(path, cred) {
1001
+ const key = normalizeVfsPath(path);
1002
+ const root = this.confinedTmpRoots.get(cred.uid);
1003
+ if (root === undefined)
1004
+ return key;
1005
+ if (key === root || key.startsWith(`${root}/`))
1006
+ return key;
1007
+ if (key === TMP_ROOT)
1008
+ return root;
1009
+ if (!key.startsWith(`${TMP_ROOT}/`))
1010
+ return key;
1011
+ return `${root}/${key.slice(TMP_ROOT.length + 1)}`;
1012
+ }
1013
+ /**
1014
+ * Storage key -> the name this credential knows it by, or `null` when it has
1015
+ * none. The inverse of {@link storageKey}, for the one surface that reports
1016
+ * paths it was not asked about: {@link list}.
1017
+ *
1018
+ * A confined caller has no name for the shared scratch tree — `/tmp` is its
1019
+ * own root — nor for another principal's, so both answer `null` and are
1020
+ * omitted. An unconfined caller sees storage as it is, which is what the
1021
+ * kernel and the session user need.
1022
+ */
1023
+ logicalPath(key, cred) {
1024
+ const root = this.confinedTmpRoots.get(cred.uid);
1025
+ if (root === undefined)
1026
+ return key;
1027
+ if (key === root)
1028
+ return TMP_ROOT;
1029
+ if (key.startsWith(`${root}/`))
1030
+ return `${TMP_ROOT}/${key.slice(root.length + 1)}`;
1031
+ // The SHARED scratch root has no name here — this caller's `/tmp` is its
1032
+ // own root, which already supplied that entry. Returning it too would
1033
+ // enumerate one name twice, for two different directories.
1034
+ return key === TMP_ROOT || key.startsWith(`${TMP_ROOT}/`) ? null : key;
1035
+ }
942
1036
  // ── Filesystem operations ─────────────────────────────────────────────
943
1037
  as(cred) {
944
1038
  const bound = Object.freeze({
@@ -983,7 +1077,7 @@ export class SqliteVFS {
983
1077
  writeBatch: (payload) => this.writeBatch(payload, bound),
984
1078
  writeStream: (stream, options) => this.writeStream(stream, options, bound),
985
1079
  mkdirBatch: (paths) => this.mkdirBatch(paths, bound),
986
- revision: (path) => this.revision(path),
1080
+ revision: (path) => this.revision(path, bound),
987
1081
  epoch: this._epoch,
988
1082
  };
989
1083
  }
@@ -1007,7 +1101,7 @@ export class SqliteVFS {
1007
1101
  return (granted & requested) === requested;
1008
1102
  }
1009
1103
  resolvePath(path, cred, followLeaf, allowMissing) {
1010
- let current = normalizeVfsPath(path);
1104
+ let current = this.storageKey(path, cred);
1011
1105
  const seen = new Set();
1012
1106
  for (let hops = 0; hops <= 40; hops++) {
1013
1107
  const parts = current.split('/').filter(Boolean);
@@ -1028,8 +1122,13 @@ export class SqliteVFS {
1028
1122
  seen.add(prefix);
1029
1123
  const target = dec.decode(this.readInodeBytes(prefix, inode));
1030
1124
  const suffix = parts.slice(index + 1).join('/');
1125
+ // An ABSOLUTE target is a logical path the same way the caller's
1126
+ // was, so it goes through the same rewrite: otherwise a symlink to
1127
+ // /tmp/y stored inside a private tree would read the shared one,
1128
+ // which is the one way out of the confinement. A RELATIVE target
1129
+ // resolves against a key that is already private, so it stays there.
1031
1130
  const resolvedTarget = target.startsWith('/')
1032
- ? normalizeVfsPath(target)
1131
+ ? this.storageKey(target, cred)
1033
1132
  : normalizeVfsPath(`${this.parentPath(prefix)}/${target}`);
1034
1133
  current = suffix ? normalizeVfsPath(`${resolvedTarget}/${suffix}`) : resolvedTarget;
1035
1134
  restarted = true;
@@ -1068,6 +1167,29 @@ export class SqliteVFS {
1068
1167
  if (resolved.inode?.kind !== 'directory')
1069
1168
  throw vfsError('ENOTDIR', parent);
1070
1169
  }
1170
+ /**
1171
+ * POSIX sticky-bit restriction on a shared directory.
1172
+ *
1173
+ * Write permission on a directory is normally enough to remove or rename
1174
+ * anything inside it, which is why `/tmp` is `1777` and not `0777`: the
1175
+ * sticky bit narrows that to the entry's owner, the directory's owner, and
1176
+ * root. Nothing enforced it here, so a world-writable shared directory gave
1177
+ * every principal the ability to delete every other principal's files —
1178
+ * the mode said one thing and the filesystem did another.
1179
+ */
1180
+ checkStickyParentMutation(path, inode, cred) {
1181
+ if (cred.uid === 0)
1182
+ return;
1183
+ const parent = this.parentPath(normalizeVfsPath(path));
1184
+ if (parent === '')
1185
+ return;
1186
+ const parentInode = this.inodes.get(parent);
1187
+ if (!parentInode || (parentInode.mode & 0o1000) === 0)
1188
+ return;
1189
+ if (cred.uid !== parentInode.uid && cred.uid !== inode.uid) {
1190
+ throw vfsError('EPERM', normalizeVfsPath(path));
1191
+ }
1192
+ }
1071
1193
  /**
1072
1194
  * Shared resolver for the boolean probes (exists/isDirectory/isFile/
1073
1195
  * isSymlink). Resolution-structure failures — a missing or non-directory
@@ -1105,10 +1227,12 @@ export class SqliteVFS {
1105
1227
  * under it changed in this DO lifetime). `revision('')` equals the
1106
1228
  * global clock by construction (every mutation stamps all ancestors).
1107
1229
  */
1108
- revision(path) {
1230
+ revision(path, cred) {
1109
1231
  if (path === undefined)
1110
1232
  return this._revision;
1111
- const p = normalizeVfsPath(path);
1233
+ // A credentialed caller asks about its own view; a confined one asking
1234
+ // after /tmp/x means its own file, so the counter must be that file's.
1235
+ const p = cred === undefined ? normalizeVfsPath(path) : this.storageKey(path, cred);
1112
1236
  if (p === '')
1113
1237
  return this._revision;
1114
1238
  return this._pathRevisions.get(p) ?? 0;
@@ -1288,7 +1412,7 @@ export class SqliteVFS {
1288
1412
  }
1289
1413
  }
1290
1414
  mkdir(path, options, cred) {
1291
- const normalized = normalizeVfsPath(path);
1415
+ const normalized = this.storageKey(path, cred);
1292
1416
  this.assertMutationsAllowed([normalized]);
1293
1417
  if (this.exists(normalized, cred))
1294
1418
  return;
@@ -1389,7 +1513,7 @@ export class SqliteVFS {
1389
1513
  }
1390
1514
  symlink(target, path, cred) {
1391
1515
  this.assertMutationsAllowed([path]);
1392
- const normalized = normalizeVfsPath(path);
1516
+ const normalized = this.storageKey(path, cred);
1393
1517
  const prior = this.checkAccess(normalized, 0, cred, { followLeaf: false, allowMissingLeaf: true });
1394
1518
  if (prior.inode)
1395
1519
  throw vfsError('EEXIST', normalized);
@@ -2124,6 +2248,22 @@ export class SqliteVFS {
2124
2248
  throw vfsError('ENOENT', path);
2125
2249
  if (cred.uid !== 0 && cred.uid !== inode.uid)
2126
2250
  throw vfsError('EPERM', resolved.path);
2251
+ // A confined principal owns its own triad and nothing else. Refusing chmod
2252
+ // outright would be simpler and wrong: execution is gated on the x bit, so
2253
+ // a guest that writes build.sh and cannot chmod it cannot run it. What
2254
+ // must not happen is WIDENING past its own principal, so the group, other
2255
+ // and setuid/setgid/sticky bits keep the value they were provisioned with
2256
+ // while the owner triad moves freely. `u+x` works; `+x` and `777` do not.
2257
+ //
2258
+ // Refused, never clamped. Quietly narrowing a mutation to the part that
2259
+ // was allowed reports success for something other than what was asked, so
2260
+ // the refusal names the spelling that works instead.
2261
+ if (cred.uid !== 0 && this.confinedTmpRoots.has(cred.uid)) {
2262
+ const beyondOwner = (mode & 0o7777) & ~0o700;
2263
+ if (beyondOwner !== ((inode.mode & 0o7777) & ~0o700)) {
2264
+ throw vfsError('EPERM', `${resolved.path}: mode change would grant permission outside your own principal; use u+x`);
2265
+ }
2266
+ }
2127
2267
  this.assertMutationsAllowed([inode.path]);
2128
2268
  const full = inodeTypeBits(inode.kind) | (mode & 0o7777);
2129
2269
  inode.mode = full;
@@ -2183,7 +2323,7 @@ export class SqliteVFS {
2183
2323
  // Before the walk, deliberately — see VfsListPage.
2184
2324
  const epoch = this._epoch;
2185
2325
  const rev = this._revision;
2186
- const from = after === null || after === undefined ? '' : normalizeVfsPath(after);
2326
+ const from = after === null || after === undefined ? '' : this.storageKey(after, cred);
2187
2327
  // this.inodes is insertion-ordered, not path-ordered, so the sort is what
2188
2328
  // makes `after` a resume key at all. Paid once per page against a map the
2189
2329
  // DO already holds whole.
@@ -2210,8 +2350,16 @@ export class SqliteVFS {
2210
2350
  catch {
2211
2351
  continue;
2212
2352
  }
2353
+ // Reported in the caller's OWN path space. Enumerating raw storage keys
2354
+ // would name a private root the caller cannot address and does not know
2355
+ // it has, and a caller feeding such a path back would be asking about
2356
+ // someone else's tree. `null` means the path has no name for this
2357
+ // caller, which is the same OMIT the access check above performs.
2358
+ const logical = this.logicalPath(path, cred);
2359
+ if (logical === null)
2360
+ continue;
2213
2361
  entries.push({
2214
- path,
2362
+ path: logical,
2215
2363
  kind: inode.kind,
2216
2364
  size: inode.size,
2217
2365
  rev: this._pathRevisions.get(path) ?? 0,
@@ -2220,7 +2368,7 @@ export class SqliteVFS {
2220
2368
  return { epoch, rev, entries, next };
2221
2369
  }
2222
2370
  readdir(path, cred) {
2223
- const np = path.replace(/^\/+/, '').replace(/\/+$/, '');
2371
+ const np = this.storageKey(path, cred);
2224
2372
  const resolved = np ? this.checkAccess(np, 0o4, cred) : { path: '', inode: undefined };
2225
2373
  const inode = resolved.inode;
2226
2374
  if (inode && inode.kind !== 'directory')
@@ -2229,7 +2377,7 @@ export class SqliteVFS {
2229
2377
  if (!kids) {
2230
2378
  // W2.5b diagnostic: empty children-set for a directory we expected
2231
2379
  // to be populated.
2232
- if (globalThis.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1') {
2380
+ if (installPipelineDiagEnabled()) {
2233
2381
  // eslint-disable-next-line no-console
2234
2382
  console.warn('[sqlite-vfs/W2.5b] readdir miss path=' + np +
2235
2383
  ' kidsUndefined=true inodeExists=' + this.inodes.has(np));
@@ -2248,7 +2396,7 @@ export class SqliteVFS {
2248
2396
  // fewer (some entries' inodes are missing from this.inodes), log it.
2249
2397
  // This distinguishes (a) "children index broken" from (b) "inodes
2250
2398
  // map lost entries".
2251
- if (globalThis.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1' &&
2399
+ if (installPipelineDiagEnabled() &&
2252
2400
  kids.size !== results.length) {
2253
2401
  // eslint-disable-next-line no-console
2254
2402
  console.warn('[sqlite-vfs/W2.5b] readdir size mismatch path=' + np +
@@ -2273,23 +2421,25 @@ export class SqliteVFS {
2273
2421
  if (!inode)
2274
2422
  throw vfsError('ENOENT', path);
2275
2423
  this.checkParentAccess(resolved.path, cred);
2424
+ this.checkStickyParentMutation(resolved.path, inode, cred);
2276
2425
  if (inode.isDir)
2277
2426
  throw vfsError('EISDIR', resolved.path);
2278
2427
  this.writeBatch({ inodes: [], chunks: [], deletePaths: [resolved.path] }, cred);
2279
2428
  }
2280
2429
  rmdir(path, cred) {
2281
2430
  this.assertMutationsAllowed([path]);
2282
- const np = path.replace(/^\/+/, '').replace(/\/+$/, '');
2431
+ const np = this.storageKey(path, cred);
2283
2432
  const resolved = this.checkAccess(np, 0, cred, { followLeaf: false });
2284
2433
  this.checkParentAccess(resolved.path, cred);
2434
+ const inode = this.inodes.get(np);
2435
+ if (!inode)
2436
+ throw vfsError('ENOENT', path);
2437
+ this.checkStickyParentMutation(resolved.path, inode, cred);
2285
2438
  // Check if empty using children index (O(1) instead of O(N))
2286
2439
  const kids = this.children.get(np);
2287
2440
  if (kids && kids.size > 0) {
2288
2441
  throw vfsError('ENOTEMPTY', path);
2289
2442
  }
2290
- const inode = this.inodes.get(np);
2291
- if (!inode)
2292
- throw vfsError('ENOENT', path);
2293
2443
  if (!inode.isDir)
2294
2444
  throw vfsError('ENOTDIR', path);
2295
2445
  this.writeBatch({ inodes: [], chunks: [], deletePaths: [np] }, cred);
@@ -2316,6 +2466,7 @@ export class SqliteVFS {
2316
2466
  if (!resolved.inode)
2317
2467
  throw vfsError('ENOENT', normalizeVfsPath(path));
2318
2468
  this.checkParentAccess(resolved.path, cred);
2469
+ this.checkStickyParentMutation(resolved.path, resolved.inode, cred);
2319
2470
  const removable = this.collectSubtreeInodes([resolved.path]);
2320
2471
  // The directories the recursive walk used to enumerate are exactly the
2321
2472
  // directory inodes of the subtree, and enumerating one needed read
@@ -2367,6 +2518,7 @@ export class SqliteVFS {
2367
2518
  const inode = this.inodes.get(oldPath);
2368
2519
  if (!inode)
2369
2520
  throw new Error("ENOENT: " + oldPath);
2521
+ this.checkStickyParentMutation(oldPath, inode, cred);
2370
2522
  // W-3 (WASI filesystem WASI): if newPath already exists, unlink it first so the
2371
2523
  // SQL UPDATE doesn't conflict on inodes.path uniqueness. POSIX rename(2)
2372
2524
  // overwrites the destination atomically; clang's atomic-write pattern
@@ -2376,6 +2528,7 @@ export class SqliteVFS {
2376
2528
  // (the latter is rare but POSIX permits it for empty dirs).
2377
2529
  const destInode = this.inodes.get(newPath);
2378
2530
  if (destInode) {
2531
+ this.checkStickyParentMutation(newPath, destInode, cred);
2379
2532
  if (destInode.isDir) {
2380
2533
  // POSIX semantics: rename onto a non-empty dir is an error
2381
2534
  // (ENOTEMPTY); rename onto an empty dir is allowed. We surface
@@ -2590,14 +2743,14 @@ export class SqliteVFS {
2590
2743
  }
2591
2744
  // ── Batch write (npm install fast path) ───────────────────────────────
2592
2745
  normalizeBatchInode(entry, cred) {
2593
- const path = normalizeVfsPath(entry.path);
2746
+ const path = this.storageKey(entry.path, cred);
2594
2747
  const prior = this.inodes.get(path);
2595
2748
  const newUid = cred.uid === 0 ? (entry.uid ?? 1000) : cred.uid;
2596
2749
  const newGid = cred.uid === 0 ? (entry.gid ?? 1000) : cred.gid;
2597
2750
  return {
2598
2751
  ...entry,
2599
2752
  path,
2600
- parentPath: normalizeVfsPath(entry.parentPath),
2753
+ parentPath: this.storageKey(entry.parentPath, cred),
2601
2754
  mode: prior
2602
2755
  ? prior.mode
2603
2756
  : inodeKind(entry) === 'symlink'
@@ -2632,7 +2785,7 @@ export class SqliteVFS {
2632
2785
  }
2633
2786
  };
2634
2787
  for (const path of payload.deletePaths ?? []) {
2635
- const normalized = normalizeVfsPath(path);
2788
+ const normalized = this.storageKey(path, cred);
2636
2789
  const existing = this.checkAccess(normalized, 0, cred, {
2637
2790
  followLeaf: false,
2638
2791
  allowMissingLeaf: true,
@@ -2648,14 +2801,14 @@ export class SqliteVFS {
2648
2801
  checkParent(entry.path);
2649
2802
  }
2650
2803
  for (const chunk of payload.chunks) {
2651
- const path = normalizeVfsPath(chunk.path);
2804
+ const path = this.storageKey(chunk.path, cred);
2652
2805
  if (!pending.has(path))
2653
2806
  this.checkAccess(path, 0o2, cred, { followLeaf: false });
2654
2807
  }
2655
2808
  return {
2656
2809
  ...payload,
2657
2810
  inodes,
2658
- deletePaths: payload.deletePaths?.map(normalizeVfsPath),
2811
+ deletePaths: payload.deletePaths?.map((path) => this.storageKey(path, cred)),
2659
2812
  };
2660
2813
  }
2661
2814
  /**
@@ -3307,18 +3460,30 @@ export class SqliteVFS {
3307
3460
  let orphanScanComplete = true;
3308
3461
  let stagingScanComplete = true;
3309
3462
  if (transactions < maximum) {
3310
- const orphanRows = [...this.sql.exec(`SELECT chunks.content_id
3311
- FROM file_chunks AS chunks
3312
- WHERE NOT EXISTS (
3313
- SELECT 1 FROM content_lifecycle AS lifecycle
3314
- WHERE lifecycle.content_id = chunks.content_id
3315
- )
3316
- AND ${sqlNoInodeContentReference('chunks.content_id')}
3317
- GROUP BY chunks.content_id
3318
- ORDER BY chunks.content_id
3319
- LIMIT ?`, CONTENT_IDS_PER_SQL_EXEC)];
3320
- orphanScanComplete = orphanRows.length < CONTENT_IDS_PER_SQL_EXEC;
3321
- const contentIds = orphanRows.map((row) => String(row.content_id));
3463
+ // The page is selected before the orphan predicates so a scan that finds
3464
+ // nothing still reads only one page, not the whole covering index.
3465
+ const pageRows = [...this.sql.exec(`SELECT page.content_id AS content_id,
3466
+ (NOT EXISTS (
3467
+ SELECT 1 FROM content_lifecycle AS lifecycle
3468
+ WHERE lifecycle.content_id = page.content_id
3469
+ )
3470
+ AND ${sqlNoInodeContentReference('page.content_id')}) AS orphaned
3471
+ FROM (
3472
+ SELECT chunks.content_id
3473
+ FROM file_chunks AS chunks
3474
+ WHERE chunks.content_id > ?
3475
+ GROUP BY chunks.content_id
3476
+ ORDER BY chunks.content_id
3477
+ LIMIT ?
3478
+ ) AS page
3479
+ ORDER BY page.content_id`, this.orphanScanCursor, CONTENT_IDS_PER_SQL_EXEC)];
3480
+ orphanScanComplete = pageRows.length < CONTENT_IDS_PER_SQL_EXEC;
3481
+ this.orphanScanCursor = orphanScanComplete
3482
+ ? ''
3483
+ : String(pageRows[pageRows.length - 1].content_id);
3484
+ const contentIds = pageRows
3485
+ .filter((row) => Number(row.orphaned) === 1)
3486
+ .map((row) => String(row.content_id));
3322
3487
  if (contentIds.length > 0) {
3323
3488
  const candidates = contentIds
3324
3489
  .map((_, index) => index === 0 ? 'SELECT ? AS content_id' : 'SELECT ?')
@@ -3480,7 +3645,7 @@ export class SqliteVFS {
3480
3645
  /** Best-effort process.memoryUsage().heapUsed; 0 in DO contexts. */
3481
3646
  _safeHeapUsed() {
3482
3647
  try {
3483
- const mu = globalThis.process?.memoryUsage?.();
3648
+ const mu = nodeHost.process?.memoryUsage?.();
3484
3649
  return Number(mu?.heapUsed) || 0;
3485
3650
  }
3486
3651
  catch {
@@ -3658,7 +3823,7 @@ export class SqliteVFS {
3658
3823
  // `_addToChildrenIndex` uses Set.add so repeated calls are idempotent;
3659
3824
  // gating it on `prior === undefined` was the bug. Counters remain
3660
3825
  // gated correctly so they don't double-count.
3661
- const __diag = (globalThis.process?.env?.NIMBUS_DIAG_INSTALL_PIPELINE === '1');
3826
+ const __diag = installPipelineDiagEnabled();
3662
3827
  const replacedPaths = new Set();
3663
3828
  for (const entry of plan.inodes) {
3664
3829
  const prior = this.inodes.get(entry.path);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nimbus-sh/core",
3
- "version": "0.4.0",
4
- "description": "Backend-agnostic half of Nimbus \u2014 the durable filesystem, the shell, and the OS/process contracts, over a host-supplied SQL port.",
3
+ "version": "0.6.0",
4
+ "description": "Backend-agnostic half of Nimbus the durable filesystem, the shell, and the OS/process contracts, over a host-supplied SQL port.",
5
5
  "keywords": [
6
6
  "filesystem",
7
7
  "shell",
@@ -55,6 +55,7 @@
55
55
  "typecheck": "tsc --noEmit"
56
56
  },
57
57
  "dependencies": {
58
+ "@nimbus-sh/platform": "^0.1.0",
58
59
  "@renovatebot/pep440": "^5.0.0",
59
60
  "acorn": "^8.15.0",
60
61
  "acorn-walk": "^8.3.5",
@@ -11,7 +11,7 @@
11
11
  * L4 — registry.npmjs.org origin ~100-300 ms cross-region
12
12
  *
13
13
  * Pre-cache metrics support: hit/miss counters lived in
14
- * src/observability/diag-counters.ts but only tracked the RPC-call
14
+ * @nimbus-sh/platform/diag-counters.js but only tracked the RPC-call
15
15
  * boundary, not per-tier. Those flat counters have since been removed.
16
16
  *
17
17
  * This module adds per-tier × per-kind counters. SAME singleton-per-
@@ -229,3 +229,29 @@ export function reset(): void {
229
229
  }
230
230
  _lastResetAt = Date.now();
231
231
  }
232
+
233
+ /**
234
+ * cache-obs-2: fold facet-collected per-tier cache events into the
235
+ * DO-side cache-stats singleton. Called from installer.ts after a
236
+ * batch-facet / resolve-facet returns — mirrors recordR2RaceCounters
237
+ * (the facet collects metrics in its result and the supervisor folds
238
+ * them into the DO isolate).
239
+ *
240
+ * Each event has shape:
241
+ * { kind: 'hit', tier: 'L2'|'L3'|'L4', cacheKind: 'tarball'|'packument'|'asset', bytes: number }
242
+ * { kind: 'miss', tier: ..., cacheKind: ... }
243
+ */
244
+ export type CacheStatEvent =
245
+ | { kind: 'hit'; tier: CacheTier; cacheKind: CacheKind; bytes: number }
246
+ | { kind: 'miss'; tier: CacheTier; cacheKind: CacheKind };
247
+
248
+ export function recordCacheStatEvents(events: readonly CacheStatEvent[] | undefined): void {
249
+ if (!events || events.length === 0) return;
250
+ for (const e of events) {
251
+ if (e.kind === 'hit') {
252
+ recordHit(e.tier, e.cacheKind, e.bytes);
253
+ } else {
254
+ recordMiss(e.tier, e.cacheKind);
255
+ }
256
+ }
257
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * error-text.ts — the text a caught value carries.
3
+ *
4
+ * A `catch` binding is `unknown`: the throw site may have thrown an Error, a
5
+ * string, or a plain object with a `message`, and the shell and runtime paths
6
+ * report all three the same way. This is that report, in one place, so a
7
+ * command's diagnostics do not depend on which of those a facet threw.
8
+ */
9
+
10
+ /**
11
+ * The message a thrown value names, or the value itself when it names none.
12
+ * Empty and other falsy messages fall through to the value, so an
13
+ * `new Error('')` still reports as `Error` rather than as nothing.
14
+ */
15
+ export function errorText(error: unknown): string {
16
+ if (typeof error === 'object' && error !== null && 'message' in error) {
17
+ const { message } = error;
18
+ if (message) return String(message);
19
+ }
20
+ return String(error);
21
+ }
@@ -7,7 +7,7 @@
7
7
  * 1. Install-time supervisor (TS) — `src/npm-resolver.ts` re-exports the
8
8
  * typed functions for tree resolution.
9
9
  *
10
- * 2. NimbusLoaderPool isolates (JS string) — `src/loaders/pre-bundle-preamble.ts`
10
+ * 2. IsolatePool isolates (JS string) — `src/loaders/pre-bundle-preamble.ts`
11
11
  * embeds `getExportsResolverJS()` as part of the pool preamble so the
12
12
  * pre-bundle facet uses identical resolution semantics to the supervisor.
13
13
  *
@@ -37,6 +37,26 @@ export const DEFAULT_ESM_CONDITIONS = ['import', 'module', 'browser', 'default']
37
37
  /** Default conditions for CJS runtime resolution (user-shell node). */
38
38
  export const DEFAULT_CJS_CONDITIONS = ['require', 'node', 'default'];
39
39
 
40
+ /**
41
+ * A `package.json#exports` / `#imports` value: a target path, an ordered list
42
+ * of fallbacks to try in turn, or a map keyed by subpath (`"./client"`) or by
43
+ * condition (`"import"`) whose values are the same shape again. `null` is the
44
+ * spec's "this subpath is not exported" marker, and blocks fallback.
45
+ */
46
+ export type ExportsField =
47
+ | string
48
+ | null
49
+ | ExportsField[]
50
+ | { [key: string]: ExportsField };
51
+
52
+ /** The package.json fields entry-point resolution reads. */
53
+ export interface ResolvablePackageJson {
54
+ exports?: ExportsField;
55
+ imports?: ExportsField;
56
+ main?: string;
57
+ module?: string;
58
+ }
59
+
40
60
  /**
41
61
  * Resolve `package.json#exports` (or `#imports`) per Node spec.
42
62
  *
@@ -46,7 +66,7 @@ export const DEFAULT_CJS_CONDITIONS = ['require', 'node', 'default'];
46
66
  * @returns Relative path target string, or null if not found / forbidden
47
67
  */
48
68
  export function resolveExports(
49
- exportsField: any,
69
+ exportsField: ExportsField | undefined,
50
70
  subpath: string = '.',
51
71
  conditions: string[] = DEFAULT_ESM_CONDITIONS,
52
72
  ): string | null {
@@ -123,7 +143,7 @@ export function resolveExports(
123
143
  * array (Node spec).
124
144
  */
125
145
  function resolveConditionValue(
126
- target: any,
146
+ target: ExportsField | undefined,
127
147
  conditions: string[],
128
148
  ): string | null {
129
149
  if (target === null || target === undefined) return null;
@@ -162,7 +182,7 @@ function resolveConditionValue(
162
182
  * itself (caller probes filesystem with extension-list).
163
183
  */
164
184
  export function resolvePackageEntry(
165
- pkg: { exports?: any; module?: string; main?: string },
185
+ pkg: ResolvablePackageJson,
166
186
  subpath: string = '.',
167
187
  conditions: string[] = DEFAULT_ESM_CONDITIONS,
168
188
  ): string | null {
@@ -23,7 +23,8 @@
23
23
  * equivalent retry loops inside their isolated function bodies.
24
24
  */
25
25
 
26
- import { disposeRpcResource } from './rpc-dispose.js';
26
+ import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
27
+ import { errorText } from './error-text.js';
27
28
 
28
29
  /** Default retry count AFTER the first attempt (3 = up to 4 total attempts). */
29
30
  export const DEFAULT_RETRIES = 3;
@@ -179,7 +180,7 @@ export async function retryableFetch(
179
180
  const delayMs = jittered(BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]);
180
181
  opts?.onRetry?.(attempt + 1, totalRetries, delayMs, `HTTP ${resp.status}`);
181
182
  await new Promise<void>((r) => setTimeout(r, delayMs));
182
- } catch (e: any) {
183
+ } catch (e) {
183
184
  if (timer) clearTimeout(timer);
184
185
  // Network-level failure (fetch rejection, timeout via AbortError,
185
186
  // DNS, connection reset). Retry the same way as a 5xx.
@@ -189,7 +190,8 @@ export async function retryableFetch(
189
190
  throw e;
190
191
  }
191
192
  const delayMs = jittered(BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]);
192
- const reason = e?.name === 'AbortError' ? 'timeout' : (e?.message || String(e));
193
+ const isAbort = typeof e === 'object' && e !== null && 'name' in e && e.name === 'AbortError';
194
+ const reason = isAbort ? 'timeout' : errorText(e);
193
195
  opts?.onRetry?.(attempt + 1, totalRetries, delayMs, reason);
194
196
  await new Promise<void>((r) => setTimeout(r, delayMs));
195
197
  }