@nimbus-sh/core 0.1.0 → 0.2.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 (60) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/runtime/bash-runner.d.ts +63 -0
  5. package/dist/runtime/bash-runner.d.ts.map +1 -0
  6. package/dist/runtime/bash-runner.generated.d.ts +14 -0
  7. package/dist/runtime/bash-runner.generated.d.ts.map +1 -0
  8. package/dist/runtime/bash-runner.generated.js +13 -0
  9. package/dist/runtime/bash-runner.js +290 -0
  10. package/dist/runtime/cpython-runner.d.ts +86 -0
  11. package/dist/runtime/cpython-runner.d.ts.map +1 -0
  12. package/dist/runtime/cpython-runner.js +425 -0
  13. package/dist/runtime/facet-host.d.ts +159 -0
  14. package/dist/runtime/facet-host.d.ts.map +1 -0
  15. package/dist/runtime/facet-host.js +22 -0
  16. package/dist/runtime/installed-runtimes.d.ts +99 -0
  17. package/dist/runtime/installed-runtimes.d.ts.map +1 -0
  18. package/dist/runtime/installed-runtimes.js +162 -0
  19. package/dist/runtime/local-facet-host.d.ts +38 -0
  20. package/dist/runtime/local-facet-host.d.ts.map +1 -0
  21. package/dist/runtime/local-facet-host.js +171 -0
  22. package/dist/runtime/python-pip.d.ts +38 -0
  23. package/dist/runtime/python-pip.d.ts.map +1 -0
  24. package/dist/runtime/python-pip.js +1063 -0
  25. package/dist/runtime/runtime-manifest.d.ts +86 -0
  26. package/dist/runtime/runtime-manifest.d.ts.map +1 -0
  27. package/dist/runtime/runtime-manifest.js +72 -0
  28. package/dist/runtime/runtime-registry.d.ts +161 -0
  29. package/dist/runtime/runtime-registry.d.ts.map +1 -0
  30. package/dist/runtime/runtime-registry.js +363 -0
  31. package/dist/runtime/vfs-snapshot.d.ts.map +1 -1
  32. package/dist/runtime/vfs-snapshot.js +15 -1
  33. package/dist/runtime/vfs-supervisor.d.ts +22 -0
  34. package/dist/runtime/vfs-supervisor.d.ts.map +1 -0
  35. package/dist/runtime/vfs-supervisor.js +65 -0
  36. package/dist/runtime/virtual-socket-kernel.generated.d.ts +14 -0
  37. package/dist/runtime/virtual-socket-kernel.generated.d.ts.map +1 -0
  38. package/dist/runtime/virtual-socket-kernel.generated.js +13 -0
  39. package/dist/runtime/wasm-runner.d.ts +80 -0
  40. package/dist/runtime/wasm-runner.d.ts.map +1 -0
  41. package/dist/runtime/wasm-runner.js +686 -0
  42. package/dist/workspace/nimbus-workspace.d.ts +16 -0
  43. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  44. package/dist/workspace/nimbus-workspace.js +57 -2
  45. package/package.json +4 -2
  46. package/src/index.ts +9 -0
  47. package/src/runtime/bash-runner.generated.ts +14 -0
  48. package/src/runtime/bash-runner.ts +347 -0
  49. package/src/runtime/cpython-runner.ts +504 -0
  50. package/src/runtime/facet-host.ts +170 -0
  51. package/src/runtime/installed-runtimes.ts +235 -0
  52. package/src/runtime/local-facet-host.ts +205 -0
  53. package/src/runtime/python-pip.ts +1211 -0
  54. package/src/runtime/runtime-manifest.ts +155 -0
  55. package/src/runtime/runtime-registry.ts +510 -0
  56. package/src/runtime/vfs-snapshot.ts +15 -1
  57. package/src/runtime/vfs-supervisor.ts +67 -0
  58. package/src/runtime/virtual-socket-kernel.generated.ts +14 -0
  59. package/src/runtime/wasm-runner.ts +835 -0
  60. package/src/workspace/nimbus-workspace.ts +102 -3
@@ -0,0 +1,504 @@
1
+ /**
2
+ * cpython-runner.ts — `python` / `python3` / `pip`, on CPython 3.13 built for
3
+ * wasm32-wasi.
4
+ *
5
+ * This replaces the Pyodide runner, and the reason is not the interpreter: it
6
+ * is the filesystem. Pyodide is CPython built with Emscripten, so it brings its
7
+ * own MEMFS, and every invocation had to copy the session's files in and diff
8
+ * them back out through vfs-snapshot.ts. That made Python the last runtime with
9
+ * a private, parallel filesystem. This build talks to runtime/wasi/preamble.ts
10
+ * like clang, bash and ruby do — `open()` in Python is the same syscall as
11
+ * `open()` in C — so there is nothing to copy and nothing to diff.
12
+ *
13
+ * What follows from that:
14
+ * - manifestVfs, not snapshotVfs: the facet is given sizes and modes and
15
+ * demand-loads the handful of files the program actually opens.
16
+ * - supervisorPid, not omitSupervisor: a pool without a supervisor can read
17
+ * the seeded manifest and can never write anything back. It looks like it
18
+ * works.
19
+ * - No Python-level socket shim. CPython's _socket is real here, over
20
+ * nimbus-net.c and the host's synthetic paths, so loopback is ordinary
21
+ * socket code rather than a monkey-patch.
22
+ *
23
+ * The interpreter is a WASI reactor (see packages/worker/wasm/python), because
24
+ * a command module's _start runs once and that only covers `python script.py`.
25
+ *
26
+ * FOUR THINGS THIS RUNTIME REDISCOVERED THE HARD WAY, ALL OF WHICH ruby-runner
27
+ * ALREADY KNEW. Read the list rather than finding a fifth:
28
+ * 1. Every entry into the VM goes through WebAssembly.promising, not only the
29
+ * calls known to park — a Suspending import traps on an unpromised stack
30
+ * even when it returns a plain integer.
31
+ * 2. The supervisor is adopted AFTER __wasiInitFS, which clears it on purpose,
32
+ * and the facet drains queued writes in a `finally`.
33
+ * 3. modes are seeded `{ '': 7, tmp: 7, home: 7 }` ahead of the manifest,
34
+ * because manifestVfs's walk skips the empty root — without it the preopen
35
+ * at '/' is mode 0 and every traversal under it is EACCES.
36
+ * 4. The loader pool is built per invocation, never cached: supervisorPid is
37
+ * baked into the SUPERVISOR binding at construction, so a held pool hands
38
+ * every later caller the first caller's write credential.
39
+ *
40
+ * One ordering in cpythonRunFacetFn is load-bearing and looks redundant: the
41
+ * supervisor stub is PUBLISHED on globalThis and only then adopted, because
42
+ * __wasiInitFS clears the adoption on purpose and the boot re-adopts it from
43
+ * there afterwards. Adopting once at the entry and deleting the Reflect.set
44
+ * leaves a guest that reads the seeded filesystem and silently writes nowhere —
45
+ * every write queued, none landed, no error anywhere. Ruby carries the same
46
+ * pair for the same reason. The drain in the `finally` is the other half: a
47
+ * program that wrote a file and then raised still wrote the file.
48
+ */
49
+
50
+ import type { Command, CommandContext } from '../substrate/lifo/commands/types.js';
51
+ import { resolveVfsPath } from '../vfs/path.js';
52
+ import type { CredentialedVfs, SqliteVFS } from '../vfs/sqlite-vfs.js';
53
+ import { z } from 'zod/v4';
54
+ import { hasLeadingCliFlag } from './cli-flags.js';
55
+ import { CPYTHON_PREAMBLE_TAIL } from './cpython-preamble.js';
56
+ import { PYTHON_SERVER_ADAPTER } from './python-server-adapter.js';
57
+ import type { FacetHost } from './facet-host.js';
58
+ import { requireVfsCred } from './os-contracts.js';
59
+ import {
60
+ buildPipInvocation,
61
+ type PipInvocation,
62
+ PYTHON_SITE_PACKAGES_ROOT,
63
+ type PythonPipRuntimeContext,
64
+ sessionUsesSciVariant,
65
+ } from './python-pip.js';
66
+ import type { RuntimeManifest } from './runtime-manifest.js';
67
+ import { VIRTUAL_SOCKET_KERNEL_SRC } from './virtual-socket-kernel.generated.js';
68
+ import { WASI_INSTANCE_PREAMBLE_SRC } from './wasi-instance.js';
69
+
70
+ const PYTHON_VERSION_FLAGS = new Set(['--version', '-V']);
71
+ const PYTHON_HELP_FLAGS = new Set(['--help', '-h']);
72
+
73
+ /** Where `nimbus install python` stages the interpreter inside the session. */
74
+ const CPYTHON_WASM_REL = 'share/cpython/python.wasm';
75
+ /**
76
+ * The same interpreter with numpy and markupsafe's C speedups linked in, and
77
+ * their Python half. wasm32-wasi has no dlopen, so a compiled package is either
78
+ * in the binary or unavailable; EXTENSIONS.md has why that beat a runtime
79
+ * linker. Chosen per invocation from what the session has installed, so a
80
+ * session that installed none of it pays none of the 7.8 MiB.
81
+ */
82
+ const CPYTHON_SCI_WASM_REL = 'share/cpython/python-sci.wasm';
83
+ const CPYTHON_SCI_PACKAGES_REL = 'lib/sci-packages.zip';
84
+ const CPYTHON_STDLIB_REL = 'lib/python313.zip';
85
+ const CPYTHON_CACERT_REL = 'etc/ssl/cert.pem';
86
+
87
+
88
+ /**
89
+ * The one canonical facet preamble. Composed in exactly one place: a
90
+ * hand-rolled second copy is how ruby-repl once drifted into booting a VM whose
91
+ * language prelude was missing.
92
+ */
93
+ export function buildCPythonPreamble(): string {
94
+ return [
95
+ VIRTUAL_SOCKET_KERNEL_SRC,
96
+ WASI_INSTANCE_PREAMBLE_SRC,
97
+ CPYTHON_PREAMBLE_TAIL,
98
+ ].join('\n');
99
+ }
100
+
101
+ interface ParsedPyArgv {
102
+ mode: 'inline' | 'script' | 'stdin';
103
+ inlineCode: string;
104
+ scriptPath: string;
105
+ scriptArgs: string[];
106
+ error?: string;
107
+ exitCode: number;
108
+ }
109
+
110
+ /**
111
+ * Python's CLI, as far as a sandbox needs it. Flags that only affect an
112
+ * interactive tty or bytecode caching are accepted and ignored; anything else
113
+ * is refused by name rather than silently doing something different.
114
+ */
115
+ function parsePythonArgv(argv: string[]): ParsedPyArgv {
116
+ const fail = (error: string): ParsedPyArgv =>
117
+ ({ mode: 'inline', inlineCode: '', scriptPath: '', scriptArgs: [], exitCode: 2, error });
118
+ let i = 0;
119
+ while (i < argv.length) {
120
+ const a = argv[i];
121
+ if (a === '-c') {
122
+ const code = argv[i + 1];
123
+ if (code === undefined) return fail('Argument expected for the -c option');
124
+ return { mode: 'inline', inlineCode: code, scriptPath: '', scriptArgs: argv.slice(i + 2), exitCode: 0 };
125
+ }
126
+ if (a === '-m') {
127
+ const mod = argv[i + 1];
128
+ if (mod === undefined) return fail('Argument expected for the -m option');
129
+ const rest = argv.slice(i + 2);
130
+ const inlineCode = [
131
+ 'import runpy, sys',
132
+ `sys.argv = ${JSON.stringify([mod, ...rest])}`,
133
+ `runpy.run_module(${JSON.stringify(mod)}, run_name='__main__', alter_sys=True)`,
134
+ ].join('\n');
135
+ return { mode: 'inline', inlineCode, scriptPath: '', scriptArgs: rest, exitCode: 0 };
136
+ }
137
+ if (a === '-') {
138
+ return { mode: 'stdin', inlineCode: '', scriptPath: '-', scriptArgs: argv.slice(i + 1), exitCode: 0 };
139
+ }
140
+ if (!a.startsWith('-')) {
141
+ return { mode: 'script', inlineCode: '', scriptPath: a, scriptArgs: argv.slice(i + 1), exitCode: 0 };
142
+ }
143
+ if (/^-[OBuEItcsx]+$/.test(a)) { i++; continue; }
144
+ return fail(`unknown option: ${a}`);
145
+ }
146
+ // No mode argument. init.ts routes a bare `python` to the REPL before it gets
147
+ // here, so reaching this point means flags without a program.
148
+ return fail("no program given. Use 'python -c \"code\"', 'python -m <module>' or 'python script.py'.");
149
+ }
150
+
151
+ /**
152
+ * Whether this invocation should get a resident process rather than a one-shot.
153
+ * A script or `-m` can bind a port and keep serving; pip never does.
154
+ */
155
+ function shouldRunAsResidentProcess(argv: string[], parsed: ParsedPyArgv, pipMode: boolean): boolean {
156
+ if (pipMode) return false;
157
+ if (parsed.mode === 'script') return true;
158
+ if (argv[0] === '-m' && argv[1] && argv[1] !== 'pip') return true;
159
+ return false;
160
+ }
161
+
162
+ /** `python -m pip ...` is pip, reached the long way round. */
163
+ async function buildPythonModulePipInvocation(
164
+ argv: string[],
165
+ cwd: string,
166
+ vfs: CredentialedVfs,
167
+ runtimeContext: PythonPipRuntimeContext,
168
+ ): Promise<PipInvocation> {
169
+ if (argv[0] !== '-m' || argv[1] !== 'pip') return { mode: 'none', code: '', exitCode: 0 };
170
+ return await buildPipInvocation(argv.slice(2), 'pip', cwd, vfs, runtimeContext);
171
+ }
172
+
173
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
174
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
175
+ }
176
+
177
+ function errorMessage(error: unknown): string {
178
+ return error instanceof Error ? error.message : String(error);
179
+ }
180
+
181
+ export interface CPythonFacetResult {
182
+ stdout: string;
183
+ stderr: string;
184
+ exitCode: number;
185
+ error?: string;
186
+ }
187
+
188
+ /**
189
+ * Facet-side entry. Serialized with fn.toString(), so it captures nothing and
190
+ * names no import: everything it needs is on globalThis, put there by the
191
+ * preamble.
192
+ */
193
+ async function cpythonRunFacetFn(
194
+ args: Record<string, unknown>,
195
+ facetEnv: { SUPERVISOR?: unknown } | undefined,
196
+ ): Promise<CPythonFacetResult> {
197
+ const run = Reflect.get(globalThis, '__cpythonRun') as
198
+ ((a: unknown) => Promise<CPythonFacetResult>) | undefined;
199
+ if (typeof run !== 'function') {
200
+ return {
201
+ stdout: '', stderr: '', exitCode: 127,
202
+ error: 'cpython preamble missing: __cpythonRun not in scope',
203
+ };
204
+ }
205
+ const adopt = Reflect.get(globalThis, '__wasiAdoptSupervisor') as
206
+ ((s: unknown) => void) | undefined;
207
+ const drain = Reflect.get(globalThis, '__wasiDrainPersist') as
208
+ (() => Promise<void>) | undefined;
209
+ const supervisor = facetEnv && facetEnv.SUPERVISOR;
210
+ // Published where the boot re-adopts it after the mount: adopting only here
211
+ // would be undone by __wasiInitFS, which clears it on purpose.
212
+ if (supervisor) Reflect.set(globalThis, '__nimbusPySupervisor', supervisor);
213
+ adopt?.(supervisor);
214
+ try {
215
+ return await run(args);
216
+ } finally {
217
+ // In `finally`, not on the success path: a program that wrote a file and
218
+ // then raised still wrote the file, and those bytes are the user's.
219
+ await drain?.();
220
+ }
221
+ }
222
+
223
+
224
+ /**
225
+ * Start a program that outlives the invocation, and report where it went.
226
+ *
227
+ * A separate dependency rather than a branch, because a resident process is a
228
+ * property of the DEPLOYMENT and not of Python: it needs a substrate that can
229
+ * keep an actor alive between requests and route inbound HTTP into it. A host
230
+ * that has none does not get a degraded version — it gets none, and says so.
231
+ */
232
+ export type CPythonResidentStart = (spawn: {
233
+ /** VFS path of the interpreter. By path, not by value: it is 10.6 MiB. */
234
+ wasmVfsPath: string;
235
+ startArgs: Record<string, unknown>;
236
+ cwd: string;
237
+ command: string;
238
+ }) => Promise<CPythonFacetResult>;
239
+
240
+ export function makeCPythonRunnerFactory(deps: {
241
+ facets: FacetHost;
242
+ vfs: SqliteVFS;
243
+ /** Where a program that keeps serving goes. See {@link CPythonResidentStart}. */
244
+ startResident?: CPythonResidentStart;
245
+ }): (manifest: RuntimeManifest, installRoot: string, binName: string, binKind: string | undefined) =>
246
+ Command {
247
+
248
+ return function cpythonRunnerFactory(manifest, installRoot, binName, _binKind) {
249
+ const findFile = (rel: string): string | null => {
250
+ const entry = manifest.files.find((f) => f.path === rel);
251
+ return entry ? `${installRoot}/${entry.path}` : null;
252
+ };
253
+ const baseWasmVfs = findFile(CPYTHON_WASM_REL);
254
+ const sciWasmVfs = findFile(CPYTHON_SCI_WASM_REL);
255
+ const sciPackagesVfs = findFile(CPYTHON_SCI_PACKAGES_REL);
256
+ const stdlibVfs = findFile(CPYTHON_STDLIB_REL);
257
+ let seedCache:
258
+ { cred: string; cwd: string; revision: number; result: ReturnType<FacetHost['seedFilesystem']> } | null = null;
259
+
260
+ return async function cpythonBinHandler(ctx: CommandContext): Promise<number> {
261
+ const cred = requireVfsCred(ctx.cred, binName);
262
+ const credKey = `${cred.uid}:${cred.gid}:${cred.groups.join(',')}`;
263
+ const vfs = deps.vfs.as(cred);
264
+ const argv: string[] = ctx.args || [];
265
+ const cwd: string = ctx.cwd || '/home/user';
266
+
267
+ const pipRuntimeContext: PythonPipRuntimeContext = {
268
+ // No Pyodide lockfile: there is no curated wheel index behind this
269
+ // interpreter, so pip resolves against PyPI like anywhere else.
270
+ pyodideLockfileText: null,
271
+ runtimeArtifacts: manifest.runtime_artifacts || [],
272
+ };
273
+ const isPipBin = _binKind === 'pip' || binName === 'pip' || binName === 'pip3';
274
+ const pipInvocation = isPipBin
275
+ ? await buildPipInvocation(argv, binName, cwd, vfs, pipRuntimeContext)
276
+ : await buildPythonModulePipInvocation(argv, cwd, vfs, pipRuntimeContext);
277
+ if (pipInvocation.error) {
278
+ ctx.stderr.write(`${binName}: ${pipInvocation.error}\n`);
279
+ return pipInvocation.exitCode;
280
+ }
281
+
282
+ if (pipInvocation.mode !== 'pip' && hasLeadingCliFlag(argv, PYTHON_VERSION_FLAGS)) {
283
+ ctx.stdout.write('Python 3.13.14 (CPython, wasm32-wasi, Nimbus runtime)\n');
284
+ return 0;
285
+ }
286
+ if (pipInvocation.mode !== 'pip' && hasLeadingCliFlag(argv, PYTHON_HELP_FLAGS)) {
287
+ ctx.stdout.write(`usage: ${binName} [option] ... [-c cmd | -m mod | file | -] [arg] ...\n`);
288
+ ctx.stdout.write('Nimbus CPython 3.13 runtime (wasm32-wasi).\n');
289
+ ctx.stdout.write('Supported: -c <code>, -m <module>, <file.py>, stdin via -, and the session filesystem directly.\n');
290
+ ctx.stdout.write('zlib, lzma, bz2, hashlib, ssl, sqlite3 and sockets are built in; pure-Python wheels install with pip.\n');
291
+ return 0;
292
+ }
293
+
294
+ // Which interpreter this invocation gets. The sci variant is chosen from
295
+ // what the session has installed, never from a guess about what this
296
+ // program will import — the whole point of keying on state is that it is
297
+ // also right for `python -c` naming a module in a variable. Falling back
298
+ // when the variant is absent keeps a session installed before the variant
299
+ // shipped working, on the base interpreter, rather than failing to start.
300
+ const wantsSci = sessionUsesSciVariant(vfs)
301
+ && sciWasmVfs !== null && vfs.exists(sciWasmVfs);
302
+ const wasmVfs = wantsSci ? sciWasmVfs : baseWasmVfs;
303
+ const sciPackagesPath = wantsSci && sciPackagesVfs && vfs.exists(sciPackagesVfs)
304
+ ? sciPackagesVfs
305
+ : null;
306
+
307
+ if (!wasmVfs || !vfs.exists(wasmVfs)) {
308
+ ctx.stderr.write(`${binName}: python.wasm missing (re-run 'nimbus install python')\n`);
309
+ return 127;
310
+ }
311
+ if (!stdlibVfs || !vfs.exists(stdlibVfs)) {
312
+ ctx.stderr.write(`${binName}: python313.zip missing (re-run 'nimbus install python')\n`);
313
+ return 127;
314
+ }
315
+
316
+ const parsed: ParsedPyArgv = pipInvocation.mode === 'pip'
317
+ ? { mode: 'inline', inlineCode: pipInvocation.code, scriptPath: '', scriptArgs: [], exitCode: 0 }
318
+ : parsePythonArgv(argv);
319
+ if (parsed.error) {
320
+ ctx.stderr.write(`${binName}: ${parsed.error}\n`);
321
+ return parsed.exitCode;
322
+ }
323
+
324
+ let userCode = '';
325
+ let progName = binName;
326
+ let pyArgv: string[] = [binName];
327
+ if (parsed.mode === 'inline') {
328
+ userCode = parsed.inlineCode;
329
+ pyArgv = ['-c', ...parsed.scriptArgs];
330
+ } else if (parsed.mode === 'script') {
331
+ const absPath = resolveVfsPath(parsed.scriptPath, cwd);
332
+ try {
333
+ if (!vfs.exists(absPath)) {
334
+ ctx.stderr.write(`${binName}: can't open file '${parsed.scriptPath}': [Errno 2] No such file or directory\n`);
335
+ return 2;
336
+ }
337
+ userCode = new TextDecoder('utf-8').decode(vfs.readFile(absPath));
338
+ } catch (e: unknown) {
339
+ ctx.stderr.write(`${binName}: ${parsed.scriptPath}: ${errorMessage(e)}\n`);
340
+ return 1;
341
+ }
342
+ progName = parsed.scriptPath;
343
+ pyArgv = [parsed.scriptPath, ...parsed.scriptArgs];
344
+ } else {
345
+ const stdinReader = ctx.stdin;
346
+ userCode = (stdinReader && typeof stdinReader.read === 'function' ? await stdinReader.read() : '') ?? '';
347
+ pyArgv = ['-', ...parsed.scriptArgs];
348
+ }
349
+
350
+ // sys.argv is set from Python rather than from WASI argv: the reactor has
351
+ // no argv of its own, and this keeps the one place that decides what the
352
+ // program sees in TypeScript.
353
+ const prelude = [
354
+ PYTHON_SERVER_ADAPTER,
355
+ 'import sys',
356
+ `sys.argv = ${JSON.stringify(pyArgv)}`,
357
+ // The variant's own packages. zipimport reads them straight out of the
358
+ // session filesystem, so they need no unpacking and no manifest entry
359
+ // beyond the archive itself.
360
+ ...(sciPackagesPath
361
+ ? [`sys.path.insert(0, ${JSON.stringify(`/${sciPackagesPath.replace(/^\/+/, '')}`)})`]
362
+ : []),
363
+ `sys.path.insert(0, ${JSON.stringify(`/${PYTHON_SITE_PACKAGES_ROOT}`)})`,
364
+ `sys.path.insert(0, ${JSON.stringify(cwd)})`,
365
+ // WASI has no process cwd, so wasi-libc starts every guest at '/'.
366
+ // Leaving it there silently reroutes every relative path a program
367
+ // opens — the shell says the user is in /home/user and Python resolves
368
+ // against the root.
369
+ 'import os',
370
+ 'try:',
371
+ ` os.chdir(${JSON.stringify(cwd)})`,
372
+ 'except OSError:',
373
+ ' pass',
374
+ ].join('\n');
375
+
376
+ const cacertVfs = findFile(CPYTHON_CACERT_REL);
377
+ const userEnv: Record<string, string> = { ...(ctx.env || {}) };
378
+ if (!userEnv.HOME) userEnv.HOME = '/home/user';
379
+ if (!userEnv.PYTHONUNBUFFERED) userEnv.PYTHONUNBUFFERED = '1';
380
+ // Without this OpenSSL has no trust anchors at all — there is no
381
+ // /etc/ssl on a Nimbus session — and every HTTPS request fails
382
+ // verification with a message about a missing local issuer rather than
383
+ // about a missing bundle.
384
+ if (!userEnv.SSL_CERT_FILE && cacertVfs) userEnv.SSL_CERT_FILE = `/${cacertVfs.replace(/^\/+/, '')}`;
385
+
386
+ // A manifest, not a copy: sizes and modes only, with the facet demand-
387
+ // loading whatever the program opens. The stdlib zip is covered by it
388
+ // like any other file, which is the whole point of not having a private
389
+ // filesystem any more.
390
+ const stdlibDir = stdlibVfs.replace(/\/[^/]+$/, '');
391
+ // Every runtime file the interpreter is told about has to be a root of
392
+ // its own. The trust store was reachable only while the cwd happened to
393
+ // be an ancestor of the install — from ~ the walk swept the whole runtime
394
+ // tree in — so `cd` into any subdirectory and SSL_CERT_FILE pointed at a
395
+ // path the facet could not see, and every pip install failed
396
+ // CERTIFICATE_VERIFY_FAILED with the bundle sitting right there.
397
+ const cacertDir = cacertVfs ? cacertVfs.replace(/\/[^/]+$/, '') : null;
398
+ const revision = Math.max(
399
+ vfs.revision(cwd),
400
+ vfs.revision(PYTHON_SITE_PACKAGES_ROOT),
401
+ vfs.revision(stdlibVfs),
402
+ );
403
+ let fsSeed = seedCache && seedCache.cred === credKey
404
+ && seedCache.cwd === cwd && seedCache.revision === revision
405
+ ? seedCache.result
406
+ : null;
407
+ if (!fsSeed) {
408
+ // The host decides what "seed" means: a manifest the facet demand-loads
409
+ // against, or the bytes themselves. Which one it is follows from
410
+ // whether the host can park a guest mid-syscall, and nothing here
411
+ // depends on the answer.
412
+ fsSeed = deps.facets.seedFilesystem(vfs, cwd, {
413
+ extraRoots: [PYTHON_SITE_PACKAGES_ROOT, stdlibDir, ...(cacertDir ? [cacertDir] : [])],
414
+ revision,
415
+ });
416
+ seedCache = { cred: credKey, cwd, revision, result: fsSeed };
417
+ }
418
+ if ('error' in fsSeed) {
419
+ ctx.stderr.write(`${binName}: ${fsSeed.error}\n`);
420
+ return 1;
421
+ }
422
+
423
+ const snapshot = fsSeed.snapshot as unknown as {
424
+ files: Record<string, string>; sizes?: Record<string, number>; revision?: number;
425
+ };
426
+ // Opened per invocation, not cached: the supervisor capability is bound
427
+ // to this process's pid when the facet opens, so one held across calls
428
+ // would hand every later caller the first caller's write credential.
429
+ const facet = deps.facets.open({
430
+ // The variant is in the tag because a host's constructor-time wasm
431
+ // fingerprint is name:length:first-byte:last-byte, not a content hash.
432
+ // Two variants differ by megabytes so they would not collide today, but
433
+ // a warm slot serving the wrong interpreter is not a failure worth
434
+ // leaving to a size coincidence.
435
+ tag: wantsSci ? 'cpython-runner:sci' : 'cpython-runner',
436
+ concurrency: 1,
437
+ // Never absent. Without the capability the facet reads its seed and can
438
+ // never write anything back — the program appears to run and its output
439
+ // never reaches the session.
440
+ syscalls: { vfs, pid: ctx.pid },
441
+ preamble: buildCPythonPreamble(),
442
+ wasmModules: { 'python.wasm': toArrayBuffer(vfs.readFile(wasmVfs)) },
443
+ });
444
+
445
+ const facetArgs = {
446
+ userCode: `${prelude}\n${userCode}`,
447
+ pyArgv,
448
+ userEnv,
449
+ progName,
450
+ cwd,
451
+ pythonHome: `/${installRoot.replace(/^\/+/, '')}`,
452
+ supervisorPid: ctx.pid,
453
+ fsSnapshot: snapshot,
454
+ };
455
+
456
+ // A script or `-m` can bind a port and keep serving, and such a program
457
+ // is not finished when it stops producing output — it is finished when it
458
+ // stops running. Pyodide gave those a dedicated socket process; that
459
+ // spawn is not ported yet, so for now they get a one-shot facet with a
460
+ // budget long enough not to cut a server off mid-request. Porting the
461
+ // resident process is the last piece, and until it lands a server holds
462
+ // its facet rather than being driven by inbound requests.
463
+ const resident = shouldRunAsResidentProcess(argv, parsed, pipInvocation.mode === 'pip');
464
+
465
+ if (resident) {
466
+ facet.dispose();
467
+ if (!deps.startResident) {
468
+ ctx.stderr.write(
469
+ `${binName}: this program keeps running after it starts, and this host has no `
470
+ + 'process substrate to keep it on\n',
471
+ );
472
+ return 1;
473
+ }
474
+ const command = [binName, ...argv].map((part) =>
475
+ (/^[A-Za-z0-9_./:=@+-]+$/.test(part) ? part : JSON.stringify(part))).join(' ');
476
+ const spawnResult = await deps.startResident(
477
+ { wasmVfsPath: wasmVfs, startArgs: facetArgs, cwd, command });
478
+ if (spawnResult.stdout) ctx.stdout.write(spawnResult.stdout);
479
+ if (spawnResult.stderr) ctx.stderr.write(spawnResult.stderr);
480
+ return spawnResult.exitCode;
481
+ }
482
+
483
+ let result: CPythonFacetResult;
484
+ try {
485
+ result = await facet.submit(cpythonRunFacetFn, facetArgs, {
486
+ timeoutMs: 120_000,
487
+ });
488
+ } catch (e: unknown) {
489
+ ctx.stderr.write(`${binName}: ${errorMessage(e)}\n`);
490
+ return 1;
491
+ } finally {
492
+ facet.dispose();
493
+ }
494
+
495
+ if (result.stdout) ctx.stdout.write(result.stdout);
496
+ if (result.stderr) ctx.stderr.write(result.stderr);
497
+ if (result.error) {
498
+ ctx.stderr.write(`${binName}: ${result.error}\n`);
499
+ return result.exitCode || 1;
500
+ }
501
+ return result.exitCode;
502
+ };
503
+ };
504
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * facet-host.ts — where a program carrying compiled WebAssembly actually runs.
3
+ *
4
+ * Every WASM runtime Nimbus ships (bash, CPython, Ruby, clang, the generic
5
+ * `wasm-runner`) has the same shape: a self-contained scheduler shipped as
6
+ * SOURCE, a table of wasm modules that must be compiled before the scheduler
7
+ * can reach them, and a function submitted into that scope with plain data and
8
+ * back. Only the substrate underneath differs.
9
+ *
10
+ * On workerd that substrate is a dynamic worker: request-time
11
+ * `WebAssembly.instantiate(bytes)` is refused by CSP, so the bytes ride inside
12
+ * the loader's modules map and are compiled during the inner worker's
13
+ * module-load phase — the one phase where wasm code generation is permitted.
14
+ * Off workerd there is no such rule and the same scope is built in place
15
+ * ({@link ./local-facet-host.ts}).
16
+ *
17
+ * NOT named after either. `Facet` is what Nimbus has always called this — one
18
+ * program, its own module scope, its own wasm table — and the port is the
19
+ * whole of what a runtime needs from it, so a runner cannot tell which
20
+ * substrate answered and never asks.
21
+ */
22
+
23
+ import type { CredentialedVfs } from '../vfs/sqlite-vfs.js';
24
+ import type { WasiFsSnapshot } from './wasi-instance.js';
25
+ import type { WasiParking } from './wasi/types.js';
26
+
27
+ /**
28
+ * A function submitted into a facet.
29
+ *
30
+ * It is SERIALIZED on any host that runs it elsewhere, so it must be
31
+ * self-contained: closure references do not survive the crossing, and neither
32
+ * do module imports. Names the spec's `preamble` declares ARE in scope — that
33
+ * is what the preamble is for — and everything else travels as `args`.
34
+ *
35
+ * `bindings` carries whatever capabilities the host minted for this facet;
36
+ * `SUPERVISOR` is the session syscall capability, present only when the spec
37
+ * named a pid to act as.
38
+ */
39
+ export type FacetFn<A, R> = (args: A, bindings: FacetBindings) => R | Promise<R>;
40
+
41
+ /** Capabilities handed to the facet's function as its second argument. */
42
+ export interface FacetBindings {
43
+ /** The session's syscall capability, bound to {@link FacetSpec.supervisorPid}. */
44
+ readonly SUPERVISOR?: unknown;
45
+ }
46
+
47
+ export interface FacetSpec {
48
+ /** Names the facet in diagnostics and in the host's own reuse key. */
49
+ tag: string;
50
+ /**
51
+ * Source evaluated once, before any function is submitted, in the scope those
52
+ * functions are evaluated in. This is how a runtime's scheduler and its WASI
53
+ * layer get there: they are far too large to travel per call, and they hold
54
+ * the state a session needs between calls (bash's process tree, CPython's
55
+ * `__main__`).
56
+ */
57
+ preamble?: string;
58
+ /**
59
+ * Wasm images the facet's scope exposes as compiled `WebAssembly.Module`s on
60
+ * `globalThis.__NIMBUS_WASM[<key>]`. Compiled by the host, because on workerd
61
+ * the caller is not allowed to.
62
+ */
63
+ wasmModules?: Record<string, ArrayBuffer>;
64
+ /**
65
+ * The session filesystem this facet's syscalls act on, and the process they
66
+ * act as. Absent for a facet that makes no syscall back into the session.
67
+ *
68
+ * Both halves, because the two hosts reach the same authority differently: a
69
+ * dynamic worker is a different isolate, so it is handed a capability minted
70
+ * for the PID and routed back to the session; a facet in the caller's own
71
+ * isolate is handed the credentialed VIEW. Naming only the pid would leave
72
+ * the second host nothing to serve from, and naming only the view would
73
+ * leave the first nothing to mint.
74
+ *
75
+ * Not a boolean: the supervisor derives the WRITE credential from the pid,
76
+ * so a facet given the capability without one can read the filesystem it was
77
+ * seeded with and silently write nowhere.
78
+ */
79
+ syscalls?: FacetSyscalls;
80
+ /** Facets the host may keep warm for this spec. Default 1. */
81
+ concurrency?: number;
82
+ }
83
+
84
+ /** The session a facet's syscalls reach, and who they reach it as. */
85
+ export interface FacetSyscalls {
86
+ readonly vfs: CredentialedVfs;
87
+ readonly pid: number;
88
+ }
89
+
90
+ /**
91
+ * A seeded WASI filesystem: what {@link FacetHost.seedFilesystem} produced, and
92
+ * how much of the session it had to carry to produce it.
93
+ */
94
+ export interface FacetFilesystemSeed {
95
+ snapshot: WasiFsSnapshot;
96
+ files: number;
97
+ bytes: number;
98
+ }
99
+
100
+ /** What a runner knows about the subtree its guest should see. */
101
+ export interface FacetFilesystemOptions {
102
+ /** Directories outside `root` the program must also reach. */
103
+ extraRoots?: Iterable<string>;
104
+ /**
105
+ * The session revision the seed describes, when the caller has computed one.
106
+ * A host that serves reads back stamps it, which is what marks the seed a
107
+ * CACHE rather than the whole world; one that cannot has no use for it.
108
+ */
109
+ revision?: number;
110
+ }
111
+
112
+ export interface FacetSubmitOptions {
113
+ /**
114
+ * Deadline for this call, honoured by hosts that can abandon a facet.
115
+ *
116
+ * A host sharing the caller's thread cannot: a wasm guest in a synchronous
117
+ * loop holds the only thread there is, and nothing observes a timer until it
118
+ * yields. Such a host says so ({@link ./local-facet-host.ts}) rather than
119
+ * racing a timer and returning while the guest runs on.
120
+ */
121
+ timeoutMs?: number;
122
+ /** Wasm images for this call alone, merged over {@link FacetSpec.wasmModules}. */
123
+ wasmModules?: Record<string, ArrayBuffer>;
124
+ }
125
+
126
+ /**
127
+ * One facet's scope, for as long as a runtime needs it.
128
+ *
129
+ * Held rather than per-call because the scope IS the session: bash boots on the
130
+ * first submit and is fed on every one after, and a scope rebuilt between them
131
+ * would hand the second call a shell that had never run.
132
+ */
133
+ export interface Facet {
134
+ submit<A, R>(fn: FacetFn<A, R>, args: A, options?: FacetSubmitOptions): Promise<Awaited<R>>;
135
+ /** Idempotent. The scope and everything it holds are dropped. */
136
+ dispose(): void;
137
+ }
138
+
139
+ export interface FacetHost {
140
+ /**
141
+ * Whether a guest in this host can be SUSPENDED in the middle of a syscall.
142
+ *
143
+ * The one place the substrates are not interchangeable, so it is stated
144
+ * rather than smoothed over — the same posture as `ProcessImageDelivery` in
145
+ * the process fabric. Everything else about running a wasm program is the
146
+ * same code either way; this is not, and it decides two things at once:
147
+ * which import table the guest gets ({@link WasiParking}), and how much
148
+ * filesystem it must be handed before it starts.
149
+ *
150
+ * `jspi` — the host can park the guest on a promise, so a syscall may go
151
+ * back to the session mid-instruction and the seed can be a manifest.
152
+ * `none` — it cannot; V8 traps any call into a suspending import off a
153
+ * stack `WebAssembly.promising` did not enter. Every syscall must answer
154
+ * synchronously, so the seed has to BE the filesystem.
155
+ */
156
+ readonly parking: WasiParking;
157
+ /**
158
+ * Hand a facet the part of the session filesystem its program needs.
159
+ *
160
+ * The host decides the strategy, because the strategy IS the consequence of
161
+ * {@link FacetHost.parking} and nothing about the program bears on it. A
162
+ * runner names the roots and gets a seed; it never learns which kind it got.
163
+ */
164
+ seedFilesystem(
165
+ vfs: CredentialedVfs,
166
+ root: string,
167
+ options?: FacetFilesystemOptions,
168
+ ): FacetFilesystemSeed | { error: string };
169
+ open(spec: FacetSpec): Facet;
170
+ }