@nimbus-sh/worker 0.2.1 → 0.2.3

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 (45) hide show
  1. package/README.md +5 -2
  2. package/dist/facets/manager.d.ts +24 -0
  3. package/dist/facets/manager.d.ts.map +1 -1
  4. package/dist/facets/manager.js +73 -14
  5. package/dist/loaders/generated-workers.d.ts +2 -2
  6. package/dist/loaders/generated-workers.d.ts.map +1 -1
  7. package/dist/loaders/generated-workers.js +3 -3
  8. package/dist/runtime/facet-loader-host.d.ts +3 -2
  9. package/dist/runtime/facet-loader-host.d.ts.map +1 -1
  10. package/dist/runtime/facet-loader-host.js +1 -0
  11. package/dist/runtime/ruby-repl.js +1 -1
  12. package/dist/runtime/ruby-resident.d.ts +20 -0
  13. package/dist/runtime/ruby-resident.d.ts.map +1 -0
  14. package/dist/runtime/ruby-resident.js +278 -0
  15. package/dist/session/init.d.ts +16 -7
  16. package/dist/session/init.d.ts.map +1 -1
  17. package/dist/session/init.js +192 -208
  18. package/dist/session/keys.d.ts +11 -9
  19. package/dist/session/keys.d.ts.map +1 -1
  20. package/dist/session/keys.js +11 -9
  21. package/dist/session/nimbus-session.d.ts +11 -1
  22. package/dist/session/nimbus-session.d.ts.map +1 -1
  23. package/dist/session/nimbus-session.js +13 -57
  24. package/dist/session/programmatic.d.ts +1 -1
  25. package/dist/session/programmatic.d.ts.map +1 -1
  26. package/dist/session/programmatic.js +1 -1
  27. package/dist/session/routes.js +1 -1
  28. package/package.json +2 -2
  29. package/scripts/bundle-facet-workers.mjs +4 -3
  30. package/scripts/bundle-runtime.mjs +279 -44
  31. package/dist/npm/tarball-stream.d.ts +0 -93
  32. package/dist/npm/tarball-stream.d.ts.map +0 -1
  33. package/dist/npm/tarball-stream.js +0 -235
  34. package/dist/npm/tarball.d.ts +0 -34
  35. package/dist/npm/tarball.d.ts.map +0 -1
  36. package/dist/npm/tarball.js +0 -97
  37. package/dist/runtime/clang-runner.d.ts +0 -37
  38. package/dist/runtime/clang-runner.d.ts.map +0 -1
  39. package/dist/runtime/clang-runner.js +0 -865
  40. package/dist/runtime/ruby-gems.d.ts +0 -30
  41. package/dist/runtime/ruby-gems.d.ts.map +0 -1
  42. package/dist/runtime/ruby-gems.js +0 -636
  43. package/dist/runtime/ruby-runner.d.ts +0 -75
  44. package/dist/runtime/ruby-runner.d.ts.map +0 -1
  45. package/dist/runtime/ruby-runner.js +0 -1577
@@ -3,12 +3,21 @@
3
3
  *
4
4
  * Why this is one big function and not a class:
5
5
  * initSession runs once per /ws upgrade and walks the session
6
- * through Phase R (rehydrate from SQL), Phase B (build kernel +
7
- * shell + register commands), Phase W (attach terminal), and
8
- * (cold-only) Phase O (MOTD + framework hint). The phases share
9
- * lots of locals (vfs, kernel, registry, shell) and ordering
10
- * matters strictly — there's no interesting reuse boundary that a
11
- * class decomposition would expose.
6
+ * through Phase R (rehydrate from SQL), Phase B (compose the
7
+ * workspace + register the session's commands), Phase W (attach
8
+ * terminal), and (cold-only) Phase O (MOTD + framework hint). The
9
+ * phases share lots of locals (vfs, kernel, registry, shell) and
10
+ * ordering matters strictly — there's no interesting reuse boundary
11
+ * that a class decomposition would expose.
12
+ *
13
+ * What it no longer does is COMPOSE the operating system. The kernel,
14
+ * the provider mounts, the command registry, the coreutils, the exec
15
+ * resolver, the default environment and the shell come from
16
+ * `NimbusWorkspace` (@nimbus-sh/core/workspace), which an embedder off
17
+ * Cloudflare builds the same way. What stays here is everything that
18
+ * only makes sense with a socket, a Durable Object and a product behind
19
+ * it: the terminal and its scrollback, the phase machine, the persisted
20
+ * shell state, npm, git, vite, wrangler and the facet-backed runtimes.
12
21
  *
13
22
  * The function is intentionally written so that a reader sees:
14
23
  * 1. setPhase('rehydrate') ...
@@ -24,9 +33,9 @@
24
33
  * - acceptShellWebSocket → self.initSession(ws) (S7 will extract).
25
34
  * - The class still has `initSession(ws)` as a delegator method.
26
35
  */
27
- import { Kernel, Shell, createDefaultRegistry, ProcessRegistry, MemoryPersistenceBackend, createCurlCommand, createNpmCommand, NPM_VERSION, createTopCommand, createWatchCommand, createHelpCommand, rehydrateGlobalPackages, } from '@nimbus-sh/core/substrate/lifo/index.js';
36
+ import { Shell, createCurlCommand, createNpmCommand, NPM_VERSION, createTopCommand, createWatchCommand, createHelpCommand, rehydrateGlobalPackages, } from '@nimbus-sh/core/substrate/lifo/index.js';
28
37
  import { createKillCommand } from '@nimbus-sh/core/substrate/lifo/commands/system/kill.js';
29
- import { SqliteVFSProvider } from '@nimbus-sh/core/vfs/sqlite-vfs.js';
38
+ import { NimbusWorkspace } from '@nimbus-sh/core/workspace';
30
39
  import { CRED_KERNEL, requireVfsCred } from '@nimbus-sh/core/runtime/os-contracts.js';
31
40
  import { WebSocketTerminal } from '../facets/ws-terminal.js';
32
41
  import { EsbuildService } from '@nimbus-sh/core/runtime/esbuild-service.js';
@@ -37,14 +46,12 @@ import { parseViteConfigSource } from '@nimbus-sh/core/runtime/vite-config-parse
37
46
  import { startRealVite } from './start-real-vite.js';
38
47
  import { findHtmlScriptEntrypoint, rewriteViteBuildHtml } from '../runtime/html-entrypoint.js';
39
48
  import { normalizeVfsPath, parentVfsPath, resolveVfsPath, stripLeadingSlashes } from '@nimbus-sh/core/vfs/path.js';
40
- import { installPathExecResolver, } from '@nimbus-sh/core/shell/exec-dispatch.js';
41
49
  import { ViteDevServer } from '../facets/vite-dev-server.js';
42
50
  import { shouldUseRealVite } from '../facets/cirrus-real.js';
43
51
  import { makeLongRunningPortStub, resolveLongRunningPort, expandArgvShellDefaults, } from '@nimbus-sh/core/runtime/long-running-handle.js';
44
52
  import { NimbusWrangler } from '../wrangler/nimbus-wrangler.js';
45
53
  import { filterWranglerFlags, detectBundlerBin, checkNodeModulesGuard, detectUnsupportedWranglerConfig, } from './helpers.js';
46
54
  import { HeredocHandler, LineEditorExtender } from '@nimbus-sh/core/shell/features.js';
47
- import { registerUnixCommands } from '@nimbus-sh/core/shell/unix-commands.js';
48
55
  import { registerShellEntrypointCommands } from '@nimbus-sh/core/shell/shell-entrypoints.js';
49
56
  import { makeChshCommand } from '@nimbus-sh/core/substrate/lifo/shell/default-shell.js';
50
57
  import { installNpmBinFallbackResolver } from '../shell/npm-bin-entrypoints.js';
@@ -62,7 +69,7 @@ import { listInstalledRuntimes, rehydrateInstalledRuntimes, registerRunnerFactor
62
69
  import { hasSeededProject, SEED_PROJECT_DIR } from '@nimbus-sh/core/vfs/seed-project.js';
63
70
  import { notifyTerminalEvent } from '../runtime/process-logs-api.js';
64
71
  import { stripAnsi } from '@nimbus-sh/core/runtime/process-logs.js';
65
- import { NIMBUS_VERSION, DEFAULT_HOSTNAME, DEFAULT_MOUNT_POINTS, NODE_VERSION, DEFAULT_PATH, } from '@nimbus-sh/core/constants.js';
72
+ import { DEFAULT_MOUNT_POINTS, NODE_VERSION, } from '@nimbus-sh/core/constants.js';
66
73
  import { ensureSessionStateSchema, loadShellState, stampHydratedAt, countSessionStateKeys, loadKernelMounts, persistKernelMounts, appendScrollback, loadScrollback, } from './state-store.js';
67
74
  import { recordRecoveryEvent } from '@nimbus-sh/core/observability/oom-discriminator.js';
68
75
  import { sessionAiEnv } from './ai.js';
@@ -77,7 +84,7 @@ function resolveNpmPrefix(prefix, cwd) {
77
84
  function quoteShellArgument(value) {
78
85
  return `'${value.replace(/'/g, `'\\''`)}'`;
79
86
  }
80
- export function initSession(self, ws) {
87
+ export async function initSession(self, ws) {
81
88
  self.ensureSqliteFs();
82
89
  const kernelFs = self.sqliteFs.as(CRED_KERNEL);
83
90
  self.ensureFacetManager();
@@ -157,43 +164,158 @@ export function initSession(self, ws) {
157
164
  self.terminal.write('\x1b[33m[nimbus] this session resumed on a new instance. Anything still '
158
165
  + 'running was lost with the old one — re-run it if it had not finished.\x1b[0m\r\n');
159
166
  }
160
- // [B'.4] Phase B — Build. Construct Kernel + Shell + registry +
161
- // install all commands. CPU-intensive phase. Spans from here
162
- // through ~line 1925 (just before Phase O).
167
+ // A reset that killed a resident launch left its journal row behind
168
+ // (facets/manager.ts, _recoverInterruptedLaunches). The alarm the dying
169
+ // instance was using for launch turns is NOT a trigger recovery can rely
170
+ // on — measured live, a launch killed early in its first chunks rolls the
171
+ // alarm-map put back with the rest of the dying turn, so the replacement
172
+ // instance never fires an alarm at all. What always follows a dead session
173
+ // is this reconnect, so recovery runs here: after the terminal exists (the
174
+ // report lands in front of the user, live) and after the constructor's
175
+ // pidBase gate (the journal predicate needs this instance's generation).
176
+ // Idempotent per instance; a no-op whenever no launch was interrupted.
177
+ await self.facetManager.pumpResidentLaunches();
178
+ // [B'.4] Phase B — Build. Compose the workspace (kernel + mounts +
179
+ // shell + the OS command set), then install the session's own
180
+ // commands and wiring on top. CPU-intensive phase. Spans from here
181
+ // through Phase O.
163
182
  setPhase(self, 'build', 'init-session');
164
- // ── Boot kernel with in-memory VFS (mounts delegate to SqliteFS) ──
165
- self.kernel = new Kernel(new MemoryPersistenceBackend());
166
- self.kernel.initFilesystem();
167
- // ── Mount SqliteFSProvider at all top-level directories [B'.2] ──
183
+ // ── Mount list = DEFAULT_MOUNT_POINTS ∪ persisted-mounts [B'.2] ──
168
184
  //
169
- // Mount list = DEFAULT_MOUNT_POINTS ∪ persisted-mounts. The
170
- // defaults are always present (they're platform invariants);
185
+ // The defaults are always present (they're platform invariants);
171
186
  // any extras a future custom-mount feature might add survive
172
- // reconnect via the nimbus_kernel_mounts table. The persist
173
- // step at the end writes the merged list back so the table
174
- // tracks the live mount tree.
187
+ // reconnect via the nimbus_kernel_mounts table. The persist step
188
+ // below writes the merged list back so the table tracks the live
189
+ // mount tree — today the same 7 rows every initSession.
175
190
  const persistedMounts = loadKernelMounts(self.ctx);
176
191
  const mountPoints = Array.from(new Set([
177
192
  ...DEFAULT_MOUNT_POINTS,
178
193
  ...persistedMounts,
179
194
  ]));
180
- for (const mp of mountPoints) {
181
- const provider = new SqliteVFSProvider(self.sqliteFs, mp);
182
- self.kernel.vfs.mount('/' + mp, provider);
195
+ // ── What the session adds to the workspace's environment [B'.1] ──
196
+ //
197
+ // The platform defaults — PATH, PS1, HOME, PORT, HOST and the rest —
198
+ // belong to the workspace (core/workspace/nimbus-workspace.ts) because
199
+ // they are the OS's, not this transport's. What is genuinely the
200
+ // session's layers here:
201
+ //
202
+ // NIMBUS_SESSION_ID — derived from sessionBasePath = "/s/<id>". Set
203
+ // here as a placeholder ("") and patched below right
204
+ // after the shell exists, so the user's first command
205
+ // sees the real id. Sentry / Datadog / any ops
206
+ // integration that wants a session-stable token reads it.
207
+ //
208
+ // sessionAiEnv() — the session AI gateway (session/ai.ts). A coding
209
+ // agent, a user's own script or curl reaches the
210
+ // session's models from these without being configured:
211
+ // by OPENAI_BASE_URL if it reads one, and otherwise by
212
+ // CLOUDFLARE_API_KEY, this session's capability token,
213
+ // which mediates the tool's own egress back to the
214
+ // gateway (_shared/ai-egress.ts).
215
+ //
216
+ // persisted.env — the user's own `export FOO=bar`, which survives
217
+ // reconnect and wins over everything above. A user who
218
+ // exports their own OPENAI_BASE_URL or CLOUDFLARE_API_KEY
219
+ // still wins; their key is not this session's token, so
220
+ // their request goes to their own account.
221
+ const envOverlay = {
222
+ NIMBUS_SESSION_ID: '',
223
+ ...sessionAiEnv(),
224
+ ...(persisted.env || {}),
225
+ };
226
+ // ── The identity the shell acts under ──
227
+ //
228
+ // Every command the shell runs is credentialed by a live entry in the
229
+ // session's process table, which is what makes `sudo`, `chown` and the
230
+ // per-process umask mean anything. A workspace with no host process
231
+ // table falls back to a bare uid-1000 identity; this one has one.
232
+ if (self.shellProcessPid !== null) {
233
+ self.processes.exit(self.shellProcessPid, 0);
183
234
  }
184
- // Persist the mount-tree. Today this writes the same
185
- // DEFAULT_MOUNT_POINTS list every initSession (idempotent — the
186
- // table just keeps the same 7 rows). Future custom mounts will
187
- // flow through the same code path.
235
+ const shellProcess = self.processes.spawn('sh', ['sh'], persisted.cwd || '/home/user');
236
+ self.shellProcessPid = shellProcess.pid;
237
+ const runAsProcess = async (parent, cred, argv) => {
238
+ if (argv.length === 0)
239
+ return 0;
240
+ const child = self.processes.spawn(argv.join(' '), argv, parent.cwd, { parentPid: parent.pid, cred });
241
+ const activeShell = self.shell;
242
+ if (!activeShell) {
243
+ self.processes.exit(child.pid, 1);
244
+ throw new Error('shell is not initialized');
245
+ }
246
+ const identity = commandIdentityFor(child.pid);
247
+ let exitCode = 1;
248
+ try {
249
+ const stdin = parent.stdin && parent.stdin !== parent.terminalStdin
250
+ ? await parent.stdin.readAll()
251
+ : undefined;
252
+ const result = await activeShell.execute(argv.map(quoteShellArgument).join(' '), {
253
+ cwd: parent.cwd,
254
+ env: parent.env,
255
+ stdin,
256
+ terminalStdin: parent.terminalStdin,
257
+ signal: parent.signal,
258
+ isolateShellState: true,
259
+ terminalFds: {
260
+ stdin: parent.isFdTerminal?.(0) ?? false,
261
+ stdout: parent.isFdTerminal?.(1) ?? false,
262
+ stderr: parent.isFdTerminal?.(2) ?? false,
263
+ },
264
+ onStdout: (data) => parent.stdout.write(data),
265
+ onStderr: (data) => parent.stderr.write(data),
266
+ commandContext: {
267
+ pid: identity.pid,
268
+ cred: identity.cred,
269
+ setUmask: identity.setUmask,
270
+ },
271
+ runAs: runAsProcess,
272
+ });
273
+ exitCode = result.exitCode;
274
+ return exitCode;
275
+ }
276
+ finally {
277
+ self.processes.exit(child.pid, exitCode);
278
+ }
279
+ };
280
+ const commandIdentityFor = (pid) => ({
281
+ pid,
282
+ get cred() {
283
+ return self.processes.cred(pid);
284
+ },
285
+ setUmask(mask) {
286
+ self.processes.setUmask(pid, mask);
287
+ },
288
+ runAs: runAsProcess,
289
+ });
290
+ // ── The workspace: one recipe for kernel + mounts + shell + coreutils ──
291
+ //
292
+ // No `facets`: the session registers its own runtime factories below,
293
+ // because the ones it needs carry REPLs, a resident-process substrate
294
+ // and clang, none of which a bare facet host reaches. Everything the
295
+ // workspace does register is the OS, and is identical either way.
296
+ const workspace = await NimbusWorkspace.create({
297
+ sql: self.ctx.storage.sql,
298
+ // The filesystem this DO already opened. `ensureSqliteFs` above may
299
+ // have been called many requests ago; a second SqliteVFS over the
300
+ // same rows would be a second cache serving stale reads.
301
+ vfs: self.sqliteFs,
302
+ mounts: mountPoints,
303
+ env: envOverlay,
304
+ terminal: self.terminal,
305
+ identity: commandIdentityFor(shellProcess.pid),
306
+ });
307
+ self.kernel = workspace.kernel;
308
+ self.shell = workspace.shell;
309
+ const kernel = workspace.kernel;
310
+ const registry = workspace.registry;
311
+ const processRegistry = kernel.processRegistry;
312
+ const env = workspace.env;
313
+ const sqliteFs = self.sqliteFs;
314
+ const facetMgr = self.facetManager;
188
315
  try {
189
316
  persistKernelMounts(self.ctx, mountPoints);
190
317
  }
191
318
  catch { /* fail-soft */ }
192
- // ── Create command registry ──
193
- const registry = createDefaultRegistry();
194
- const kernel = self.kernel;
195
- const sqliteFs = self.sqliteFs;
196
- const facetMgr = self.facetManager;
197
319
  // ── editor/monaco (2026-05-13): editor-pane fs bridge ──
198
320
  //
199
321
  // The terminal hosts the WS that the editor pane reuses for
@@ -321,24 +443,10 @@ export function initSession(self, ws) {
321
443
  });
322
444
  }
323
445
  });
324
- // ── WASI Stage 1: exec dispatch for path-shaped invocations ──
325
- // `./x`, `/abs/x`, `../x` go through POSIX execve semantics
326
- // (shell/exec-dispatch.ts): exec-bit check (with the wasm-magic
327
- // grandfather rule for pre-chmod modes), then format dispatch —
328
- // `\0asm` → wasm-runner, `#!` → the named interpreter, binary
329
- // junk → honest ENOEXEC, plain text → sh (the POSIX ENOEXEC
330
- // fallback). Bare-word "X" still must come from the registered set.
331
- //
332
- // Implementation: monkey-patch registry.resolve. cwd and file
333
- // state are read at every resolve call (not cached) so `cd` and
334
- // recompiles between invocations are honoured.
335
- installPathExecResolver(registry, kernelFs, () => self.shell?.getCwd() || '/home/user');
336
446
  // W8: hand the registry to the cp broker so child_process.spawn from
337
447
  // a parent facet can resolve and dispatch commands the same way the
338
- // shell does. Done AFTER all registrations are complete (below).
448
+ // shell does.
339
449
  self._setCpRegistry(registry);
340
- // ── Unix commands (30+ real implementations) ──
341
- registerUnixCommands(registry, sqliteFs);
342
450
  registry.register('chsh', makeChshCommand({
343
451
  isBashInstalled: (home) => listInstalledRuntimes(sqliteFs, home).some((runtime) => runtime.name === 'bash'),
344
452
  }));
@@ -355,8 +463,9 @@ export function initSession(self, ws) {
355
463
  // 3. Rehydrate any previously-installed runtimes from VFS so their
356
464
  // bins reappear in the registry after DO eviction or WS reconnect.
357
465
  registerRunnerFactory('clang-runner', (manifest, installRoot, binName, binKind) => async (ctx) => {
358
- const { makeClangRunnerFactory } = await import('../runtime/clang-runner.js');
359
- return await makeClangRunnerFactory({ facetMgr, vfs: sqliteFs })(manifest, installRoot, binName, binKind)(ctx);
466
+ const { makeClangRunnerFactory } = await import('@nimbus-sh/core/runtime/clang-runner.js');
467
+ const { facetHostForManager } = await import('../runtime/facet-loader-host.js');
468
+ return await makeClangRunnerFactory({ facets: facetHostForManager(facetMgr), vfs: sqliteFs })(manifest, installRoot, binName, binKind)(ctx);
360
469
  });
361
470
  // Pyodide v1 — Python 3.13 via the same R2-package-manager
362
471
  // substrate that ships clang. Manifest entrypoints `python` and
@@ -421,8 +530,15 @@ export function initSession(self, ws) {
421
530
  installRoot,
422
531
  });
423
532
  }
424
- const { makeRubyRunnerFactory } = await import('../runtime/ruby-runner.js');
425
- return await makeRubyRunnerFactory({ facetMgr, vfs: sqliteFs, registry })(manifest, installRoot, binName, binKind)(ctx);
533
+ const { makeRubyRunnerFactory } = await import('@nimbus-sh/core/runtime/ruby-runner.js');
534
+ const { facetHostForManager } = await import('../runtime/facet-loader-host.js');
535
+ const { rubyResidentStart } = await import('../runtime/ruby-resident.js');
536
+ return await makeRubyRunnerFactory({
537
+ facets: facetHostForManager(facetMgr),
538
+ vfs: sqliteFs,
539
+ registry,
540
+ startResident: rubyResidentStart(facetMgr),
541
+ })(manifest, installRoot, binName, binKind)(ctx);
426
542
  });
427
543
  // GNU bash 5.2.37 (wasm32-wasi, asyncified) — dedicated facet
428
544
  // runner driving the fork/pipe/exec/setjmp scheduler (fork M1-M3
@@ -1566,158 +1682,28 @@ export function initSession(self, ws) {
1566
1682
  }
1567
1683
  return result.failed.length > 0 ? 1 : 0;
1568
1684
  });
1569
- // ── Set up environment [B'.1: rehydrate from SQL] ──
1570
- //
1571
- // Cold start: env is the platform default below.
1572
- // Silent re-init (persisted env present): the Shell's constructor
1573
- // does `this.env = { ...n }`, so we layer the persisted env over
1574
- // the defaults — defaults provide PATH/PS1/etc. (which the user
1575
- // never sets explicitly), persisted overlays whatever the user
1576
- // did set (NIMBUS_TEST=cool, etc.).
1577
- //
1578
- // Primitive #7 (runtime primitive support): PORT/HOST and
1579
- // NIMBUS_SESSION_ID are part of the standard contract.
1580
- //
1581
- // PORT=3000 — the same default Markflow's `${PORT:-3000}` shell
1582
- // expansion targets, and what every Express/Hono/
1583
- // fastify/Bun.serve script reads when the user
1584
- // doesn't set it explicitly. Long-running spawns
1585
- // still pull from `--port` argv first (see
1586
- // runtime/long-running-handle.ts:resolveLongRunningPort);
1587
- // this default is the SOURCE for that fall-through.
1588
- //
1589
- // HOST=0.0.0.0 — Cloudflare Workers / DO have no localhost vs.
1590
- // external distinction (the supervisor never opens
1591
- // a real socket); 0.0.0.0 is what every tutorial
1592
- // tells users to bind to and matches CF docs.
1593
- //
1594
- // NIMBUS_SESSION_ID — derived from sessionBasePath = "/s/<id>".
1595
- // Set lazily here as a placeholder ("") and patched
1596
- // below right after Shell construction so the user's
1597
- // first command sees the real id.
1598
- //
1599
- // Why these aren't optional: package.json scripts that hardcode
1600
- // process.env.PORT (Express's default app, every "create-vite"
1601
- // template) get `undefined` without this. Sentry / Datadog / any
1602
- // ops integration that wants a session-stable token uses
1603
- // NIMBUS_SESSION_ID.
1604
- const env = {
1605
- HOME: '/home/user',
1606
- USER: 'user',
1607
- SHELL: '/bin/sh',
1608
- HOSTNAME: DEFAULT_HOSTNAME,
1609
- TERM: 'xterm-256color',
1610
- PWD: '/home/user',
1611
- PATH: DEFAULT_PATH,
1612
- PS1: `\x1b[1;32muser@${DEFAULT_HOSTNAME}\x1b[0m:\x1b[1;34m\\w\x1b[0m$ `,
1613
- NODE_ENV: 'development',
1614
- LANG: 'en_US.UTF-8',
1615
- EDITOR: 'nano',
1616
- NIMBUS_VERSION: NIMBUS_VERSION,
1617
- TMPDIR: '/tmp',
1618
- XDG_CONFIG_HOME: '/home/user/.config',
1619
- XDG_DATA_HOME: '/home/user/.local/share',
1620
- npm_config_prefix: '/usr/local',
1621
- // Primitive #7 contract additions.
1622
- PORT: '3000',
1623
- HOST: '0.0.0.0',
1624
- NIMBUS_SESSION_ID: '', // patched after Shell ctor — see below.
1625
- // The session AI gateway (session/ai.ts). A coding agent, a user's own
1626
- // script or curl reaches the session's models from these without being
1627
- // configured: by OPENAI_BASE_URL if it reads one, and otherwise by
1628
- // CLOUDFLARE_API_KEY, this session's capability token, which mediates the
1629
- // tool's own egress back to the gateway (_shared/ai-egress.ts). A user
1630
- // who exports their own OPENAI_BASE_URL or CLOUDFLARE_API_KEY still wins,
1631
- // via the persisted spread — their key is not this session's token, so
1632
- // their request goes to their own account.
1633
- ...sessionAiEnv(),
1634
- // Persisted env keys win over defaults — the user's `export FOO=bar`
1635
- // survives reconnect.
1636
- ...(persisted.env || {}),
1637
- };
1638
- // ── Create shell ──
1639
- const processRegistry = new ProcessRegistry();
1640
- if (self.shellProcessPid !== null) {
1641
- self.processes.exit(self.shellProcessPid, 0);
1642
- }
1643
- const shellProcess = self.processes.spawn('sh', ['sh'], persisted.cwd || '/home/user');
1644
- self.shellProcessPid = shellProcess.pid;
1645
- const runAsProcess = async (parent, cred, argv) => {
1646
- if (argv.length === 0)
1647
- return 0;
1648
- const child = self.processes.spawn(argv.join(' '), argv, parent.cwd, { parentPid: parent.pid, cred });
1649
- const activeShell = self.shell;
1650
- if (!activeShell) {
1651
- self.processes.exit(child.pid, 1);
1652
- throw new Error('shell is not initialized');
1653
- }
1654
- const identity = commandIdentityFor(child.pid);
1655
- let exitCode = 1;
1656
- try {
1657
- const stdin = parent.stdin && parent.stdin !== parent.terminalStdin
1658
- ? await parent.stdin.readAll()
1659
- : undefined;
1660
- const result = await activeShell.execute(argv.map(quoteShellArgument).join(' '), {
1661
- cwd: parent.cwd,
1662
- env: parent.env,
1663
- stdin,
1664
- terminalStdin: parent.terminalStdin,
1665
- signal: parent.signal,
1666
- isolateShellState: true,
1667
- terminalFds: {
1668
- stdin: parent.isFdTerminal?.(0) ?? false,
1669
- stdout: parent.isFdTerminal?.(1) ?? false,
1670
- stderr: parent.isFdTerminal?.(2) ?? false,
1671
- },
1672
- onStdout: (data) => parent.stdout.write(data),
1673
- onStderr: (data) => parent.stderr.write(data),
1674
- commandContext: {
1675
- pid: identity.pid,
1676
- cred: identity.cred,
1677
- setUmask: identity.setUmask,
1678
- },
1679
- runAs: runAsProcess,
1680
- });
1681
- exitCode = result.exitCode;
1682
- return exitCode;
1683
- }
1684
- finally {
1685
- self.processes.exit(child.pid, exitCode);
1686
- }
1687
- };
1688
- const commandIdentityFor = (pid) => ({
1689
- pid,
1690
- get cred() {
1691
- return self.processes.cred(pid);
1692
- },
1693
- setUmask(mask) {
1694
- self.processes.setUmask(pid, mask);
1695
- },
1696
- runAs: runAsProcess,
1697
- });
1698
- self.shell = new Shell(self.terminal, self.kernel.vfs, registry, env, processRegistry, commandIdentityFor(shellProcess.pid));
1699
1685
  // Primitive #7: patch NIMBUS_SESSION_ID into the live shell env.
1700
1686
  // sessionBasePath is "/s/<sid>" set by the X-Nimbus-Base header on
1701
1687
  // the first /ws upgrade — by the time initSession runs (after the
1702
1688
  // ws handshake), it's populated. Older /ws-pre-base callers see
1703
1689
  // an empty string, which is the safe placeholder (no false id).
1704
1690
  //
1705
- // We patch the live env (not the local `env` map above) so persisted
1706
- // shell state on warm-rejoin still picks up the SAME session id —
1707
- // the DO's name is stable across hibernation cycles. Any user
1708
- // `export NIMBUS_SESSION_ID=...` would have been persisted to
1709
- // persisted.env and the spread above would have overridden the
1710
- // empty placeholder; we only set when the live env is empty
1711
- // (don't clobber a user-set value).
1691
+ // We patch the live env (not the overlay the workspace was built
1692
+ // from) so persisted shell state on warm-rejoin still picks up the
1693
+ // SAME session id — the DO's name is stable across hibernation
1694
+ // cycles. Any user `export NIMBUS_SESSION_ID=...` would have been
1695
+ // persisted to persisted.env and the overlay's spread would have
1696
+ // overridden the empty placeholder; we only set when the live env
1697
+ // is empty (don't clobber a user-set value).
1712
1698
  const sessionIdFromBase = (self.sessionBasePath || '').replace(/^\/s\//, '');
1713
1699
  if (sessionIdFromBase) {
1714
1700
  // Shell.env is declared private but mutable at runtime — there's
1715
1701
  // no public setter. We `any`-cast deliberately; the alternative
1716
- // (replacing the whole Shell after ctor) would lose the kernel +
1717
- // registry wiring. Anti-req note: this is NOT a defensive cast,
1718
- // it's a deliberate single-write operation to plug the contract
1719
- // gap that env-construction couldn't fill (sessionBasePath
1720
- // wasn't yet hydrated at ctor time).
1702
+ // (replacing the whole Shell after construction) would lose the
1703
+ // kernel + registry wiring. Anti-req note: this is NOT a defensive
1704
+ // cast, it's a deliberate single-write operation to plug the
1705
+ // contract gap that env-construction couldn't fill (sessionBasePath
1706
+ // wasn't yet hydrated when the workspace was composed).
1721
1707
  const shellAny = self.shell;
1722
1708
  if (!shellAny.env.NIMBUS_SESSION_ID) {
1723
1709
  shellAny.env.NIMBUS_SESSION_ID = sessionIdFromBase;
@@ -2733,17 +2719,15 @@ export function initSession(self, ws) {
2733
2719
  catch { /* non-critical */ }
2734
2720
  }
2735
2721
  // ── Start shell ──
2736
- self.shell.start();
2737
- (async () => {
2738
- try {
2739
- await self.shell.sourceFile('/etc/profile');
2740
- }
2741
- catch { }
2742
- try {
2743
- await self.shell.sourceFile('/home/user/.nimbusrc');
2744
- }
2745
- catch { }
2746
- })();
2722
+ //
2723
+ // Now, and not inside the workspace, because the login files are the
2724
+ // user's and may name any of the commands registered above. Not awaited:
2725
+ // `shell.start()` runs synchronously and the rc files apply as they
2726
+ // finish, which is what the terminal has always done. A user's broken
2727
+ // rc file must not take the socket down with it.
2728
+ workspace.start().catch((e) => {
2729
+ console.warn('[nimbus] shell start failed:', e?.message || e);
2730
+ });
2747
2731
  ws.send(JSON.stringify({ type: 'ready' }));
2748
2732
  }
2749
2733
  function installShellExecutionFeatures(shell, terminal) {
@@ -61,16 +61,18 @@ export declare const W1_NEXT_ALARM_REASONS_KEY = "w1_next_alarm_reasons";
61
61
  */
62
62
  export declare const SESSION_DESTROYED_KEY = "session_destroyed";
63
63
  /**
64
- * Prefix for the resident-launch journal: one row per launch in flight, keyed
65
- * by the pid it is building for.
64
+ * Prefix for the resident-process journal: one row per resident this session
65
+ * owes the user, keyed by the pid it was built for.
66
66
  *
67
- * A launch spans many turns and holds its state in memory, so an instance
68
- * reset destroys it silently — the process, the terminal and the work all go
69
- * with the object. The row is what a LATER instance reads to know a launch
70
- * ended that way rather than by finishing: a pid at or below the reader's own
71
- * pid base was allocated by a previous generation (see PID_GEN_STRIDE).
72
- * Written before the launch's first byte of work and removed when it settles,
73
- * so a row that outlives its instance is exactly an interrupted launch.
67
+ * A resident holds its state in memory — the process table entry, the facet
68
+ * handle, the terminal — so an instance reset destroys it silently. The row
69
+ * is what a LATER instance reads to know a resident ended that way rather
70
+ * than on purpose: a pid at or below the reader's own pid base was allocated
71
+ * by a previous generation (see PID_GEN_STRIDE). Written (and synced) before
72
+ * the launch's first byte of work, rewritten as `running` when the launch
73
+ * settles, and released only when the PROCESS ends — because the resets this
74
+ * row survives strike after the launch as often as during it (measured live,
75
+ * staging 2026-08-13: every observed reset landed seconds AFTER settle).
74
76
  */
75
77
  export declare const RESIDENT_LAUNCH_KEY_PREFIX = "resident-launch:";
76
78
  /** Prefix for consumed single-use attach bootstrap token ids (`jti`).
@@ -1 +1 @@
1
- {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../../src/session/keys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;+DAC+D;AAC/D,eAAO,MAAM,kBAAkB,mBAAmB,CAAC;AAEnD;2EAC2E;AAC3E,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC;;yDAEyD;AACzD,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAEpD;0DAC0D;AAC1D,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AAEzD;;uBAEuB;AACvB,eAAO,MAAM,eAAe,gBAAgB,CAAC;AAE7C;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,yBAAyB,0BAA0B,CAAC;AAEjE;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AAEzD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,qBAAqB,CAAC;AAE7D;;;4EAG4E;AAC5E,eAAO,MAAM,+BAA+B,0BAA0B,CAAC"}
1
+ {"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../../src/session/keys.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH;+DAC+D;AAC/D,eAAO,MAAM,kBAAkB,mBAAmB,CAAC;AAEnD;2EAC2E;AAC3E,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC;;yDAEyD;AACzD,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAEpD;0DAC0D;AAC1D,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AAEzD;;uBAEuB;AACvB,eAAO,MAAM,eAAe,gBAAgB,CAAC;AAE7C;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,yBAAyB,0BAA0B,CAAC;AAEjE;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AAEzD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,0BAA0B,qBAAqB,CAAC;AAE7D;;;4EAG4E;AAC5E,eAAO,MAAM,+BAA+B,0BAA0B,CAAC"}
@@ -61,16 +61,18 @@ export const W1_NEXT_ALARM_REASONS_KEY = 'w1_next_alarm_reasons';
61
61
  */
62
62
  export const SESSION_DESTROYED_KEY = 'session_destroyed';
63
63
  /**
64
- * Prefix for the resident-launch journal: one row per launch in flight, keyed
65
- * by the pid it is building for.
64
+ * Prefix for the resident-process journal: one row per resident this session
65
+ * owes the user, keyed by the pid it was built for.
66
66
  *
67
- * A launch spans many turns and holds its state in memory, so an instance
68
- * reset destroys it silently — the process, the terminal and the work all go
69
- * with the object. The row is what a LATER instance reads to know a launch
70
- * ended that way rather than by finishing: a pid at or below the reader's own
71
- * pid base was allocated by a previous generation (see PID_GEN_STRIDE).
72
- * Written before the launch's first byte of work and removed when it settles,
73
- * so a row that outlives its instance is exactly an interrupted launch.
67
+ * A resident holds its state in memory — the process table entry, the facet
68
+ * handle, the terminal — so an instance reset destroys it silently. The row
69
+ * is what a LATER instance reads to know a resident ended that way rather
70
+ * than on purpose: a pid at or below the reader's own pid base was allocated
71
+ * by a previous generation (see PID_GEN_STRIDE). Written (and synced) before
72
+ * the launch's first byte of work, rewritten as `running` when the launch
73
+ * settles, and released only when the PROCESS ends — because the resets this
74
+ * row survives strike after the launch as often as during it (measured live,
75
+ * staging 2026-08-13: every observed reset landed seconds AFTER settle).
74
76
  */
75
77
  export const RESIDENT_LAUNCH_KEY_PREFIX = 'resident-launch:';
76
78
  /** Prefix for consumed single-use attach bootstrap token ids (`jti`).
@@ -560,8 +560,18 @@ export declare class NimbusSession extends CloudflareDurableObject {
560
560
  * private API across modules.
561
561
  */
562
562
  _envFlagDefaultOn(name: string): boolean;
563
- initSession(ws: WebSocket): void;
563
+ initSession(ws: WebSocket): Promise<void>;
564
564
  ensureGlobalPrefixDirs(prefix: string): void;
565
+ /**
566
+ * The starter content a fresh Nimbus session shows a user: the banner, the
567
+ * sample files, and the Vite starter app.
568
+ *
569
+ * The base the OS boots on — the mount directories, /etc/passwd, /etc/group,
570
+ * /etc/profile, ~/.nimbusrc — comes from the workspace's own seed, which
571
+ * this calls rather than restates. Both are idempotent, so the workspace
572
+ * re-running it when it composes the shell costs a handful of existence
573
+ * checks and changes nothing.
574
+ */
565
575
  seedFilesystem(): void;
566
576
  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void>;
567
577
  webSocketClose(ws: WebSocket, code?: number, reason?: string, wasClean?: boolean): Promise<void>;