@nimbus-sh/core 0.2.0 → 0.4.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/_shared/tarball-stream.d.ts +93 -0
  4. package/dist/_shared/tarball-stream.d.ts.map +1 -0
  5. package/dist/_shared/tarball-stream.js +235 -0
  6. package/dist/_shared/tarball.d.ts +17 -0
  7. package/dist/_shared/tarball.d.ts.map +1 -0
  8. package/dist/_shared/tarball.js +39 -0
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/runtime/clang-runner.d.ts +38 -0
  13. package/dist/runtime/clang-runner.d.ts.map +1 -0
  14. package/dist/runtime/clang-runner.js +866 -0
  15. package/dist/runtime/facet-host.d.ts +12 -0
  16. package/dist/runtime/facet-host.d.ts.map +1 -1
  17. package/dist/runtime/local-facet-host.d.ts.map +1 -1
  18. package/dist/runtime/local-facet-host.js +29 -9
  19. package/dist/runtime/ruby-gems.d.ts +30 -0
  20. package/dist/runtime/ruby-gems.d.ts.map +1 -0
  21. package/dist/runtime/ruby-gems.js +636 -0
  22. package/dist/runtime/ruby-runner.d.ts +127 -0
  23. package/dist/runtime/ruby-runner.d.ts.map +1 -0
  24. package/dist/runtime/ruby-runner.js +1357 -0
  25. package/dist/runtime/runtime-package.d.ts +63 -0
  26. package/dist/runtime/runtime-package.d.ts.map +1 -0
  27. package/dist/runtime/runtime-package.js +66 -0
  28. package/dist/runtime/runtime-registry.d.ts +3 -2
  29. package/dist/runtime/runtime-registry.d.ts.map +1 -1
  30. package/dist/runtime/runtime-registry.js +1 -1
  31. package/dist/runtime/session-process-supervisor.d.ts +15 -0
  32. package/dist/runtime/session-process-supervisor.d.ts.map +1 -1
  33. package/dist/runtime/session-process-supervisor.js +30 -0
  34. package/dist/workspace/nimbus-workspace.d.ts +102 -22
  35. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  36. package/dist/workspace/nimbus-workspace.js +197 -52
  37. package/package.json +4 -2
  38. package/src/_shared/tarball-stream.ts +263 -0
  39. package/src/_shared/tarball.ts +46 -0
  40. package/src/index.ts +7 -0
  41. package/src/runtime/clang-runner.ts +924 -0
  42. package/src/runtime/facet-host.ts +12 -0
  43. package/src/runtime/local-facet-host.ts +28 -8
  44. package/src/runtime/ruby-gems.ts +682 -0
  45. package/src/runtime/ruby-runner.ts +1484 -0
  46. package/src/runtime/runtime-package.ts +114 -0
  47. package/src/runtime/runtime-registry.ts +4 -3
  48. package/src/runtime/session-process-supervisor.ts +27 -0
  49. package/src/workspace/nimbus-workspace.ts +268 -63
@@ -0,0 +1,114 @@
1
+ /**
2
+ * runtime-package.ts — a runtime that arrived with the code, rather than over
3
+ * the network.
4
+ *
5
+ * `nimbus install <name>` reads a catalog out of R2 and writes the result into
6
+ * `~/.nimbus/runtimes/<name>/<version>/`. That is the Cloudflare deployment's
7
+ * answer to "where do the bytes come from", and it needs a bucket, a binding
8
+ * and a colo cache. An embedder who ran `npm i @nimbus-sh/runtime-bash` has
9
+ * already answered the same question: the bytes are on disk beside their
10
+ * manifest, fetched and integrity-checked by npm before any of this ran.
11
+ *
12
+ * So this is the second publisher, not the second package manager. It writes
13
+ * the SAME tree at the SAME path from the SAME manifest — `installRoot()`,
14
+ * `parseRuntimeManifest()`, one file per `manifest.files` entry — so
15
+ * `listInstalledManifests` and `rehydrateInstalledRuntimes` cannot tell which
16
+ * one ran, and a workspace behaves identically either way.
17
+ *
18
+ * Trust: R2 is verified against a digest chain rooted in a build-time pin
19
+ * because `caches.default` is shared across tenants and R2 keys are not
20
+ * content-addressed. A runtime package's root of trust is npm's own tarball
21
+ * integrity, which the install already checked; from there the manifest's
22
+ * per-file digests are re-verified here for exactly the reason the R2 path
23
+ * verifies them — these blobs are interpreters, so bytes that reach the
24
+ * filesystem are bytes that execute.
25
+ */
26
+
27
+ import { sha256Hex } from '../_shared/crypto.js';
28
+ import type { CredentialedVfs } from '../vfs/sqlite-vfs.js';
29
+ import { installRoot } from './installed-runtimes.js';
30
+ import {
31
+ parseRuntimeManifest,
32
+ type ManifestFile,
33
+ type RuntimeManifest,
34
+ } from './runtime-manifest.js';
35
+
36
+ /**
37
+ * An installed npm package holding one runtime.
38
+ *
39
+ * The published packages (`@nimbus-sh/runtime-bash`,
40
+ * `@nimbus-sh/runtime-cpython`) are the implementations; each is a manifest,
41
+ * the content-addressed blobs it names, and the eight lines of `node:fs` that
42
+ * read them. The port is here rather than in those packages because the
43
+ * FILESYSTEM is what a runtime is, and this is the half that knows it.
44
+ *
45
+ * `readBlob` takes the whole manifest entry rather than a bare key, mirroring
46
+ * `fetchBlob` in the Cloudflare catalog: a key and the digest that vouches for
47
+ * it never travel as separate arguments, so there is no call in which they can
48
+ * disagree.
49
+ */
50
+ export interface RuntimePackage {
51
+ readonly manifest: RuntimeManifest;
52
+ readBlob(file: ManifestFile): Uint8Array | Promise<Uint8Array>;
53
+ }
54
+
55
+ export interface SeededRuntime {
56
+ readonly name: string;
57
+ readonly version: string;
58
+ /** VFS path of the install root, e.g. `home/user/.nimbus/runtimes/bash/5.2.37`. */
59
+ readonly root: string;
60
+ /** False when the runtime was already installed at `root` and nothing was written. */
61
+ readonly written: boolean;
62
+ }
63
+
64
+ /**
65
+ * Write a runtime package into the filesystem as an install.
66
+ *
67
+ * Idempotent on the same rule the package manager uses: a `manifest.json`
68
+ * already at the install root means the install completed, and the root
69
+ * carries the version, so a package upgrade lands beside its predecessor
70
+ * rather than half over it.
71
+ */
72
+ export async function seedRuntimePackage(
73
+ vfs: CredentialedVfs,
74
+ homeDir: string,
75
+ runtimePackage: RuntimePackage,
76
+ ): Promise<SeededRuntime> {
77
+ const manifest = parseRuntimeManifest(runtimePackage.manifest);
78
+ const root = installRoot(homeDir, manifest.name, manifest.version);
79
+
80
+ if (vfs.exists(`${root}/manifest.json`)) {
81
+ return { name: manifest.name, version: manifest.version, root, written: false };
82
+ }
83
+
84
+ vfs.mkdir(root, { recursive: true });
85
+ for (const file of manifest.files) {
86
+ const target = `${root}/${file.path}`;
87
+ const parent = target.slice(0, target.lastIndexOf('/'));
88
+ if (!vfs.exists(parent)) vfs.mkdir(parent, { recursive: true });
89
+ vfs.writeFile(target, await verifiedBlob(manifest, runtimePackage, file));
90
+ }
91
+ // Last, where the R2 installer writes it first: that one has `--reinstall`
92
+ // to force a redo, and this one has no verb at all, so a manifest sitting
93
+ // beside a half-written tree would report a broken runtime as installed for
94
+ // the life of the filesystem.
95
+ vfs.writeFile(`${root}/manifest.json`, JSON.stringify(manifest, null, 2));
96
+
97
+ return { name: manifest.name, version: manifest.version, root, written: true };
98
+ }
99
+
100
+ async function verifiedBlob(
101
+ manifest: RuntimeManifest,
102
+ runtimePackage: RuntimePackage,
103
+ file: ManifestFile,
104
+ ): Promise<Uint8Array> {
105
+ const bytes = await runtimePackage.readBlob(file);
106
+ const actual = await sha256Hex(bytes);
107
+ if (actual !== file.sha256) {
108
+ throw new Error(
109
+ `${manifest.name}@${manifest.version}: sha256 mismatch for ${file.path} — manifest expects `
110
+ + `${file.sha256}, ${file.content} holds ${actual}`,
111
+ );
112
+ }
113
+ return bytes;
114
+ }
@@ -196,8 +196,9 @@ export function buildRuntimeHandler(
196
196
  ctx0: {
197
197
  vfs: SqliteVFS;
198
198
  /** Lazy esbuild initialiser. Called once per first .ts/.tsx/.jsx
199
- * invocation — the host owns the init lifecycle. */
200
- getEsbuild(): EsbuildService;
199
+ * invocation — the host owns the init lifecycle, including whether
200
+ * the module is loaded eagerly or on this call. */
201
+ getEsbuild(): EsbuildService | Promise<EsbuildService>;
201
202
  registry: ShellRegistry;
202
203
  },
203
204
  ): (ctx: any) => Promise<number> {
@@ -421,7 +422,7 @@ export function buildRuntimeHandler(
421
422
  needsEsmTransform
422
423
  ) {
423
424
  try {
424
- const eb = getEsbuild();
425
+ const eb = await getEsbuild();
425
426
  const loader =
426
427
  scriptExt === '.tsx' ? 'tsx' :
427
428
  scriptExt === '.jsx' ? 'jsx' :
@@ -67,6 +67,8 @@ export class SessionProcessSupervisor {
67
67
  private terminators = new Map<number, () => void>();
68
68
  /** Fires after every appendOutput/markExit once log persistence is wired. */
69
69
  private logActivity: (() => void) | null = null;
70
+ /** Fires once per pid on its first terminal transition; see setOnTerminal. */
71
+ private onTerminalCb: ((pid: number) => void) | null = null;
70
72
 
71
73
  // ── Lifecycle / PID authority ─────────────────────────────────────────
72
74
 
@@ -130,10 +132,33 @@ export class SessionProcessSupervisor {
130
132
  return this.table.setUmask(pid, umask);
131
133
  }
132
134
 
135
+ /**
136
+ * Observe every pid's FIRST transition out of `running`, whichever door it
137
+ * leaves by — exit(), kill(), a facet's self-reported exit, a timeout abort:
138
+ * all of them end here, which is what makes this one callback a complete
139
+ * seam for per-pid durable state (the resident-launch journal) that must be
140
+ * released exactly when the process ends and never before.
141
+ *
142
+ * One slot, owned by the FacetManager. A second subscriber would mean two
143
+ * owners of process-end policy; grow this into a list only when a second
144
+ * genuine owner exists.
145
+ */
146
+ setOnTerminal(cb: (pid: number) => void): void {
147
+ this.onTerminalCb = cb;
148
+ }
149
+
150
+ private fireTerminal(pid: number, wasRunning: boolean): void {
151
+ if (!wasRunning || !this.onTerminalCb) return;
152
+ if (this.table.get(pid)?.state === 'running') return;
153
+ try { this.onTerminalCb(pid); } catch { /* the process is gone regardless */ }
154
+ }
155
+
133
156
  /** Mark a process as exited. First terminal state wins. */
134
157
  exit(pid: number, exitCode: number): void {
158
+ const wasRunning = this.table.get(pid)?.state === 'running';
135
159
  this.table.exit(pid, exitCode);
136
160
  this.terminators.delete(pid);
161
+ this.fireTerminal(pid, wasRunning);
137
162
  }
138
163
 
139
164
  /**
@@ -141,9 +166,11 @@ export class SessionProcessSupervisor {
141
166
  * stdin can't outlive the process.
142
167
  */
143
168
  kill(pid: number): boolean {
169
+ const wasRunning = this.table.get(pid)?.state === 'running';
144
170
  const killed = this.table.kill(pid);
145
171
  this.terminate(pid);
146
172
  this.input.close(pid);
173
+ this.fireTerminal(pid, wasRunning);
147
174
  return killed;
148
175
  }
149
176
 
@@ -3,26 +3,38 @@
3
3
  *
4
4
  * A workspace is a durable filesystem plus a shell over it. It owns no
5
5
  * transport, no session, no socket and no Durable Object: the host supplies
6
- * SQLite and gets back `.fs` and `.exec`. That is what makes it embeddable in
7
- * a Durable Object that is already busy powering something else, and what
8
- * makes it runnable in a plain bun process over `bun:sqlite`.
6
+ * the filesystem and gets back `.fs`, `.exec`, and a command registry to add
7
+ * to. That is what makes it embeddable in a Durable Object that is already
8
+ * busy powering something else, and what makes it runnable in a plain bun
9
+ * process over `bun:sqlite`.
9
10
  *
10
- * The composition here is not new. It is the one `session/init.ts` performs,
11
- * lifted out of the session so there is one recipe rather than one per caller
12
- * — five unit tests were already hand-rolling it, one of them reaching a
13
- * private field to do so. The boot itself still belongs to `Sandbox.create`;
14
- * this adds only what a durable, credentialed filesystem needs on top and
15
- * nothing the sandbox already knows how to do.
11
+ * The composition here is not new. It is the one `session/init.ts` performed
12
+ * inline, lifted out of the session so there is one recipe rather than one per
13
+ * caller the session now reads its kernel, shell and registry off a
14
+ * workspace, and five unit tests were already hand-rolling the same steps.
15
+ *
16
+ * Deliberately not `Sandbox.create`, which is the lifo demo sandbox's boot
17
+ * rather than this one: it registers `systemctl`, `tunnel` and the network
18
+ * command set, boots enabled service units out of `/etc/systemd`, and starts
19
+ * the shell before the host can register a command of its own. A session
20
+ * routed through it would silently acquire all of that.
16
21
  */
17
22
 
18
- import { Sandbox } from '../substrate/lifo/sandbox/Sandbox.js';
23
+ import { Kernel } from '../substrate/lifo/kernel/index.js';
24
+ import { Shell } from '../substrate/lifo/shell/Shell.js';
25
+ import type { ShellCommandIdentity } from '../substrate/lifo/shell/Shell.js';
26
+ import { createDefaultRegistry } from '../substrate/lifo/commands/registry.js';
27
+ import type { CommandRegistry } from '../substrate/lifo/commands/registry.js';
28
+ import { SandboxCommandsImpl } from '../substrate/lifo/sandbox/SandboxCommands.js';
19
29
  import { SandboxFsImpl } from '../substrate/lifo/sandbox/SandboxFs.js';
30
+ import { HeadlessTerminal } from '../substrate/lifo/sandbox/HeadlessTerminal.js';
20
31
  import type { CommandResult, RunOptions, SandboxFs } from '../substrate/lifo/sandbox/types.js';
21
- import type { Kernel } from '../substrate/lifo/kernel/index.js';
22
- import type { Shell } from '../substrate/lifo/shell/Shell.js';
23
32
  import type { ITerminal } from '../substrate/lifo/terminal/ITerminal.js';
24
33
  import { SqliteVFS, SqliteVFSProvider } from '../vfs/sqlite-vfs.js';
25
- import { DEFAULT_MOUNT_POINTS, DEFAULT_PATH } from '../constants.js';
34
+ import {
35
+ DEFAULT_HOME, DEFAULT_HOSTNAME, DEFAULT_MOUNT_POINTS, DEFAULT_PATH,
36
+ DEFAULT_SHELL, DEFAULT_USER, NIMBUS_VERSION,
37
+ } from '../constants.js';
26
38
  import { CRED_KERNEL, CRED_SESSION_USER } from '../runtime/os-contracts.js';
27
39
  import type { SqlDatabase, TransactionHost } from '../runtime/os-contracts.js';
28
40
  import { PID_GEN_STRIDE } from '../runtime/process-table.js';
@@ -32,7 +44,7 @@ import {
32
44
  rehydrateInstalledRuntimesView,
33
45
  type RunnerFactory,
34
46
  } from '../runtime/installed-runtimes.js';
35
- import type { CommandRegistry } from '../substrate/lifo/commands/registry.js';
47
+ import { seedRuntimePackage, type RuntimePackage } from '../runtime/runtime-package.js';
36
48
  import type { EsbuildService } from '../runtime/esbuild-service.js';
37
49
  import { registerUnixCommands } from '../shell/unix-commands.js';
38
50
  import { installPathExecResolver } from '../shell/exec-dispatch.js';
@@ -49,18 +61,55 @@ export interface NimbusWorkspaceOptions {
49
61
  */
50
62
  readonly transactions?: TransactionHost;
51
63
  /**
52
- * Process-id generation. The workspace revokes every append capability at
53
- * or below `generation * 1_000_000` before serving anything, so a value
54
- * that repeats across restarts hands a dead process live write authority.
55
- * Hosts that persist must supply a counter that never repeats.
64
+ * The filesystem already open over `sql`, for a host that has one.
65
+ *
66
+ * A Durable Object does: its installer, its git commands and its RPC
67
+ * surfaces read those rows without a shell in sight, and they hold a
68
+ * SqliteVFS from the first request that needed one. It must hand over THAT
69
+ * one — a second SqliteVFS over the same database is a second cache, and
70
+ * one of the two will serve a stale read. Such a host has also already
71
+ * revoked the previous generation's append writers, which is why that only
72
+ * happens below when the workspace is the one opening the filesystem.
73
+ */
74
+ readonly vfs?: SqliteVFS;
75
+ /**
76
+ * Process-id generation. Two things rest on it: the workspace revokes every
77
+ * append capability at or below `generation * 1_000_000` before serving
78
+ * anything, and the wasm runner allocates pids above it. A value that
79
+ * repeats across restarts hands a dead process live write authority, so
80
+ * hosts that persist must supply a counter that never repeats.
56
81
  */
57
82
  readonly generation?: number;
58
83
  /** Top-level directories backed by `sql`. Defaults to DEFAULT_MOUNT_POINTS. */
59
84
  readonly mounts?: readonly string[];
85
+ /** Overlaid on the Nimbus default environment. */
60
86
  readonly env?: Record<string, string>;
61
87
  readonly cwd?: string;
62
88
  /** Absent means headless: `.exec` captures output and nothing is drawn. */
63
89
  readonly terminal?: ITerminal;
90
+ /**
91
+ * Who the shell acts as, when the host keeps a process table that can answer
92
+ * for it. Absent, commands run as uid 1000 with a umask of 022 and no
93
+ * process behind them, which is the Shell's own default.
94
+ */
95
+ readonly identity?: ShellCommandIdentity;
96
+ /**
97
+ * Language runtimes to install before the shell is served, as npm packages
98
+ * the embedder imported (`@nimbus-sh/runtime-bash`,
99
+ * `@nimbus-sh/runtime-cpython`).
100
+ *
101
+ * A Durable Object gets these from R2 through `nimbus install`; an embedder
102
+ * off Cloudflare has no bucket and needs none, because npm already fetched
103
+ * and integrity-checked the same bytes. Both write the same tree at the same
104
+ * path, so what is installed here is indistinguishable from what is
105
+ * installed there — see runtime/runtime-package.ts.
106
+ *
107
+ * Independent of `facets`: this decides what the filesystem HOLDS, and
108
+ * `facets` decides whether anything can run it. A workspace given runtimes
109
+ * and no facet host installs them and still answers "command not found",
110
+ * because it still has nothing that could compile a module.
111
+ */
112
+ readonly runtimes?: readonly RuntimePackage[];
64
113
  /**
65
114
  * Where WebAssembly runs.
66
115
  *
@@ -72,8 +121,9 @@ export interface NimbusWorkspaceOptions {
72
121
  * and `wasm-runner` joins them, which is what makes a `\0asm` file on the
73
122
  * PATH executable (see shell/exec-dispatch.ts).
74
123
  *
75
- * A Durable Object passes the workerd host (`@nimbus-sh/worker`'s
76
- * `loaderFacetHost`); a plain process passes `localFacetHost()`.
124
+ * A plain process passes `localFacetHost()`. A Durable Object passes nothing
125
+ * here and registers its own runners instead, because the ones it needs
126
+ * carry REPLs and a resident-process substrate this cannot reach.
77
127
  */
78
128
  readonly facets?: FacetHost;
79
129
  }
@@ -81,10 +131,13 @@ export interface NimbusWorkspaceOptions {
81
131
  /**
82
132
  * A durable filesystem and a shell over it.
83
133
  *
84
- * Created with {@link NimbusWorkspace.create} rather than `new` because the
85
- * boot it delegates to sources `/etc/profile`, which is genuinely async. A
86
- * Durable Object constructor cannot await, so a host constructs the workspace
87
- * in its first request rather than in its constructor.
134
+ * The composition itself is synchronous. {@link create} awaits only the
135
+ * optional work installing runtime packages, loading the wasm runner modules
136
+ * so a host that asks for neither is never suspended between mounting the
137
+ * filesystem and registering the commands. A Durable Object needs that: it
138
+ * must not take delivery of an event with a half-built shell. The remaining
139
+ * async step, running the user's login files, is {@link start}, which the host
140
+ * calls once its own commands are in place.
88
141
  */
89
142
  export class NimbusWorkspace {
90
143
  /**
@@ -97,61 +150,109 @@ export class NimbusWorkspace {
97
150
  readonly vfs: SqliteVFS;
98
151
  readonly kernel: Kernel;
99
152
  readonly shell: Shell;
153
+ /** What the shell resolves a command name against. A host adds its own. */
154
+ readonly registry: CommandRegistry;
155
+ /**
156
+ * The environment the shell was composed with. The shell's own copy drifts
157
+ * from this one the moment the user exports anything; this is what a host
158
+ * hands to a subordinate shell it starts itself.
159
+ */
160
+ readonly env: Record<string, string>;
161
+
162
+ private readonly commands: SandboxCommandsImpl;
100
163
 
101
164
  private constructor(
102
- private readonly sandbox: Sandbox,
103
165
  vfs: SqliteVFS,
166
+ kernel: Kernel,
167
+ shell: Shell,
168
+ registry: CommandRegistry,
169
+ env: Record<string, string>,
104
170
  private readonly sql: SqlDatabase,
105
171
  ) {
106
172
  this.vfs = vfs;
107
- this.kernel = sandbox.kernel;
108
- this.shell = sandbox.shell;
109
- // NOT `sandbox.fs`, which wraps the raw kernel VFS. The mount is
110
- // kernel-credentialed because the shell re-credentials per command; a host
111
- // calling `.fs` has no process behind it and must not inherit that.
112
- this.fs = new SandboxFsImpl(
113
- sandbox.kernel.vfs.as(CRED_SESSION_USER),
114
- () => sandbox.shell.getCwd(),
115
- );
173
+ this.kernel = kernel;
174
+ this.shell = shell;
175
+ this.registry = registry;
176
+ this.env = env;
177
+ this.commands = new SandboxCommandsImpl(shell, registry);
178
+ // NOT the kernel VFS as it stands, which is kernel-credentialed because
179
+ // the shell re-credentials per command; a host calling `.fs` has no
180
+ // process behind it and must not inherit that.
181
+ this.fs = new SandboxFsImpl(kernel.vfs.as(CRED_SESSION_USER), () => shell.getCwd());
116
182
  }
117
183
 
118
184
  static async create(options: NimbusWorkspaceOptions): Promise<NimbusWorkspace> {
119
- const vfs = new SqliteVFS(options.sql, options.transactions);
120
- vfs.revokeAppendWritersThrough((options.generation ?? 1) * PID_GEN_STRIDE);
121
-
185
+ const vfs = options.vfs ?? openFilesystem(options);
122
186
  const mounts = options.mounts ?? DEFAULT_MOUNT_POINTS;
123
187
  seedBaseFilesystem(vfs, mounts);
124
188
 
125
- const sandbox = await Sandbox.create({
126
- env: options.env,
127
- cwd: options.cwd,
128
- terminal: options.terminal,
129
- providerMounts: mounts.map((mount) => ({
130
- virtualPath: '/' + mount,
131
- provider: new SqliteVFSProvider(vfs, mount),
132
- })),
133
- });
189
+ const kernel = new Kernel();
190
+ // Seeds the in-memory tree. Mounting AFTER it is what keeps a durable
191
+ // /etc from being overwritten by the defaults on every boot.
192
+ kernel.initFilesystem();
193
+ for (const mount of mounts) {
194
+ kernel.vfs.mount(`/${mount}`, new SqliteVFSProvider(vfs, mount));
195
+ }
134
196
 
197
+ const registry = createDefaultRegistry();
135
198
  // The durable coreutils replace ~25 lifo builtins. They are the ones that
136
199
  // carry credentials and read this filesystem's uid/gid, so they must win.
137
- registerUnixCommands(sandbox.commands.registry, vfs);
138
- installPathExecResolver(sandbox.commands.registry, vfs.as(CRED_SESSION_USER), () => sandbox.shell.getCwd());
200
+ registerUnixCommands(registry, vfs);
201
+
202
+ const env = { ...defaultEnv(), ...options.env };
203
+ const shell = new Shell(
204
+ options.terminal ?? new HeadlessTerminal(),
205
+ kernel.vfs,
206
+ registry,
207
+ env,
208
+ kernel.processRegistry,
209
+ options.identity,
210
+ );
211
+ if (options.cwd) shell.setCwd(options.cwd);
212
+
213
+ // Kernel-credentialed on purpose: this only INSPECTS a file to decide how
214
+ // to run it, and re-checks the caller's own execute permission at
215
+ // invocation time — the `authorize` wrapper in exec-dispatch.ts.
216
+ installPathExecResolver(registry, vfs.as(CRED_KERNEL), () => shell.getCwd());
217
+
218
+ const home = env.HOME ?? DEFAULT_HOME;
219
+
220
+ // Before the runners are wired, because registration reads what the
221
+ // filesystem holds — the same order `nimbus install` observes, and the
222
+ // same order a Durable Object observes when it rehydrates after eviction.
223
+ for (const runtimePackage of options.runtimes ?? []) {
224
+ await seedRuntimePackage(vfs.as(CRED_KERNEL), home, runtimePackage);
225
+ }
139
226
 
140
227
  if (options.facets) {
141
228
  await registerWasmRuntimes({
142
229
  facets: options.facets,
143
230
  vfs,
144
- registry: sandbox.commands.registry,
231
+ registry,
145
232
  generation: options.generation ?? 1,
146
- home: options.env?.HOME ?? '/home/user',
233
+ home,
147
234
  });
148
235
  }
149
236
 
150
- return new NimbusWorkspace(sandbox, vfs, options.sql);
237
+ return new NimbusWorkspace(vfs, kernel, shell, registry, env, options.sql);
151
238
  }
152
239
 
153
240
  exec(command: string, options?: RunOptions): Promise<CommandResult> {
154
- return this.sandbox.commands.run(command, options);
241
+ return this.commands.run(command, options);
242
+ }
243
+
244
+ /**
245
+ * Apply the login files, and begin reading the terminal when there is one.
246
+ *
247
+ * Separate from {@link create} because a host with commands of its own must
248
+ * register them first: `/etc/profile` and `~/.nimbusrc` are the user's
249
+ * files, and either may name a command the host has yet to supply.
250
+ */
251
+ async start(): Promise<void> {
252
+ // Sources /etc/profile and the first user rc file it finds, then prompts.
253
+ this.shell.start();
254
+ // Nimbus's own rc file, which the shell's list predates.
255
+ await this.shell.sourceFile(`${this.shell.getEnv().HOME ?? DEFAULT_HOME}/.nimbusrc`);
155
256
  }
156
257
 
157
258
  /**
@@ -181,6 +282,53 @@ export class NimbusWorkspace {
181
282
  }
182
283
  }
183
284
 
285
+ /**
286
+ * Open the durable filesystem for a host that has not opened one itself.
287
+ *
288
+ * The revocation is here rather than in `create` because it is the act of
289
+ * OPENING that carries it: pids at or below this generation's floor belong to
290
+ * an instance that is gone, and their append capabilities must stop being
291
+ * honoured before the first read. A host that opened the filesystem itself has
292
+ * already done this, at the same seam, for the same reason.
293
+ */
294
+ function openFilesystem(options: NimbusWorkspaceOptions): SqliteVFS {
295
+ const vfs = new SqliteVFS(options.sql, options.transactions);
296
+ vfs.revokeAppendWritersThrough((options.generation ?? 1) * PID_GEN_STRIDE);
297
+ return vfs;
298
+ }
299
+
300
+ /**
301
+ * The environment a Nimbus shell starts in.
302
+ *
303
+ * `PATH` and `EDITOR` restate what the seeded `/etc/profile` exports, so a
304
+ * workspace whose host never runs the login files is still on the real PATH.
305
+ * `PORT` and `HOST` are here because every scaffolded server reads them and
306
+ * gets `undefined` otherwise — Express's default app, every create-vite
307
+ * template, `${PORT:-3000}` in a package.json script.
308
+ */
309
+ function defaultEnv(): Record<string, string> {
310
+ return {
311
+ HOME: DEFAULT_HOME,
312
+ USER: DEFAULT_USER,
313
+ SHELL: DEFAULT_SHELL,
314
+ HOSTNAME: DEFAULT_HOSTNAME,
315
+ TERM: 'xterm-256color',
316
+ PWD: DEFAULT_HOME,
317
+ PATH: DEFAULT_PATH,
318
+ PS1: `\x1b[1;32muser@${DEFAULT_HOSTNAME}\x1b[0m:\x1b[1;34m\\w\x1b[0m$ `,
319
+ NODE_ENV: 'development',
320
+ LANG: 'en_US.UTF-8',
321
+ EDITOR: 'nano',
322
+ NIMBUS_VERSION: NIMBUS_VERSION,
323
+ TMPDIR: '/tmp',
324
+ XDG_CONFIG_HOME: `${DEFAULT_HOME}/.config`,
325
+ XDG_DATA_HOME: `${DEFAULT_HOME}/.local/share`,
326
+ npm_config_prefix: '/usr/local',
327
+ PORT: '3000',
328
+ HOST: '0.0.0.0',
329
+ };
330
+ }
331
+
184
332
  /**
185
333
  * Turn a facet host into commands: the runtimes this filesystem already holds,
186
334
  * plus `wasm-runner` for everything else with a `\0asm` header.
@@ -204,30 +352,39 @@ async function registerWasmRuntimes(deps: {
204
352
  const [
205
353
  { makeBashRunnerFactory },
206
354
  { makeCPythonRunnerFactory },
355
+ { makeRubyRunnerFactory },
356
+ { makeClangRunnerFactory },
207
357
  { wasmRunnerSpec },
208
358
  { buildRuntimeHandler },
209
- esbuildModule,
210
359
  ] = await Promise.all([
211
360
  import('../runtime/bash-runner.js'),
212
361
  import('../runtime/cpython-runner.js'),
362
+ import('../runtime/ruby-runner.js'),
363
+ import('../runtime/clang-runner.js'),
213
364
  import('../runtime/wasm-runner.js'),
214
365
  import('../runtime/runtime-registry.js'),
215
- import('../runtime/esbuild-service.js'),
216
366
  ]);
217
367
 
218
368
  // wasm-runner allocates pids for what it runs, so it needs a process table
219
- // whose pid space is this generation's — the same rule the append writers
220
- // above are revoked by.
369
+ // whose pid space is this generation's.
221
370
  const processes = new SessionProcessSupervisor();
222
371
  processes.setPidBase(deps.generation * PID_GEN_STRIDE);
223
372
 
224
- let esbuild: EsbuildService | null = null;
373
+ // Loaded on the first TypeScript or ESM script and not before. The module
374
+ // statically imports `esbuild-wasm/esbuild.wasm`, which only wrangler
375
+ // resolves — node instantiates it as a wasm module and fails on its Go
376
+ // imports — so a host outside Cloudflare must be able to run a shell, bash
377
+ // and python without that module ever entering its graph.
378
+ let esbuild: Promise<EsbuildService> | null = null;
225
379
  deps.registry.register('wasm-runner', buildRuntimeHandler(
226
380
  wasmRunnerSpec({ vfs: deps.vfs, facets: deps.facets, processes }),
227
381
  {
228
382
  vfs: deps.vfs,
229
383
  getEsbuild: () => {
230
- if (!esbuild) esbuild = new esbuildModule.EsbuildService(deps.vfs);
384
+ if (!esbuild) {
385
+ esbuild = import('../runtime/esbuild-service.js')
386
+ .then((module) => new module.EsbuildService(deps.vfs));
387
+ }
231
388
  return esbuild;
232
389
  },
233
390
  registry: deps.registry,
@@ -238,8 +395,13 @@ async function registerWasmRuntimes(deps: {
238
395
  'bash-runner': makeBashRunnerFactory({ facets: deps.facets, vfs: deps.vfs }),
239
396
  // No `startResident`: a workspace owns no actor that could outlive the
240
397
  // call, so a program that keeps serving is refused by name rather than
241
- // run as a one-shot that dies with it.
398
+ // run as a one-shot that dies with it. Same for ruby, where a script is
399
+ // the shape that may bind a port.
242
400
  'cpython-runner': makeCPythonRunnerFactory({ facets: deps.facets, vfs: deps.vfs }),
401
+ 'ruby-runner': makeRubyRunnerFactory({
402
+ facets: deps.facets, vfs: deps.vfs, registry: deps.registry,
403
+ }),
404
+ 'clang-runner': makeClangRunnerFactory({ facets: deps.facets, vfs: deps.vfs }),
243
405
  };
244
406
  rehydrateInstalledRuntimesView(
245
407
  deps.vfs.as(CRED_KERNEL),
@@ -277,8 +439,14 @@ const WORKSPACE_TABLES = [
277
439
  * a workspace reopened over a populated database keeps whatever the user did
278
440
  * to these files. `/etc/passwd` and `/etc/group` are load-bearing rather than
279
441
  * decorative — `id`, `chown` and `su` resolve names through them.
442
+ *
443
+ * What a PRODUCT puts in a fresh filesystem — a banner, a welcome file, a
444
+ * starter app — is not here. This is the base an OS needs in order to boot,
445
+ * and it is exported because a host may need the filesystem before it needs a
446
+ * shell: the Nimbus session seeds its starter project for a browser that hits
447
+ * `/preview` without ever opening a terminal.
280
448
  */
281
- function seedBaseFilesystem(vfs: SqliteVFS, mounts: readonly string[]): void {
449
+ export function seedBaseFilesystem(vfs: SqliteVFS, mounts: readonly string[]): void {
282
450
  const fs = vfs.as(CRED_SESSION_USER);
283
451
  const rootFs = vfs.as(CRED_KERNEL);
284
452
 
@@ -287,11 +455,37 @@ function seedBaseFilesystem(vfs: SqliteVFS, mounts: readonly string[]): void {
287
455
  for (const mount of mounts) {
288
456
  if (mount !== 'etc' && !fs.exists(mount)) fs.mkdir(mount, { recursive: true });
289
457
  }
290
- for (const dir of ['home/user', 'usr/bin', 'usr/local/bin', 'var/log', 'tmp']) {
458
+ for (const dir of [
459
+ 'home/user', 'home/user/.config', 'home/user/projects',
460
+ 'tmp', 'var/log',
461
+ 'usr/bin', 'usr/lib', 'usr/lib/node_modules',
462
+ 'usr/share', 'usr/share/pkg', 'usr/share/pkg/node_modules',
463
+ 'usr/local', 'usr/local/lib', 'usr/local/lib/node_modules', 'usr/local/bin',
464
+ ]) {
291
465
  if (!fs.exists(dir)) fs.mkdir(dir, { recursive: true });
292
466
  }
293
467
 
294
- if (!rootFs.exists('etc')) rootFs.mkdir('etc', { mode: 0o755 });
468
+ // /etc belongs to root, and is re-asserted rather than only created: a
469
+ // user-writable /etc is an authority bug, not an untidy directory.
470
+ if (!rootFs.exists('etc')) {
471
+ rootFs.mkdir('etc', { mode: 0o755 });
472
+ } else {
473
+ const etc = rootFs.stat('etc');
474
+ if (etc.uid !== 0 || etc.gid !== 0) rootFs.chown('etc', 0, 0);
475
+ if ((etc.mode & 0o7777) !== 0o755) rootFs.chmod('etc', 0o755);
476
+ }
477
+
478
+ if (!rootFs.exists('etc/hostname')) {
479
+ rootFs.writeFile('etc/hostname', `${DEFAULT_HOSTNAME}\n`);
480
+ rootFs.chown('etc/hostname', CRED_SESSION_USER.uid, CRED_SESSION_USER.gid);
481
+ }
482
+ if (!rootFs.exists('etc/os-release')) {
483
+ rootFs.writeFile('etc/os-release',
484
+ `NAME="Nimbus"\nVERSION="${NIMBUS_VERSION}"\nID=nimbus\n`
485
+ + 'PRETTY_NAME="Nimbus — Cloud Dev Environment"\n',
486
+ );
487
+ rootFs.chown('etc/os-release', CRED_SESSION_USER.uid, CRED_SESSION_USER.gid);
488
+ }
295
489
 
296
490
  // Root-owned 0644, and re-asserted rather than only created: these decide
297
491
  // what `id`, `chown` and `su` believe, so a user-writable /etc/passwd would
@@ -305,8 +499,19 @@ function seedBaseFilesystem(vfs: SqliteVFS, mounts: readonly string[]): void {
305
499
  accountFile('etc/passwd', 'root:x:0:0:root:/root:/bin/sh\nuser:x:1000:1000:Nimbus User:/home/user:/bin/sh\n');
306
500
  accountFile('etc/group', 'root:x:0:\nuser:x:1000:user\n');
307
501
 
502
+ const defaultProfile = `export PATH=${DEFAULT_PATH}\nexport EDITOR=nano\n`;
308
503
  if (!rootFs.exists('etc/profile')) {
309
- rootFs.writeFile('etc/profile', `export PATH=${DEFAULT_PATH}\nexport EDITOR=nano\n`);
504
+ rootFs.writeFile('etc/profile', defaultProfile);
310
505
  rootFs.chown('etc/profile', CRED_SESSION_USER.uid, CRED_SESSION_USER.gid);
506
+ } else if (rootFs.readFileString('etc/profile') === 'export PATH=/usr/bin:/bin\nexport EDITOR=nano\n') {
507
+ // The lifo default, from before Nimbus had a PATH of its own. Nobody ever
508
+ // chose it, so replacing it is not overwriting a user's file.
509
+ rootFs.writeFile('etc/profile', defaultProfile);
510
+ }
511
+
512
+ if (!fs.exists('home/user/.nimbusrc')) {
513
+ fs.writeFile('home/user/.nimbusrc',
514
+ '# Nimbus shell config\nalias ll="ls -la"\nalias la="ls -a"\nalias l="ls -1"\n',
515
+ );
311
516
  }
312
517
  }