@nimbus-sh/core 0.6.0 → 0.7.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 (88) hide show
  1. package/README.md +47 -26
  2. package/dist/_shared/byte-stream.d.ts +5 -4
  3. package/dist/_shared/byte-stream.d.ts.map +1 -1
  4. package/dist/_shared/real-node-imports.d.ts +7 -0
  5. package/dist/_shared/real-node-imports.d.ts.map +1 -1
  6. package/dist/_shared/real-node-imports.js +8 -0
  7. package/dist/_shared/tarball.d.ts +23 -5
  8. package/dist/_shared/tarball.d.ts.map +1 -1
  9. package/dist/_shared/tarball.js +38 -5
  10. package/dist/_shared/vfs-write-ledger.d.ts.map +1 -1
  11. package/dist/_shared/vfs-write-ledger.js +22 -16
  12. package/dist/runtime/comment-strip.d.ts +0 -35
  13. package/dist/runtime/comment-strip.d.ts.map +1 -1
  14. package/dist/runtime/comment-strip.js +48 -20
  15. package/dist/runtime/esbuild-service.d.ts +38 -42
  16. package/dist/runtime/esbuild-service.d.ts.map +1 -1
  17. package/dist/runtime/esbuild-service.js +494 -197
  18. package/dist/runtime/javascript-ast.d.ts.map +1 -1
  19. package/dist/runtime/javascript-ast.js +45 -7
  20. package/dist/runtime/opentui-wasm-backend.d.ts.map +1 -1
  21. package/dist/runtime/opentui-wasm-backend.js +18 -16
  22. package/dist/runtime/port-registry.d.ts +4 -2
  23. package/dist/runtime/port-registry.d.ts.map +1 -1
  24. package/dist/runtime/port-registry.js +60 -8
  25. package/dist/shell/stdin-adapter.d.ts +11 -0
  26. package/dist/shell/stdin-adapter.d.ts.map +1 -0
  27. package/dist/shell/stdin-adapter.js +59 -0
  28. package/dist/shell/unix-commands.d.ts +2 -1
  29. package/dist/shell/unix-commands.d.ts.map +1 -1
  30. package/dist/shell/unix-commands.js +1078 -50
  31. package/dist/substrate/lifo/commands/io/dd.js +1 -1
  32. package/dist/substrate/lifo/commands/net/curl.d.ts.map +1 -1
  33. package/dist/substrate/lifo/commands/net/curl.js +270 -54
  34. package/dist/substrate/lifo/commands/system/npm.d.ts +18 -1
  35. package/dist/substrate/lifo/commands/system/npm.d.ts.map +1 -1
  36. package/dist/substrate/lifo/commands/system/npm.js +26 -43
  37. package/dist/substrate/lifo/commands/text/head.d.ts.map +1 -1
  38. package/dist/substrate/lifo/commands/text/head.js +2 -1
  39. package/dist/substrate/lifo/commands/text/sed.d.ts.map +1 -1
  40. package/dist/substrate/lifo/commands/text/sed.js +499 -117
  41. package/dist/substrate/lifo/commands/types.d.ts +8 -4
  42. package/dist/substrate/lifo/commands/types.d.ts.map +1 -1
  43. package/dist/substrate/lifo/node-compat/zlib.d.ts +37 -18
  44. package/dist/substrate/lifo/node-compat/zlib.d.ts.map +1 -1
  45. package/dist/substrate/lifo/node-compat/zlib.js +122 -23
  46. package/dist/substrate/lifo/shell/interpreter.d.ts +9 -1
  47. package/dist/substrate/lifo/shell/interpreter.d.ts.map +1 -1
  48. package/dist/substrate/lifo/shell/interpreter.js +79 -29
  49. package/dist/substrate/lifo/shell/pipe.d.ts +16 -0
  50. package/dist/substrate/lifo/shell/pipe.d.ts.map +1 -1
  51. package/dist/substrate/lifo/shell/pipe.js +62 -18
  52. package/dist/substrate/lifo/shell/terminal-stdin.d.ts +20 -3
  53. package/dist/substrate/lifo/shell/terminal-stdin.d.ts.map +1 -1
  54. package/dist/substrate/lifo/shell/terminal-stdin.js +60 -24
  55. package/dist/vfs/sqlite-vfs.d.ts +19 -0
  56. package/dist/vfs/sqlite-vfs.d.ts.map +1 -1
  57. package/dist/vfs/sqlite-vfs.js +106 -23
  58. package/dist/workspace/nimbus-workspace.d.ts +15 -0
  59. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  60. package/dist/workspace/nimbus-workspace.js +20 -3
  61. package/dist/workspace/supervisor-op.d.ts +23 -0
  62. package/dist/workspace/supervisor-op.d.ts.map +1 -0
  63. package/dist/workspace/supervisor-op.js +92 -0
  64. package/package.json +2 -2
  65. package/src/_shared/byte-stream.ts +5 -4
  66. package/src/_shared/real-node-imports.ts +8 -0
  67. package/src/_shared/tarball.ts +56 -5
  68. package/src/_shared/vfs-write-ledger.ts +22 -16
  69. package/src/runtime/comment-strip.ts +42 -23
  70. package/src/runtime/esbuild-service.ts +566 -217
  71. package/src/runtime/javascript-ast.ts +40 -9
  72. package/src/runtime/opentui-wasm-backend.ts +19 -17
  73. package/src/runtime/port-registry.ts +66 -8
  74. package/src/shell/stdin-adapter.ts +58 -0
  75. package/src/shell/unix-commands.ts +1054 -50
  76. package/src/substrate/lifo/commands/io/dd.ts +2 -2
  77. package/src/substrate/lifo/commands/net/curl.ts +310 -58
  78. package/src/substrate/lifo/commands/system/npm.ts +58 -38
  79. package/src/substrate/lifo/commands/text/head.ts +2 -1
  80. package/src/substrate/lifo/commands/text/sed.ts +519 -109
  81. package/src/substrate/lifo/commands/types.ts +8 -5
  82. package/src/substrate/lifo/node-compat/zlib.ts +155 -26
  83. package/src/substrate/lifo/shell/interpreter.ts +74 -27
  84. package/src/substrate/lifo/shell/pipe.ts +65 -24
  85. package/src/substrate/lifo/shell/terminal-stdin.ts +66 -30
  86. package/src/vfs/sqlite-vfs.ts +104 -23
  87. package/src/workspace/nimbus-workspace.ts +26 -2
  88. package/src/workspace/supervisor-op.ts +117 -0
@@ -1,14 +1,18 @@
1
+ import { encode } from '../utils/encoding.js';
1
2
  import type { TerminalInputStream } from '../commands/types.js';
2
3
 
3
4
  /**
4
- * A bridge between terminal keyboard input and command stdin.
5
- * The Shell feeds lines in via feed(), commands consume via read()/readAll().
5
+ * A bridge between terminal keyboard input and command stdin. The Shell
6
+ * feeds lines in via feed(); commands consume bytes or text. Bytes are the
7
+ * storage format, so a byte read always makes progress even when maxLength
8
+ * splits a multi-byte code point — `dd bs=1` over `é` yields c3, then a9.
6
9
  */
7
10
  export class TerminalStdin implements TerminalInputStream {
8
- private buffer: string[] = [];
11
+ private buffer: Uint8Array[] = [];
9
12
  private closed = false;
10
- private resolvers: Array<(value: string | null) => void> = [];
13
+ private resolvers: Array<(value: Uint8Array | null) => void> = [];
11
14
  private _rawMode = false;
15
+ private decoder = new TextDecoder('utf-8');
12
16
 
13
17
  /** True when a command has called read() and is waiting for input. */
14
18
  get isWaiting(): boolean {
@@ -26,16 +30,13 @@ export class TerminalStdin implements TerminalInputStream {
26
30
 
27
31
  /** Shell calls this on Enter (with line + '\n'). */
28
32
  feed(text: string): void {
29
- if (this.closed) return;
30
-
31
- this.deliver(text, 'back');
33
+ if (this.closed || text === '') return;
34
+ this.deliver(encode(text), 'back');
32
35
  }
33
36
 
34
37
  /** Shell calls this on Ctrl+D to signal EOF. */
35
38
  close(): void {
36
- if (this.closed) return;
37
39
  this.closed = true;
38
-
39
40
  while (this.resolvers.length > 0) {
40
41
  const resolve = this.resolvers.shift()!;
41
42
  resolve(null);
@@ -44,15 +45,15 @@ export class TerminalStdin implements TerminalInputStream {
44
45
 
45
46
  /** Commands consume input. Returns null on EOF. */
46
47
  async read(): Promise<string | null> {
47
- if (this.buffer.length > 0) {
48
- return this.buffer.shift()!;
49
- }
50
- if (this.closed) {
51
- return null;
48
+ while (true) {
49
+ const bytes = await this.pull();
50
+ if (bytes === null) {
51
+ const tail = this.decoder.decode();
52
+ return tail.length > 0 ? tail : null;
53
+ }
54
+ const text = this.decoder.decode(bytes, { stream: true });
55
+ if (text.length > 0) return text;
52
56
  }
53
- return new Promise<string | null>((resolve) => {
54
- this.resolvers.push(resolve);
55
- });
56
57
  }
57
58
 
58
59
  async readLine(): Promise<string | null> {
@@ -64,7 +65,7 @@ export class TerminalStdin implements TerminalInputStream {
64
65
  const newline = chunk.indexOf('\n');
65
66
  if (newline >= 0) {
66
67
  const rest = chunk.slice(newline + 1);
67
- if (rest.length > 0) this.deliver(rest, 'front');
68
+ if (rest.length > 0) this.deliverBackText(rest);
68
69
  return line + chunk.slice(0, newline);
69
70
  }
70
71
 
@@ -72,15 +73,21 @@ export class TerminalStdin implements TerminalInputStream {
72
73
  }
73
74
  }
74
75
 
75
- async readBytes(maxLength: number): Promise<string | null> {
76
- if (maxLength <= 0) return '';
77
-
78
- const chunk = await this.read();
76
+ /**
77
+ * Bounded byte read: returns whatever the user has already typed, capped
78
+ * at maxLength. maxLength bounds the result, it is never a fill target —
79
+ * a command reading bytes must not stall until maxLength arrive. A larger
80
+ * queued chunk keeps only its first maxLength bytes; the remainder stays
81
+ * queued, so `dd bs=1` over `é` still yields c3, then a9.
82
+ */
83
+ async readBytes(maxLength: number): Promise<Uint8Array | null> {
84
+ if (maxLength <= 0) return new Uint8Array(0);
85
+
86
+ const chunk = await this.pull();
79
87
  if (chunk === null) return null;
80
88
  if (chunk.length <= maxLength) return chunk;
81
-
82
- this.deliver(chunk.slice(maxLength), 'front');
83
- return chunk.slice(0, maxLength);
89
+ this.buffer.unshift(chunk.subarray(maxLength));
90
+ return chunk.subarray(0, maxLength);
84
91
  }
85
92
 
86
93
  /** Read all remaining input until EOF, joined together. */
@@ -94,16 +101,45 @@ export class TerminalStdin implements TerminalInputStream {
94
101
  return parts.join('');
95
102
  }
96
103
 
97
- private deliver(text: string, position: 'front' | 'back'): void {
98
- if (text.length === 0) return;
104
+ /**
105
+ * Text snapshot of everything queued but not yet consumed. The shell's
106
+ * wrap layer uses this for commands that want drained terminal input.
107
+ */
108
+ drainBuffered(): string {
109
+ let text = '';
110
+ while (this.buffer.length > 0) {
111
+ text += this.decoder.decode(this.buffer.shift()!, { stream: true });
112
+ }
113
+ text += this.decoder.decode();
114
+ return text;
115
+ }
116
+
117
+ private pull(): Promise<Uint8Array | null> {
118
+ if (this.buffer.length > 0) {
119
+ return Promise.resolve(this.buffer.shift() ?? null);
120
+ }
121
+ if (this.closed) {
122
+ return Promise.resolve(null);
123
+ }
124
+ return new Promise<Uint8Array | null>((resolve) => {
125
+ this.resolvers.push(resolve);
126
+ });
127
+ }
128
+
129
+ private deliver(bytes: Uint8Array, position: 'front' | 'back'): void {
130
+ if (bytes.length === 0) return;
99
131
 
100
132
  if (this.resolvers.length > 0) {
101
133
  const resolve = this.resolvers.shift()!;
102
- resolve(text);
134
+ resolve(bytes);
103
135
  return;
104
136
  }
105
137
 
106
- if (position === 'front') this.buffer.unshift(text);
107
- else this.buffer.push(text);
138
+ if (position === 'front') this.buffer.unshift(bytes);
139
+ else this.buffer.push(bytes);
140
+ }
141
+
142
+ private deliverBackText(text: string): void {
143
+ this.deliver(encode(text), 'front');
108
144
  }
109
145
  }
@@ -614,10 +614,9 @@ export class SqliteVFS {
614
614
 
615
615
  // ── Running counters for O(1) getStats() (B3 / AUDIT M10 / M-S8) ──
616
616
  // Replaces the triple scan of this.inodes on every /api/stats poll.
617
- // Bootstrapped in loadInodes(); maintained at every mutator entry:
618
- // mkdir/rmdir/writeFile/unlink/writeBatch (rename is a no-op
619
- // same inode, new path). Invariant: these match a fresh O(N)
620
- // walk of this.inodes. Unit-tested in the A5/B3 runtime tests.
617
+ // Bootstrapped in loadInodes(); maintained at committed publication by
618
+ // mkdir, batch writes/deletes and rename. These match a fresh O(N)
619
+ // walk of this.inodes; rollback reloads them from the durable rows.
621
620
  private _totalFiles = 0;
622
621
  private _totalDirs = 0;
623
622
  private _usedBytes = 0;
@@ -632,6 +631,10 @@ export class SqliteVFS {
632
631
  // so unrelated writes no longer invalidate them. In-memory only — the
633
632
  // clock resets with the DO lifetime, exactly like the caches keyed on it.
634
633
  private _pathRevisions = new Map<string, number>();
634
+ private transactionPublication: {
635
+ paths: Set<string>;
636
+ events: { type: VfsEventType; path: string; oldPath?: string }[];
637
+ } | null = null;
635
638
 
636
639
  // ── Invalidation log (facet cache coherence) ──────────────────────────
637
640
  // A facet's resident set is a cache of this VFS, and it learns what to
@@ -1673,6 +1676,10 @@ export class SqliteVFS {
1673
1676
  * additional coverage, since no facet view keys on a grandparent.
1674
1677
  */
1675
1678
  private bumpRevision(paths: readonly string[]): void {
1679
+ if (this.transactionPublication) {
1680
+ for (const path of paths) this.transactionPublication.paths.add(path);
1681
+ return;
1682
+ }
1676
1683
  const rev = ++this._revision;
1677
1684
  for (const path of paths) {
1678
1685
  let p = normalizeVfsPath(path);
@@ -1884,7 +1891,7 @@ export class SqliteVFS {
1884
1891
  this._addToChildrenIndex(pp, path);
1885
1892
  this._totalDirs++; // B3
1886
1893
  this.bumpRevision([path]);
1887
- this.events.emit('addDir', path);
1894
+ this.emitMutation('addDir', path);
1888
1895
  }
1889
1896
 
1890
1897
  private writeFile(
@@ -2905,11 +2912,11 @@ export class SqliteVFS {
2905
2912
  }
2906
2913
  const atime = atimeMs !== null && Number.isFinite(atimeMs) ? Math.trunc(atimeMs) : this.now();
2907
2914
  const mtime = mtimeMs !== null && Number.isFinite(mtimeMs) ? Math.trunc(mtimeMs) : this.now();
2915
+ this.sql.exec("UPDATE inodes SET atime = ?, mtime = ? WHERE path = ?", atime, mtime, inode.path);
2908
2916
  inode.atime = atime;
2909
2917
  inode.mtime = mtime;
2910
- this.sql.exec("UPDATE inodes SET atime = ?, mtime = ? WHERE path = ?", atime, mtime, inode.path);
2911
2918
  this.bumpRevision([inode.path]);
2912
- this.events.emit('change', inode.path);
2919
+ this.emitMutation('change', inode.path);
2913
2920
  }
2914
2921
 
2915
2922
  /**
@@ -2949,10 +2956,10 @@ export class SqliteVFS {
2949
2956
  }
2950
2957
  this.assertMutationsAllowed([inode.path]);
2951
2958
  const full = inodeTypeBits(inode.kind) | (mode & 0o7777);
2952
- inode.mode = full;
2953
2959
  this.sql.exec("UPDATE inodes SET mode = ? WHERE path = ?", full, inode.path);
2960
+ inode.mode = full;
2954
2961
  this.bumpRevision([inode.path]);
2955
- this.events.emit('change', inode.path);
2962
+ this.emitMutation('change', inode.path);
2956
2963
  }
2957
2964
 
2958
2965
  private chown(
@@ -2975,18 +2982,21 @@ export class SqliteVFS {
2975
2982
  }
2976
2983
  if (gid !== null && gid === inode.gid && cred.uid !== 0 && !owner) throw vfsError('EPERM', resolved.path);
2977
2984
  this.assertMutationsAllowed([inode.path]);
2978
- inode.uid = uid ?? inode.uid;
2979
- inode.gid = gid ?? inode.gid;
2980
- if (cred.uid !== 0) inode.mode &= ~0o6000;
2985
+ const nextUid = uid ?? inode.uid;
2986
+ const nextGid = gid ?? inode.gid;
2987
+ const nextMode = cred.uid === 0 ? inode.mode : inode.mode & ~0o6000;
2981
2988
  this.sql.exec(
2982
2989
  'UPDATE inodes SET uid = ?, gid = ?, mode = ? WHERE path = ?',
2983
- inode.uid,
2984
- inode.gid,
2985
- inode.mode,
2990
+ nextUid,
2991
+ nextGid,
2992
+ nextMode,
2986
2993
  inode.path,
2987
2994
  );
2995
+ inode.uid = nextUid;
2996
+ inode.gid = nextGid;
2997
+ inode.mode = nextMode;
2988
2998
  this.bumpRevision([inode.path]);
2989
- this.events.emit('change', inode.path);
2999
+ this.emitMutation('change', inode.path);
2990
3000
  }
2991
3001
 
2992
3002
  /**
@@ -3199,15 +3209,19 @@ export class SqliteVFS {
3199
3209
 
3200
3210
  private rename(oldPath: string, newPath: string, cred: VfsCred): void {
3201
3211
  this.assertMutationsAllowed([oldPath, newPath]);
3202
- this.checkAccess(oldPath, 0, cred, { followLeaf: false });
3212
+ // Resolve private /tmp names to storage keys before reading or mutating.
3213
+ const source = this.checkAccess(oldPath, 0, cred, { followLeaf: false });
3214
+ const inode = source.inode;
3215
+ if (!inode) throw vfsError('ENOENT', oldPath);
3216
+ const target = this.checkAccess(newPath, 0, cred, { followLeaf: false, allowMissingLeaf: true });
3217
+ oldPath = source.path;
3218
+ newPath = target.path;
3203
3219
  this.checkParentAccess(oldPath, cred);
3204
3220
  this.checkParentAccess(newPath, cred);
3205
3221
  if (oldPath === newPath) return;
3206
3222
  if (newPath.startsWith(`${oldPath}/`)) {
3207
3223
  throw new Error(`EINVAL: cannot move ${oldPath} inside itself`);
3208
3224
  }
3209
- const inode = this.inodes.get(oldPath);
3210
- if (!inode) throw new Error("ENOENT: " + oldPath);
3211
3225
  this.checkStickyParentMutation(oldPath, inode, cred);
3212
3226
 
3213
3227
  // W-3 (WASI filesystem WASI): if newPath already exists, unlink it first so the
@@ -3217,7 +3231,7 @@ export class SqliteVFS {
3217
3231
  // a 2nd `make` after a prior successful build throws on UPDATE
3218
3232
  // failure. Pre-unlink covers both file-over-file and file-over-dir
3219
3233
  // (the latter is rare but POSIX permits it for empty dirs).
3220
- const destInode = this.inodes.get(newPath);
3234
+ const destInode = target.inode;
3221
3235
  if (destInode) {
3222
3236
  this.checkStickyParentMutation(newPath, destInode, cred);
3223
3237
  if (destInode.isDir) {
@@ -3381,7 +3395,7 @@ export class SqliteVFS {
3381
3395
  }
3382
3396
 
3383
3397
  this.bumpRevision([...touchedPaths]);
3384
- this.events.emit('rename', newPath, oldPath);
3398
+ this.emitMutation('rename', newPath, oldPath);
3385
3399
  this.runContentMaintenanceSafely(1);
3386
3400
  }
3387
3401
 
@@ -4080,6 +4094,72 @@ export class SqliteVFS {
4080
4094
  return this.errorMessage(error).toUpperCase().includes('SQLITE_NOMEM');
4081
4095
  }
4082
4096
 
4097
+ /**
4098
+ * Participate in an embedder's synchronous transaction: VFS writes and SQL
4099
+ * issued by callback commit together; on rollback the in-memory mirror
4100
+ * returns to the committed state before the error is rethrown with cause.
4101
+ *
4102
+ * This method MUST own the outermost transaction on this VFS's SQL host;
4103
+ * use it instead of wrapping VFS calls in storage.transactionSync. Do not
4104
+ * nest it, return a Promise/thenable, or start asynchronous work inside it.
4105
+ * The callback may read its writes. Revisions and events publish only on
4106
+ * commit, once for the combined mutation. Existing credential checks apply.
4107
+ *
4108
+ * Inodes are an always-resident cache (absence means ENOENT), so rollback
4109
+ * discards cached chunks and rebuilds metadata/children/counters from SQL.
4110
+ * No inode snapshot or undo log is retained. Recovery failure is surfaced
4111
+ * with both errors; the embedder must discard this VFS in that case.
4112
+ */
4113
+ withTransaction<T>(callback: () => T): T {
4114
+ if (this.transactionPublication !== null) {
4115
+ throw new Error('[sqlite-vfs] nested embedder transactions are not supported');
4116
+ }
4117
+ const storage = this.ctx.storage;
4118
+ if (!storage) throw new Error('[sqlite-vfs] atomic storage operation requires transactionSync');
4119
+ const publication = {
4120
+ paths: new Set<string>(),
4121
+ events: new Array<{ type: VfsEventType; path: string; oldPath?: string }>(),
4122
+ };
4123
+ const maintenancePending = this.maintenancePending;
4124
+ this.transactionPublication = publication;
4125
+ let result: T;
4126
+ try {
4127
+ result = storage.transactionSync(() => {
4128
+ const value = callback();
4129
+ if (value !== null && (typeof value === 'object' || typeof value === 'function')
4130
+ && 'then' in value && typeof value.then === 'function') {
4131
+ throw new TypeError('[sqlite-vfs] transaction callback must be synchronous');
4132
+ }
4133
+ return value;
4134
+ });
4135
+ } catch (error) {
4136
+ this.evictAll();
4137
+ this.maintenancePending = maintenancePending;
4138
+ try {
4139
+ this.loadInodes();
4140
+ } catch (reloadError) {
4141
+ throw new AggregateError([error, reloadError], '[sqlite-vfs] transaction rollback reload failed', { cause: error });
4142
+ }
4143
+ throw new Error('[sqlite-vfs] embedder transaction rolled back', { cause: error });
4144
+ } finally {
4145
+ this.transactionPublication = null;
4146
+ }
4147
+ if (publication.paths.size > 0) this.bumpRevision([...publication.paths]);
4148
+ for (const event of publication.events) {
4149
+ this.events.emit(event.type, event.path, event.oldPath);
4150
+ }
4151
+ this.runContentMaintenanceSafely(1);
4152
+ return result;
4153
+ }
4154
+
4155
+ private emitMutation(type: VfsEventType, path: string, oldPath?: string): void {
4156
+ if (this.transactionPublication) {
4157
+ this.transactionPublication.events.push({ type, path, oldPath });
4158
+ } else {
4159
+ this.events.emit(type, path, oldPath);
4160
+ }
4161
+ }
4162
+
4083
4163
  private transactionSync(callback: () => void): void {
4084
4164
  if (!this.ctx?.storage?.transactionSync) {
4085
4165
  throw new Error('[sqlite-vfs] atomic storage operation requires transactionSync');
@@ -4448,6 +4528,7 @@ export class SqliteVFS {
4448
4528
  }
4449
4529
 
4450
4530
  private runContentMaintenanceSafely(maxTransactions: number, force = false): void {
4531
+ if (this.transactionPublication) return;
4451
4532
  if (!force && !this.maintenancePending) return;
4452
4533
  const startedAt = performance.now();
4453
4534
  try {
@@ -4773,10 +4854,10 @@ export class SqliteVFS {
4773
4854
 
4774
4855
  // 5. Events observe the already-published metadata and revision.
4775
4856
  for (const inode of deletedInodes) {
4776
- this.events.emit(inode.isDir ? 'unlinkDir' : 'unlink', inode.path);
4857
+ this.emitMutation(inode.isDir ? 'unlinkDir' : 'unlink', inode.path);
4777
4858
  }
4778
4859
  for (const entry of plan.inodes) {
4779
- this.events.emit(entry.isDir ? 'addDir' : replacedPaths.has(entry.path) ? 'change' : 'add', entry.path);
4860
+ this.emitMutation(entry.isDir ? 'addDir' : replacedPaths.has(entry.path) ? 'change' : 'add', entry.path);
4780
4861
  }
4781
4862
 
4782
4863
  this.recordDuration(this._postCommitDuration, performance.now() - postCommitStartedAt);
@@ -48,6 +48,8 @@ import { seedRuntimePackage, type RuntimePackage } from '../runtime/runtime-pack
48
48
  import type { EsbuildService } from '../runtime/esbuild-service.js';
49
49
  import { registerUnixCommands } from '../shell/unix-commands.js';
50
50
  import { installPathExecResolver } from '../shell/exec-dispatch.js';
51
+ import { adoptCtxExports, composeFabric, type CtxExports, type FabricComposition } from '@nimbus-sh/platform/composition.js';
52
+ import { createSupervisorOpHandler, type SupervisorOpEnvelope, type SupervisorOpHandler } from './supervisor-op.js';
51
53
 
52
54
  export interface NimbusWorkspaceOptions {
53
55
  /** The host's SQLite. In a Durable Object: `ctx.storage.sql`. */
@@ -126,6 +128,15 @@ export interface NimbusWorkspaceOptions {
126
128
  * carry REPLs and a resident-process substrate this cannot reach.
127
129
  */
128
130
  readonly facets?: FacetHost;
131
+ /** Isolate-wide fabric composition, including the hosting namespace. */
132
+ readonly fabric?: FabricComposition;
133
+ /** Explicit exports override the bag on the transaction host. */
134
+ readonly ctxExports?: CtxExports;
135
+ /** The process table that allocated supervisor-binding pids. */
136
+ readonly processes?: SessionProcessSupervisor;
137
+ readonly processOutput?: (stream: 'stdout' | 'stderr', pid: number, data: string) => void;
138
+ /** Host operations, including overrides for host-specific accounting. */
139
+ readonly supervisorOps?: Readonly<Record<string, SupervisorOpHandler>>;
129
140
  }
130
141
 
131
142
  /**
@@ -168,6 +179,7 @@ export class NimbusWorkspace {
168
179
  registry: CommandRegistry,
169
180
  env: Record<string, string>,
170
181
  private readonly sql: SqlDatabase,
182
+ private readonly supervisorOps: (envelope: SupervisorOpEnvelope) => Promise<unknown>,
171
183
  ) {
172
184
  this.vfs = vfs;
173
185
  this.kernel = kernel;
@@ -182,6 +194,10 @@ export class NimbusWorkspace {
182
194
  }
183
195
 
184
196
  static async create(options: NimbusWorkspaceOptions): Promise<NimbusWorkspace> {
197
+ if (options.fabric) composeFabric(options.fabric);
198
+ const exports = options.ctxExports
199
+ ?? (options.transactions as (TransactionHost & { exports?: CtxExports }) | undefined)?.exports;
200
+ if (exports) adoptCtxExports(exports);
185
201
  const vfs = options.vfs ?? openFilesystem(options);
186
202
  const mounts = options.mounts ?? DEFAULT_MOUNT_POINTS;
187
203
  seedBaseFilesystem(vfs, mounts);
@@ -234,13 +250,21 @@ export class NimbusWorkspace {
234
250
  });
235
251
  }
236
252
 
237
- return new NimbusWorkspace(vfs, kernel, shell, registry, env, options.sql);
253
+ const supervisorOps = createSupervisorOpHandler({
254
+ vfs, processes: options.processes, output: options.processOutput, extend: options.supervisorOps,
255
+ });
256
+ return new NimbusWorkspace(vfs, kernel, shell, registry, env, options.sql, supervisorOps);
238
257
  }
239
258
 
240
259
  exec(command: string, options?: RunOptions): Promise<CommandResult> {
241
260
  return this.commands.run(command, options);
242
261
  }
243
262
 
263
+ /** The hosting object forwards its supervisorOp RPC to this method. */
264
+ supervisorOp(envelope: SupervisorOpEnvelope): Promise<unknown> {
265
+ return this.supervisorOps(envelope);
266
+ }
267
+
244
268
  /**
245
269
  * Apply the login files, and begin reading the terminal when there is one.
246
270
  *
@@ -383,7 +407,7 @@ async function registerWasmRuntimes(deps: {
383
407
  getEsbuild: () => {
384
408
  if (!esbuild) {
385
409
  esbuild = import('../runtime/esbuild-service.js')
386
- .then((module) => new module.EsbuildService(deps.vfs));
410
+ .then((module) => new module.EsbuildService(deps.vfs.as(CRED_KERNEL)));
387
411
  }
388
412
  return esbuild;
389
413
  },
@@ -0,0 +1,117 @@
1
+ import type { SqliteVFS } from '../vfs/sqlite-vfs.js';
2
+ import { CRED_SESSION_USER, type VfsCred } from '../runtime/os-contracts.js';
3
+ import { SqliteRuntimeFsBridge } from '../runtime/sqlite-runtime-fs-bridge.js';
4
+ import { getSymlinkRegistry } from '../vfs/symlink-registry.js';
5
+ import type { SessionProcessSupervisor } from '../runtime/session-process-supervisor.js';
6
+
7
+ /** Identity comes from the supervisor binding, never from facet arguments. */
8
+ export interface SupervisorOpEnvelope {
9
+ readonly op: string;
10
+ readonly args?: readonly unknown[];
11
+ readonly pid?: number;
12
+ readonly writerId?: string;
13
+ readonly mutationOwner?: string;
14
+ readonly stream?: ReadableStream<Uint8Array>;
15
+ }
16
+
17
+ export type SupervisorOpHandler = (envelope: SupervisorOpEnvelope) => unknown;
18
+
19
+ export interface SupervisorOpDeps {
20
+ readonly vfs: SqliteVFS;
21
+ /** Absent a process table, operations use the unprivileged session user. */
22
+ readonly processes?: SessionProcessSupervisor;
23
+ readonly output?: (stream: 'stdout' | 'stderr', pid: number, data: string) => void;
24
+ /** Host handlers override defaults, for example to account for stream drains. */
25
+ readonly extend?: Readonly<Record<string, SupervisorOpHandler>>;
26
+ }
27
+
28
+ function stringArg(envelope: SupervisorOpEnvelope, index: number): string {
29
+ const value = envelope.args?.[index];
30
+ if (typeof value !== 'string') {
31
+ throw new Error(`supervisor op ${envelope.op}: argument ${index} must be a string`);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function numberArg(envelope: SupervisorOpEnvelope, index: number): number {
37
+ const value = envelope.args?.[index];
38
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
39
+ throw new Error(`supervisor op ${envelope.op}: argument ${index} must be a number`);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ function contentArg(envelope: SupervisorOpEnvelope, index: number): string | Uint8Array {
45
+ const value = envelope.args?.[index];
46
+ if (typeof value === 'string' || value instanceof Uint8Array) return value;
47
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
48
+ throw new Error(`supervisor op ${envelope.op}: argument ${index} must be bytes or text`);
49
+ }
50
+
51
+ function credFor(deps: SupervisorOpDeps, pid: number | undefined): VfsCred {
52
+ if (pid === undefined) return CRED_SESSION_USER;
53
+ if (!Number.isInteger(pid) || pid <= 0) {
54
+ throw new Error('supervisor op: filesystem operation requires a valid process pid');
55
+ }
56
+ return deps.processes ? deps.processes.cred(pid) : CRED_SESSION_USER;
57
+ }
58
+
59
+ /** One dispatch method lets any host serve its workspace to process facets. */
60
+ export function createSupervisorOpHandler(
61
+ deps: SupervisorOpDeps,
62
+ ): (envelope: SupervisorOpEnvelope) => Promise<unknown> {
63
+ const bridges = new Map<number, SqliteRuntimeFsBridge>();
64
+ const bridgeFor = (pid: number | undefined): SqliteRuntimeFsBridge => {
65
+ const key = pid ?? 0;
66
+ const credentialed = deps.vfs.as(credFor(deps, pid));
67
+ const held = bridges.get(key);
68
+ if (held) {
69
+ held.updateCredential(credentialed);
70
+ return held;
71
+ }
72
+ const built = new SqliteRuntimeFsBridge(credentialed, deps.vfs);
73
+ bridges.set(key, built);
74
+ return built;
75
+ };
76
+ const ops: Readonly<Record<string, SupervisorOpHandler>> = {
77
+ readFile: async (e) => {
78
+ const bytes = await bridgeFor(e.pid).readFile(stringArg(e, 0));
79
+ return bytes === null ? null : new TextDecoder().decode(bytes);
80
+ },
81
+ readFileBytes: (e) => bridgeFor(e.pid).readFile(stringArg(e, 0)),
82
+ stat: (e) => bridgeFor(e.pid).stat(stringArg(e, 0)),
83
+ lstat: (e) => bridgeFor(e.pid).stat(stringArg(e, 0), { followSymlinks: false }),
84
+ exists: async (e) => (await bridgeFor(e.pid).stat(stringArg(e, 0))) !== null,
85
+ readdir: (e) => bridgeFor(e.pid).readdir(stringArg(e, 0)),
86
+ readlink: (e) => bridgeFor(e.pid).readlink(stringArg(e, 0)),
87
+ fsReadRange: (e) => bridgeFor(e.pid).readRange(stringArg(e, 0), numberArg(e, 1), numberArg(e, 2)),
88
+ fsReadRangeUncached: (e) => bridgeFor(e.pid).readRange(stringArg(e, 0), numberArg(e, 1), numberArg(e, 2), { cached: false }),
89
+ fsRevision: (e) => bridgeFor(e.pid).revision(e.args?.[0] === undefined ? undefined : stringArg(e, 0)),
90
+ hasLegacySymlinkUnder: (e) => getSymlinkRegistry(deps.vfs).hasAtOrBelow(stringArg(e, 0)),
91
+ writeFile: (e) => bridgeFor(e.pid).writeFile(stringArg(e, 0), contentArg(e, 1)),
92
+ mkdir: (e) => bridgeFor(e.pid).mkdir(stringArg(e, 0), { recursive: true }),
93
+ rmdir: (e) => bridgeFor(e.pid).rmdir(stringArg(e, 0)),
94
+ unlink: (e) => bridgeFor(e.pid).unlink(stringArg(e, 0)),
95
+ rename: (e) => bridgeFor(e.pid).rename(stringArg(e, 0), stringArg(e, 1)),
96
+ symlink: (e) => bridgeFor(e.pid).symlink(stringArg(e, 0), stringArg(e, 1)),
97
+ chmod: (e) => bridgeFor(e.pid).chmod(stringArg(e, 0), numberArg(e, 1)),
98
+ utimes: (e) => bridgeFor(e.pid).utimes(stringArg(e, 0), numberArg(e, 1), numberArg(e, 2)),
99
+ fsTruncate: (e) => bridgeFor(e.pid).truncate(stringArg(e, 0), numberArg(e, 1)),
100
+ writeBatchStream: (e) => {
101
+ if (!e.stream) throw new Error('supervisor op writeBatchStream: no stream');
102
+ return deps.vfs.as(credFor(deps, e.pid)).writeStream(e.stream, { mutationOwner: e.mutationOwner });
103
+ },
104
+ stdout: (e) => { deps.output?.('stdout', e.pid ?? 0, stringArg(e, 0)); },
105
+ stderr: (e) => { deps.output?.('stderr', e.pid ?? 0, stringArg(e, 0)); },
106
+ };
107
+ const extend = deps.extend ?? {};
108
+ return async (envelope) => {
109
+ if (!envelope || typeof envelope.op !== 'string') {
110
+ throw new Error('supervisor op: envelope names no operation');
111
+ }
112
+ const handler = Object.hasOwn(extend, envelope.op) ? extend[envelope.op]
113
+ : Object.hasOwn(ops, envelope.op) ? ops[envelope.op] : undefined;
114
+ if (!handler) throw new Error(`supervisor op: '${envelope.op}' is not served by this host`);
115
+ return handler(envelope);
116
+ };
117
+ }