@nimbus-sh/core 0.1.0 → 0.3.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 (64) hide show
  1. package/dist/index.d.ts +5 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2 -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-package.d.ts +63 -0
  29. package/dist/runtime/runtime-package.d.ts.map +1 -0
  30. package/dist/runtime/runtime-package.js +66 -0
  31. package/dist/runtime/runtime-registry.d.ts +162 -0
  32. package/dist/runtime/runtime-registry.d.ts.map +1 -0
  33. package/dist/runtime/runtime-registry.js +363 -0
  34. package/dist/runtime/vfs-snapshot.d.ts.map +1 -1
  35. package/dist/runtime/vfs-snapshot.js +15 -1
  36. package/dist/runtime/vfs-supervisor.d.ts +22 -0
  37. package/dist/runtime/vfs-supervisor.d.ts.map +1 -0
  38. package/dist/runtime/vfs-supervisor.js +65 -0
  39. package/dist/runtime/virtual-socket-kernel.generated.d.ts +14 -0
  40. package/dist/runtime/virtual-socket-kernel.generated.d.ts.map +1 -0
  41. package/dist/runtime/virtual-socket-kernel.generated.js +13 -0
  42. package/dist/runtime/wasm-runner.d.ts +80 -0
  43. package/dist/runtime/wasm-runner.d.ts.map +1 -0
  44. package/dist/runtime/wasm-runner.js +686 -0
  45. package/dist/workspace/nimbus-workspace.d.ts +116 -20
  46. package/dist/workspace/nimbus-workspace.d.ts.map +1 -1
  47. package/dist/workspace/nimbus-workspace.js +238 -45
  48. package/package.json +4 -2
  49. package/src/index.ts +16 -0
  50. package/src/runtime/bash-runner.generated.ts +14 -0
  51. package/src/runtime/bash-runner.ts +347 -0
  52. package/src/runtime/cpython-runner.ts +504 -0
  53. package/src/runtime/facet-host.ts +170 -0
  54. package/src/runtime/installed-runtimes.ts +235 -0
  55. package/src/runtime/local-facet-host.ts +205 -0
  56. package/src/runtime/python-pip.ts +1211 -0
  57. package/src/runtime/runtime-manifest.ts +155 -0
  58. package/src/runtime/runtime-package.ts +114 -0
  59. package/src/runtime/runtime-registry.ts +511 -0
  60. package/src/runtime/vfs-snapshot.ts +15 -1
  61. package/src/runtime/vfs-supervisor.ts +67 -0
  62. package/src/runtime/virtual-socket-kernel.generated.ts +14 -0
  63. package/src/runtime/wasm-runner.ts +835 -0
  64. package/src/workspace/nimbus-workspace.ts +349 -54
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cpython-runner.d.ts","sourceRoot":"","sources":["../../src/runtime/cpython-runner.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAkB,MAAM,qCAAqC,CAAC;AAEnF,OAAO,KAAK,EAAmB,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAKvE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AASjD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAsB7D;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAM7C;AAkFD,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAsCD;;;;;;;GAOG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE;IACzC,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;AAElC,wBAAgB,wBAAwB,CAAC,IAAI,EAAE;IAC7C,MAAM,EAAE,SAAS,CAAC;IAClB,GAAG,EAAE,SAAS,CAAC;IACf,iFAAiF;IACjF,aAAa,CAAC,EAAE,oBAAoB,CAAC;CACtC,GAAG,CAAC,QAAQ,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,KAC7F,OAAO,CAkQV"}
@@ -0,0 +1,425 @@
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
+ import { resolveVfsPath } from '../vfs/path.js';
50
+ import { hasLeadingCliFlag } from './cli-flags.js';
51
+ import { CPYTHON_PREAMBLE_TAIL } from './cpython-preamble.js';
52
+ import { PYTHON_SERVER_ADAPTER } from './python-server-adapter.js';
53
+ import { requireVfsCred } from './os-contracts.js';
54
+ import { buildPipInvocation, PYTHON_SITE_PACKAGES_ROOT, sessionUsesSciVariant, } from './python-pip.js';
55
+ import { VIRTUAL_SOCKET_KERNEL_SRC } from './virtual-socket-kernel.generated.js';
56
+ import { WASI_INSTANCE_PREAMBLE_SRC } from './wasi-instance.js';
57
+ const PYTHON_VERSION_FLAGS = new Set(['--version', '-V']);
58
+ const PYTHON_HELP_FLAGS = new Set(['--help', '-h']);
59
+ /** Where `nimbus install python` stages the interpreter inside the session. */
60
+ const CPYTHON_WASM_REL = 'share/cpython/python.wasm';
61
+ /**
62
+ * The same interpreter with numpy and markupsafe's C speedups linked in, and
63
+ * their Python half. wasm32-wasi has no dlopen, so a compiled package is either
64
+ * in the binary or unavailable; EXTENSIONS.md has why that beat a runtime
65
+ * linker. Chosen per invocation from what the session has installed, so a
66
+ * session that installed none of it pays none of the 7.8 MiB.
67
+ */
68
+ const CPYTHON_SCI_WASM_REL = 'share/cpython/python-sci.wasm';
69
+ const CPYTHON_SCI_PACKAGES_REL = 'lib/sci-packages.zip';
70
+ const CPYTHON_STDLIB_REL = 'lib/python313.zip';
71
+ const CPYTHON_CACERT_REL = 'etc/ssl/cert.pem';
72
+ /**
73
+ * The one canonical facet preamble. Composed in exactly one place: a
74
+ * hand-rolled second copy is how ruby-repl once drifted into booting a VM whose
75
+ * language prelude was missing.
76
+ */
77
+ export function buildCPythonPreamble() {
78
+ return [
79
+ VIRTUAL_SOCKET_KERNEL_SRC,
80
+ WASI_INSTANCE_PREAMBLE_SRC,
81
+ CPYTHON_PREAMBLE_TAIL,
82
+ ].join('\n');
83
+ }
84
+ /**
85
+ * Python's CLI, as far as a sandbox needs it. Flags that only affect an
86
+ * interactive tty or bytecode caching are accepted and ignored; anything else
87
+ * is refused by name rather than silently doing something different.
88
+ */
89
+ function parsePythonArgv(argv) {
90
+ const fail = (error) => ({ mode: 'inline', inlineCode: '', scriptPath: '', scriptArgs: [], exitCode: 2, error });
91
+ let i = 0;
92
+ while (i < argv.length) {
93
+ const a = argv[i];
94
+ if (a === '-c') {
95
+ const code = argv[i + 1];
96
+ if (code === undefined)
97
+ return fail('Argument expected for the -c option');
98
+ return { mode: 'inline', inlineCode: code, scriptPath: '', scriptArgs: argv.slice(i + 2), exitCode: 0 };
99
+ }
100
+ if (a === '-m') {
101
+ const mod = argv[i + 1];
102
+ if (mod === undefined)
103
+ return fail('Argument expected for the -m option');
104
+ const rest = argv.slice(i + 2);
105
+ const inlineCode = [
106
+ 'import runpy, sys',
107
+ `sys.argv = ${JSON.stringify([mod, ...rest])}`,
108
+ `runpy.run_module(${JSON.stringify(mod)}, run_name='__main__', alter_sys=True)`,
109
+ ].join('\n');
110
+ return { mode: 'inline', inlineCode, scriptPath: '', scriptArgs: rest, exitCode: 0 };
111
+ }
112
+ if (a === '-') {
113
+ return { mode: 'stdin', inlineCode: '', scriptPath: '-', scriptArgs: argv.slice(i + 1), exitCode: 0 };
114
+ }
115
+ if (!a.startsWith('-')) {
116
+ return { mode: 'script', inlineCode: '', scriptPath: a, scriptArgs: argv.slice(i + 1), exitCode: 0 };
117
+ }
118
+ if (/^-[OBuEItcsx]+$/.test(a)) {
119
+ i++;
120
+ continue;
121
+ }
122
+ return fail(`unknown option: ${a}`);
123
+ }
124
+ // No mode argument. init.ts routes a bare `python` to the REPL before it gets
125
+ // here, so reaching this point means flags without a program.
126
+ return fail("no program given. Use 'python -c \"code\"', 'python -m <module>' or 'python script.py'.");
127
+ }
128
+ /**
129
+ * Whether this invocation should get a resident process rather than a one-shot.
130
+ * A script or `-m` can bind a port and keep serving; pip never does.
131
+ */
132
+ function shouldRunAsResidentProcess(argv, parsed, pipMode) {
133
+ if (pipMode)
134
+ return false;
135
+ if (parsed.mode === 'script')
136
+ return true;
137
+ if (argv[0] === '-m' && argv[1] && argv[1] !== 'pip')
138
+ return true;
139
+ return false;
140
+ }
141
+ /** `python -m pip ...` is pip, reached the long way round. */
142
+ async function buildPythonModulePipInvocation(argv, cwd, vfs, runtimeContext) {
143
+ if (argv[0] !== '-m' || argv[1] !== 'pip')
144
+ return { mode: 'none', code: '', exitCode: 0 };
145
+ return await buildPipInvocation(argv.slice(2), 'pip', cwd, vfs, runtimeContext);
146
+ }
147
+ function toArrayBuffer(bytes) {
148
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
149
+ }
150
+ function errorMessage(error) {
151
+ return error instanceof Error ? error.message : String(error);
152
+ }
153
+ /**
154
+ * Facet-side entry. Serialized with fn.toString(), so it captures nothing and
155
+ * names no import: everything it needs is on globalThis, put there by the
156
+ * preamble.
157
+ */
158
+ async function cpythonRunFacetFn(args, facetEnv) {
159
+ const run = Reflect.get(globalThis, '__cpythonRun');
160
+ if (typeof run !== 'function') {
161
+ return {
162
+ stdout: '', stderr: '', exitCode: 127,
163
+ error: 'cpython preamble missing: __cpythonRun not in scope',
164
+ };
165
+ }
166
+ const adopt = Reflect.get(globalThis, '__wasiAdoptSupervisor');
167
+ const drain = Reflect.get(globalThis, '__wasiDrainPersist');
168
+ const supervisor = facetEnv && facetEnv.SUPERVISOR;
169
+ // Published where the boot re-adopts it after the mount: adopting only here
170
+ // would be undone by __wasiInitFS, which clears it on purpose.
171
+ if (supervisor)
172
+ Reflect.set(globalThis, '__nimbusPySupervisor', supervisor);
173
+ adopt?.(supervisor);
174
+ try {
175
+ return await run(args);
176
+ }
177
+ finally {
178
+ // In `finally`, not on the success path: a program that wrote a file and
179
+ // then raised still wrote the file, and those bytes are the user's.
180
+ await drain?.();
181
+ }
182
+ }
183
+ export function makeCPythonRunnerFactory(deps) {
184
+ return function cpythonRunnerFactory(manifest, installRoot, binName, _binKind) {
185
+ const findFile = (rel) => {
186
+ const entry = manifest.files.find((f) => f.path === rel);
187
+ return entry ? `${installRoot}/${entry.path}` : null;
188
+ };
189
+ const baseWasmVfs = findFile(CPYTHON_WASM_REL);
190
+ const sciWasmVfs = findFile(CPYTHON_SCI_WASM_REL);
191
+ const sciPackagesVfs = findFile(CPYTHON_SCI_PACKAGES_REL);
192
+ const stdlibVfs = findFile(CPYTHON_STDLIB_REL);
193
+ let seedCache = null;
194
+ return async function cpythonBinHandler(ctx) {
195
+ const cred = requireVfsCred(ctx.cred, binName);
196
+ const credKey = `${cred.uid}:${cred.gid}:${cred.groups.join(',')}`;
197
+ const vfs = deps.vfs.as(cred);
198
+ const argv = ctx.args || [];
199
+ const cwd = ctx.cwd || '/home/user';
200
+ const pipRuntimeContext = {
201
+ // No Pyodide lockfile: there is no curated wheel index behind this
202
+ // interpreter, so pip resolves against PyPI like anywhere else.
203
+ pyodideLockfileText: null,
204
+ runtimeArtifacts: manifest.runtime_artifacts || [],
205
+ };
206
+ const isPipBin = _binKind === 'pip' || binName === 'pip' || binName === 'pip3';
207
+ const pipInvocation = isPipBin
208
+ ? await buildPipInvocation(argv, binName, cwd, vfs, pipRuntimeContext)
209
+ : await buildPythonModulePipInvocation(argv, cwd, vfs, pipRuntimeContext);
210
+ if (pipInvocation.error) {
211
+ ctx.stderr.write(`${binName}: ${pipInvocation.error}\n`);
212
+ return pipInvocation.exitCode;
213
+ }
214
+ if (pipInvocation.mode !== 'pip' && hasLeadingCliFlag(argv, PYTHON_VERSION_FLAGS)) {
215
+ ctx.stdout.write('Python 3.13.14 (CPython, wasm32-wasi, Nimbus runtime)\n');
216
+ return 0;
217
+ }
218
+ if (pipInvocation.mode !== 'pip' && hasLeadingCliFlag(argv, PYTHON_HELP_FLAGS)) {
219
+ ctx.stdout.write(`usage: ${binName} [option] ... [-c cmd | -m mod | file | -] [arg] ...\n`);
220
+ ctx.stdout.write('Nimbus CPython 3.13 runtime (wasm32-wasi).\n');
221
+ ctx.stdout.write('Supported: -c <code>, -m <module>, <file.py>, stdin via -, and the session filesystem directly.\n');
222
+ ctx.stdout.write('zlib, lzma, bz2, hashlib, ssl, sqlite3 and sockets are built in; pure-Python wheels install with pip.\n');
223
+ return 0;
224
+ }
225
+ // Which interpreter this invocation gets. The sci variant is chosen from
226
+ // what the session has installed, never from a guess about what this
227
+ // program will import — the whole point of keying on state is that it is
228
+ // also right for `python -c` naming a module in a variable. Falling back
229
+ // when the variant is absent keeps a session installed before the variant
230
+ // shipped working, on the base interpreter, rather than failing to start.
231
+ const wantsSci = sessionUsesSciVariant(vfs)
232
+ && sciWasmVfs !== null && vfs.exists(sciWasmVfs);
233
+ const wasmVfs = wantsSci ? sciWasmVfs : baseWasmVfs;
234
+ const sciPackagesPath = wantsSci && sciPackagesVfs && vfs.exists(sciPackagesVfs)
235
+ ? sciPackagesVfs
236
+ : null;
237
+ if (!wasmVfs || !vfs.exists(wasmVfs)) {
238
+ ctx.stderr.write(`${binName}: python.wasm missing (re-run 'nimbus install python')\n`);
239
+ return 127;
240
+ }
241
+ if (!stdlibVfs || !vfs.exists(stdlibVfs)) {
242
+ ctx.stderr.write(`${binName}: python313.zip missing (re-run 'nimbus install python')\n`);
243
+ return 127;
244
+ }
245
+ const parsed = pipInvocation.mode === 'pip'
246
+ ? { mode: 'inline', inlineCode: pipInvocation.code, scriptPath: '', scriptArgs: [], exitCode: 0 }
247
+ : parsePythonArgv(argv);
248
+ if (parsed.error) {
249
+ ctx.stderr.write(`${binName}: ${parsed.error}\n`);
250
+ return parsed.exitCode;
251
+ }
252
+ let userCode = '';
253
+ let progName = binName;
254
+ let pyArgv = [binName];
255
+ if (parsed.mode === 'inline') {
256
+ userCode = parsed.inlineCode;
257
+ pyArgv = ['-c', ...parsed.scriptArgs];
258
+ }
259
+ else if (parsed.mode === 'script') {
260
+ const absPath = resolveVfsPath(parsed.scriptPath, cwd);
261
+ try {
262
+ if (!vfs.exists(absPath)) {
263
+ ctx.stderr.write(`${binName}: can't open file '${parsed.scriptPath}': [Errno 2] No such file or directory\n`);
264
+ return 2;
265
+ }
266
+ userCode = new TextDecoder('utf-8').decode(vfs.readFile(absPath));
267
+ }
268
+ catch (e) {
269
+ ctx.stderr.write(`${binName}: ${parsed.scriptPath}: ${errorMessage(e)}\n`);
270
+ return 1;
271
+ }
272
+ progName = parsed.scriptPath;
273
+ pyArgv = [parsed.scriptPath, ...parsed.scriptArgs];
274
+ }
275
+ else {
276
+ const stdinReader = ctx.stdin;
277
+ userCode = (stdinReader && typeof stdinReader.read === 'function' ? await stdinReader.read() : '') ?? '';
278
+ pyArgv = ['-', ...parsed.scriptArgs];
279
+ }
280
+ // sys.argv is set from Python rather than from WASI argv: the reactor has
281
+ // no argv of its own, and this keeps the one place that decides what the
282
+ // program sees in TypeScript.
283
+ const prelude = [
284
+ PYTHON_SERVER_ADAPTER,
285
+ 'import sys',
286
+ `sys.argv = ${JSON.stringify(pyArgv)}`,
287
+ // The variant's own packages. zipimport reads them straight out of the
288
+ // session filesystem, so they need no unpacking and no manifest entry
289
+ // beyond the archive itself.
290
+ ...(sciPackagesPath
291
+ ? [`sys.path.insert(0, ${JSON.stringify(`/${sciPackagesPath.replace(/^\/+/, '')}`)})`]
292
+ : []),
293
+ `sys.path.insert(0, ${JSON.stringify(`/${PYTHON_SITE_PACKAGES_ROOT}`)})`,
294
+ `sys.path.insert(0, ${JSON.stringify(cwd)})`,
295
+ // WASI has no process cwd, so wasi-libc starts every guest at '/'.
296
+ // Leaving it there silently reroutes every relative path a program
297
+ // opens — the shell says the user is in /home/user and Python resolves
298
+ // against the root.
299
+ 'import os',
300
+ 'try:',
301
+ ` os.chdir(${JSON.stringify(cwd)})`,
302
+ 'except OSError:',
303
+ ' pass',
304
+ ].join('\n');
305
+ const cacertVfs = findFile(CPYTHON_CACERT_REL);
306
+ const userEnv = { ...(ctx.env || {}) };
307
+ if (!userEnv.HOME)
308
+ userEnv.HOME = '/home/user';
309
+ if (!userEnv.PYTHONUNBUFFERED)
310
+ userEnv.PYTHONUNBUFFERED = '1';
311
+ // Without this OpenSSL has no trust anchors at all — there is no
312
+ // /etc/ssl on a Nimbus session — and every HTTPS request fails
313
+ // verification with a message about a missing local issuer rather than
314
+ // about a missing bundle.
315
+ if (!userEnv.SSL_CERT_FILE && cacertVfs)
316
+ userEnv.SSL_CERT_FILE = `/${cacertVfs.replace(/^\/+/, '')}`;
317
+ // A manifest, not a copy: sizes and modes only, with the facet demand-
318
+ // loading whatever the program opens. The stdlib zip is covered by it
319
+ // like any other file, which is the whole point of not having a private
320
+ // filesystem any more.
321
+ const stdlibDir = stdlibVfs.replace(/\/[^/]+$/, '');
322
+ // Every runtime file the interpreter is told about has to be a root of
323
+ // its own. The trust store was reachable only while the cwd happened to
324
+ // be an ancestor of the install — from ~ the walk swept the whole runtime
325
+ // tree in — so `cd` into any subdirectory and SSL_CERT_FILE pointed at a
326
+ // path the facet could not see, and every pip install failed
327
+ // CERTIFICATE_VERIFY_FAILED with the bundle sitting right there.
328
+ const cacertDir = cacertVfs ? cacertVfs.replace(/\/[^/]+$/, '') : null;
329
+ const revision = Math.max(vfs.revision(cwd), vfs.revision(PYTHON_SITE_PACKAGES_ROOT), vfs.revision(stdlibVfs));
330
+ let fsSeed = seedCache && seedCache.cred === credKey
331
+ && seedCache.cwd === cwd && seedCache.revision === revision
332
+ ? seedCache.result
333
+ : null;
334
+ if (!fsSeed) {
335
+ // The host decides what "seed" means: a manifest the facet demand-loads
336
+ // against, or the bytes themselves. Which one it is follows from
337
+ // whether the host can park a guest mid-syscall, and nothing here
338
+ // depends on the answer.
339
+ fsSeed = deps.facets.seedFilesystem(vfs, cwd, {
340
+ extraRoots: [PYTHON_SITE_PACKAGES_ROOT, stdlibDir, ...(cacertDir ? [cacertDir] : [])],
341
+ revision,
342
+ });
343
+ seedCache = { cred: credKey, cwd, revision, result: fsSeed };
344
+ }
345
+ if ('error' in fsSeed) {
346
+ ctx.stderr.write(`${binName}: ${fsSeed.error}\n`);
347
+ return 1;
348
+ }
349
+ const snapshot = fsSeed.snapshot;
350
+ // Opened per invocation, not cached: the supervisor capability is bound
351
+ // to this process's pid when the facet opens, so one held across calls
352
+ // would hand every later caller the first caller's write credential.
353
+ const facet = deps.facets.open({
354
+ // The variant is in the tag because a host's constructor-time wasm
355
+ // fingerprint is name:length:first-byte:last-byte, not a content hash.
356
+ // Two variants differ by megabytes so they would not collide today, but
357
+ // a warm slot serving the wrong interpreter is not a failure worth
358
+ // leaving to a size coincidence.
359
+ tag: wantsSci ? 'cpython-runner:sci' : 'cpython-runner',
360
+ concurrency: 1,
361
+ // Never absent. Without the capability the facet reads its seed and can
362
+ // never write anything back — the program appears to run and its output
363
+ // never reaches the session.
364
+ syscalls: { vfs, pid: ctx.pid },
365
+ preamble: buildCPythonPreamble(),
366
+ wasmModules: { 'python.wasm': toArrayBuffer(vfs.readFile(wasmVfs)) },
367
+ });
368
+ const facetArgs = {
369
+ userCode: `${prelude}\n${userCode}`,
370
+ pyArgv,
371
+ userEnv,
372
+ progName,
373
+ cwd,
374
+ pythonHome: `/${installRoot.replace(/^\/+/, '')}`,
375
+ supervisorPid: ctx.pid,
376
+ fsSnapshot: snapshot,
377
+ };
378
+ // A script or `-m` can bind a port and keep serving, and such a program
379
+ // is not finished when it stops producing output — it is finished when it
380
+ // stops running. Pyodide gave those a dedicated socket process; that
381
+ // spawn is not ported yet, so for now they get a one-shot facet with a
382
+ // budget long enough not to cut a server off mid-request. Porting the
383
+ // resident process is the last piece, and until it lands a server holds
384
+ // its facet rather than being driven by inbound requests.
385
+ const resident = shouldRunAsResidentProcess(argv, parsed, pipInvocation.mode === 'pip');
386
+ if (resident) {
387
+ facet.dispose();
388
+ if (!deps.startResident) {
389
+ ctx.stderr.write(`${binName}: this program keeps running after it starts, and this host has no `
390
+ + 'process substrate to keep it on\n');
391
+ return 1;
392
+ }
393
+ const command = [binName, ...argv].map((part) => (/^[A-Za-z0-9_./:=@+-]+$/.test(part) ? part : JSON.stringify(part))).join(' ');
394
+ const spawnResult = await deps.startResident({ wasmVfsPath: wasmVfs, startArgs: facetArgs, cwd, command });
395
+ if (spawnResult.stdout)
396
+ ctx.stdout.write(spawnResult.stdout);
397
+ if (spawnResult.stderr)
398
+ ctx.stderr.write(spawnResult.stderr);
399
+ return spawnResult.exitCode;
400
+ }
401
+ let result;
402
+ try {
403
+ result = await facet.submit(cpythonRunFacetFn, facetArgs, {
404
+ timeoutMs: 120_000,
405
+ });
406
+ }
407
+ catch (e) {
408
+ ctx.stderr.write(`${binName}: ${errorMessage(e)}\n`);
409
+ return 1;
410
+ }
411
+ finally {
412
+ facet.dispose();
413
+ }
414
+ if (result.stdout)
415
+ ctx.stdout.write(result.stdout);
416
+ if (result.stderr)
417
+ ctx.stderr.write(result.stderr);
418
+ if (result.error) {
419
+ ctx.stderr.write(`${binName}: ${result.error}\n`);
420
+ return result.exitCode || 1;
421
+ }
422
+ return result.exitCode;
423
+ };
424
+ };
425
+ }
@@ -0,0 +1,159 @@
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
+ import type { CredentialedVfs } from '../vfs/sqlite-vfs.js';
23
+ import type { WasiFsSnapshot } from './wasi-instance.js';
24
+ import type { WasiParking } from './wasi/types.js';
25
+ /**
26
+ * A function submitted into a facet.
27
+ *
28
+ * It is SERIALIZED on any host that runs it elsewhere, so it must be
29
+ * self-contained: closure references do not survive the crossing, and neither
30
+ * do module imports. Names the spec's `preamble` declares ARE in scope — that
31
+ * is what the preamble is for — and everything else travels as `args`.
32
+ *
33
+ * `bindings` carries whatever capabilities the host minted for this facet;
34
+ * `SUPERVISOR` is the session syscall capability, present only when the spec
35
+ * named a pid to act as.
36
+ */
37
+ export type FacetFn<A, R> = (args: A, bindings: FacetBindings) => R | Promise<R>;
38
+ /** Capabilities handed to the facet's function as its second argument. */
39
+ export interface FacetBindings {
40
+ /** The session's syscall capability, bound to {@link FacetSpec.supervisorPid}. */
41
+ readonly SUPERVISOR?: unknown;
42
+ }
43
+ export interface FacetSpec {
44
+ /** Names the facet in diagnostics and in the host's own reuse key. */
45
+ tag: string;
46
+ /**
47
+ * Source evaluated once, before any function is submitted, in the scope those
48
+ * functions are evaluated in. This is how a runtime's scheduler and its WASI
49
+ * layer get there: they are far too large to travel per call, and they hold
50
+ * the state a session needs between calls (bash's process tree, CPython's
51
+ * `__main__`).
52
+ */
53
+ preamble?: string;
54
+ /**
55
+ * Wasm images the facet's scope exposes as compiled `WebAssembly.Module`s on
56
+ * `globalThis.__NIMBUS_WASM[<key>]`. Compiled by the host, because on workerd
57
+ * the caller is not allowed to.
58
+ */
59
+ wasmModules?: Record<string, ArrayBuffer>;
60
+ /**
61
+ * The session filesystem this facet's syscalls act on, and the process they
62
+ * act as. Absent for a facet that makes no syscall back into the session.
63
+ *
64
+ * Both halves, because the two hosts reach the same authority differently: a
65
+ * dynamic worker is a different isolate, so it is handed a capability minted
66
+ * for the PID and routed back to the session; a facet in the caller's own
67
+ * isolate is handed the credentialed VIEW. Naming only the pid would leave
68
+ * the second host nothing to serve from, and naming only the view would
69
+ * leave the first nothing to mint.
70
+ *
71
+ * Not a boolean: the supervisor derives the WRITE credential from the pid,
72
+ * so a facet given the capability without one can read the filesystem it was
73
+ * seeded with and silently write nowhere.
74
+ */
75
+ syscalls?: FacetSyscalls;
76
+ /** Facets the host may keep warm for this spec. Default 1. */
77
+ concurrency?: number;
78
+ }
79
+ /** The session a facet's syscalls reach, and who they reach it as. */
80
+ export interface FacetSyscalls {
81
+ readonly vfs: CredentialedVfs;
82
+ readonly pid: number;
83
+ }
84
+ /**
85
+ * A seeded WASI filesystem: what {@link FacetHost.seedFilesystem} produced, and
86
+ * how much of the session it had to carry to produce it.
87
+ */
88
+ export interface FacetFilesystemSeed {
89
+ snapshot: WasiFsSnapshot;
90
+ files: number;
91
+ bytes: number;
92
+ }
93
+ /** What a runner knows about the subtree its guest should see. */
94
+ export interface FacetFilesystemOptions {
95
+ /** Directories outside `root` the program must also reach. */
96
+ extraRoots?: Iterable<string>;
97
+ /**
98
+ * The session revision the seed describes, when the caller has computed one.
99
+ * A host that serves reads back stamps it, which is what marks the seed a
100
+ * CACHE rather than the whole world; one that cannot has no use for it.
101
+ */
102
+ revision?: number;
103
+ }
104
+ export interface FacetSubmitOptions {
105
+ /**
106
+ * Deadline for this call, honoured by hosts that can abandon a facet.
107
+ *
108
+ * A host sharing the caller's thread cannot: a wasm guest in a synchronous
109
+ * loop holds the only thread there is, and nothing observes a timer until it
110
+ * yields. Such a host says so ({@link ./local-facet-host.ts}) rather than
111
+ * racing a timer and returning while the guest runs on.
112
+ */
113
+ timeoutMs?: number;
114
+ /** Wasm images for this call alone, merged over {@link FacetSpec.wasmModules}. */
115
+ wasmModules?: Record<string, ArrayBuffer>;
116
+ }
117
+ /**
118
+ * One facet's scope, for as long as a runtime needs it.
119
+ *
120
+ * Held rather than per-call because the scope IS the session: bash boots on the
121
+ * first submit and is fed on every one after, and a scope rebuilt between them
122
+ * would hand the second call a shell that had never run.
123
+ */
124
+ export interface Facet {
125
+ submit<A, R>(fn: FacetFn<A, R>, args: A, options?: FacetSubmitOptions): Promise<Awaited<R>>;
126
+ /** Idempotent. The scope and everything it holds are dropped. */
127
+ dispose(): void;
128
+ }
129
+ export interface FacetHost {
130
+ /**
131
+ * Whether a guest in this host can be SUSPENDED in the middle of a syscall.
132
+ *
133
+ * The one place the substrates are not interchangeable, so it is stated
134
+ * rather than smoothed over — the same posture as `ProcessImageDelivery` in
135
+ * the process fabric. Everything else about running a wasm program is the
136
+ * same code either way; this is not, and it decides two things at once:
137
+ * which import table the guest gets ({@link WasiParking}), and how much
138
+ * filesystem it must be handed before it starts.
139
+ *
140
+ * `jspi` — the host can park the guest on a promise, so a syscall may go
141
+ * back to the session mid-instruction and the seed can be a manifest.
142
+ * `none` — it cannot; V8 traps any call into a suspending import off a
143
+ * stack `WebAssembly.promising` did not enter. Every syscall must answer
144
+ * synchronously, so the seed has to BE the filesystem.
145
+ */
146
+ readonly parking: WasiParking;
147
+ /**
148
+ * Hand a facet the part of the session filesystem its program needs.
149
+ *
150
+ * The host decides the strategy, because the strategy IS the consequence of
151
+ * {@link FacetHost.parking} and nothing about the program bears on it. A
152
+ * runner names the roots and gets a seed; it never learns which kind it got.
153
+ */
154
+ seedFilesystem(vfs: CredentialedVfs, root: string, options?: FacetFilesystemOptions): FacetFilesystemSeed | {
155
+ error: string;
156
+ };
157
+ open(spec: FacetSpec): Facet;
158
+ }
159
+ //# sourceMappingURL=facet-host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"facet-host.d.ts","sourceRoot":"","sources":["../../src/runtime/facet-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,OAAO,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,QAAQ,EAAE,aAAa,KAAK,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAEjF,0EAA0E;AAC1E,MAAM,WAAW,aAAa;IAC5B,kFAAkF;IAClF,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,SAAS;IACxB,sEAAsE;IACtE,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC1C;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,EAAE,aAAa,CAAC;IACzB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,sEAAsE;AACtE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,eAAe,CAAC;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,QAAQ,EAAE,cAAc,CAAC;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,kEAAkE;AAClE,MAAM,WAAW,sBAAsB;IACrC,8DAA8D;IAC9D,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,kFAAkF;IAClF,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;CAC3C;AAED;;;;;;GAMG;AACH,MAAM,WAAW,KAAK;IACpB,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5F,iEAAiE;IACjE,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB;;;;;;;;;;;;;;;OAeG;IACH,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B;;;;;;OAMG;IACH,cAAc,CACZ,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,sBAAsB,GAC/B,mBAAmB,GAAG;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3C,IAAI,CAAC,IAAI,EAAE,SAAS,GAAG,KAAK,CAAC;CAC9B"}
@@ -0,0 +1,22 @@
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
+ export {};