sandboxedjs 0.2.10 → 0.2.12

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 (43) hide show
  1. package/README.md +96 -63
  2. package/assets/logo.png +0 -0
  3. package/bin/sandboxedjs-egress.mjs +25 -10
  4. package/dist/index.cjs +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/service-worker.js +3 -2
  7. package/docs/agent/COMMANDS.md +85 -0
  8. package/docs/agent/DECISION-TREE.md +84 -0
  9. package/docs/agent/INVARIANTS.md +40 -0
  10. package/docs/agent/LAUNCH-PROMPT.md +37 -0
  11. package/docs/agent/LOOP.md +84 -0
  12. package/docs/agent/README.md +77 -0
  13. package/docs/agent/ROADMAP.md +37 -0
  14. package/docs/agent/STATE.md +93 -0
  15. package/docs/agent/tasks/00-verify-inherited-work.md +36 -0
  16. package/docs/agent/tasks/01-authoritative-metadata.md +34 -0
  17. package/docs/agent/tasks/02-native-dependencies.md +35 -0
  18. package/docs/agent/tasks/03-reproducible-inputs.md +27 -0
  19. package/docs/agent/tasks/04-build-frontends.md +27 -0
  20. package/docs/agent/tasks/05-registry-integration.md +27 -0
  21. package/docs/agent/tasks/06-package-cohorts.md +42 -0
  22. package/docs/agent/tasks/07-build-on-miss-boundary.md +31 -0
  23. package/docs/browser-runtime-architecture.md +142 -0
  24. package/docs/compatibility-implementation-plan.md +98 -0
  25. package/docs/developer-tool-packs.md +134 -0
  26. package/docs/frontend-automation.md +49 -0
  27. package/docs/fullstack-deployment.md +163 -0
  28. package/docs/handoff.md +275 -0
  29. package/docs/original-x64.md +39 -0
  30. package/docs/platform-hardening.md +49 -0
  31. package/docs/python/abi.md +97 -0
  32. package/docs/python/architecture.md +94 -0
  33. package/docs/python/baseline-inventory.md +54 -0
  34. package/docs/python/build-on-miss.md +198 -0
  35. package/docs/python/compatibility.md +206 -0
  36. package/docs/python/cross-build.md +354 -0
  37. package/docs/python/extensions.md +282 -0
  38. package/docs/python/release-gates.md +46 -0
  39. package/docs/python/virtual-sockets-plan.md +331 -0
  40. package/docs/runtime-lifecycle-fixes.md +39 -0
  41. package/docs/server-previews.md +268 -0
  42. package/docs/virtual-browser.md +120 -0
  43. package/package.json +5 -3
@@ -0,0 +1,275 @@
1
+ # Handoff — SandboxedJS
2
+
3
+ ## Repo state
4
+
5
+ `npx tsc --noEmit` passes, `npm run build` passes, and **316 tests pass**
6
+ including the network acceptance suite:
7
+
8
+ ```bash
9
+ SANDBOXEDJS_CLEAN_NETWORK_TESTS=1 npx vitest run
10
+ ```
11
+
12
+ (The network tests hit the real npm registry and take ~2 min: Express,
13
+ `npx serve`, `npm install` + `npx`, `npm create vite`, a Vite 7 dev server, and
14
+ an interactive clack prompt driven through `Terminal`.)
15
+
16
+ ## Context
17
+
18
+ `@scelar/nodepod` was removed entirely (licence: Commons Clause). The Node.js
19
+ runtime is this package's own, under `src/runtime/`. It must stay
20
+ browser-capable: **nothing in `src/runtime/` may import a `node:` builtin**
21
+ except through `nodeOnlyModule`/`nodeBuiltin` (`src/util/binary.ts`) on a path
22
+ that returns `null` off Node. Several npm shims had to be replaced because they
23
+ are not browser-safe — see `util-module.ts`, `assert-module.ts`,
24
+ `zlib-module.ts`, `url-module.ts`, `readline-module.ts`, `readable-from.ts`.
25
+
26
+ ## Verified in a real browser
27
+
28
+ Chrome, against the Vite host app at
29
+ `/Users/shazi/Practice/sandboxedjs tes/browser-server` (`npm run dev`). That
30
+ project has a `check.html` / `src/browser-check.ts` page that boots a container
31
+ and drives `Terminal` through the whole set; open `/check.html` and it prints a
32
+ pass/fail line per check:
33
+
34
+ - `readline.question` answered, with the typed text echoed as it is typed
35
+ - `https.get` from inside the sandbox reaching the npm registry
36
+ - clipboard round-trip through `pbcopy`/`pbpaste`
37
+ - `npm create vite@latest` accepting a project name and advancing
38
+ - `ctrl+c` cancelling a prompt and returning to the shell
39
+ - `npx serve --debug` starting with no errors and serving a file
40
+
41
+ ## How isolation is put together
42
+
43
+ `WorkerRuntimePod` (`src/runtime/worker-runtime-pod.ts`) extends
44
+ `LocalRuntimePod` and overrides `spawn` alone; everything else in the contract
45
+ is identical. Both are held to `test/pod-contract.ts`, which is the thing to
46
+ extend when either changes.
47
+
48
+ The topology is forced, not chosen. A synchronous call must block its caller
49
+ while the work it waits on still progresses, so the blocking side cannot own
50
+ the shared state — otherwise a child needing the filesystem would call into a
51
+ frozen thread. Hence: guest in the Worker, volume and kernel on the host.
52
+
53
+ - `sync-channel.ts` — `SharedArrayBuffer` + `Atomics.wait`, chunked both ways.
54
+ Only the client blocks; the server answers from its ordinary event loop.
55
+ - `remote-volume.ts` — `RuntimeVolume` over that channel. Because the interface
56
+ was already the seam, `core-modules`, `Vfs` and the kernel are untouched.
57
+ - `sync-syscalls.ts` — the filesystem and `spawnSync` share one channel.
58
+ - `worker-entry.ts` — the guest half, built as its own bundle.
59
+
60
+ Two packaging traps, both already paid for:
61
+
62
+ 1. The guest bundle must be **fully self-contained** (`noExternal` in
63
+ `tsup.config.ts`). The main bundle leaves `buffer` and friends external and
64
+ lets the host's bundler map them onto polyfills; the Worker is fetched as
65
+ its own module graph and gets no such help, so an external `buffer` resolves
66
+ to raw CommonJS with no named exports and the Worker dies before it runs.
67
+ 2. `new URL("./worker-entry.js", import.meta.url)` **does** survive Vite's
68
+ dependency pre-bundling — verified in Chrome, it fetches from
69
+ `node_modules/sandboxedjs/dist/`. Do not "fix" this with a blob URL: a blob
70
+ worker has no module-resolution context and could no longer `import()` the
71
+ host's esbuild or Rolldown binding.
72
+
73
+ ## Verifying the preview service worker
74
+
75
+ The in-app browser pane refuses service worker registration outright — even a
76
+ one-line worker fails — so the preview cannot be checked there and will always
77
+ report itself unavailable. Real Chrome works. The quickest check is
78
+ `/swcheck.html` in the harness, which stands up a tiny server and asserts that
79
+ an absolute-path subresource is routed; `/preview.html` runs the full
80
+ `create-vite` flow and takes about half a minute. Headless Chrome over CDP works
81
+ too, which is how this was verified.
82
+
83
+ Three things bit during that verification and are easy to re-introduce:
84
+ `navigator.serviceWorker.ready` never resolves for a worker whose scope excludes
85
+ the registering page; the claim path arrives under the worker's scope directory,
86
+ so its pattern must not be anchored to the start; and a framed response needs
87
+ `Cross-Origin-Embedder-Policy` as well as `Cross-Origin-Resource-Policy` or an
88
+ isolated parent refuses it.
89
+
90
+ ## A trap worth remembering: inherited stdin is a terminal
91
+
92
+ `stdio: "inherit"` hands a child the parent's standard input, and a terminal
93
+ never ends. Give it a pipe and anything that reads to end-of-file first will
94
+ wait forever — and `node` does that in `captureStdin` (`src/runtime/node.ts`)
95
+ before it runs a script. The symptom is remote from the cause: a shell script
96
+ that launches node prints nothing at all and hangs, with no error anywhere.
97
+
98
+ `KernelChildProcess` (`node-child-process-bridge.ts`) therefore marks an
99
+ inheriting child's stdin `isTTY`/`interactive`. `test/sync-child-process.test.ts`
100
+ pins the reduced case — `spawnSync('sh', ['-c', 'node -e …'], {stdio:"inherit"})`
101
+ — which is much cheaper to run than the create-vite flow it was found in.
102
+
103
+ ## The interactive-input chain, for future work
104
+
105
+ 1. `Terminal.key()` — `src/container/terminal.ts`. While a command runs it
106
+ writes each keystroke to `this.currentStdin` (a `Pipe`). **Raw mode changes
107
+ what it does**: no local echo, no CR→LF translation, and `ctrl+c`/`ctrl+d`
108
+ are passed through as keystrokes rather than becoming a signal and EOF.
109
+ 2. `node` command — `src/runtime/node.ts`, `execute()`. Forwards `ctx.stdin`
110
+ into `proc.write(...)`, propagates EOF via `proc.endInput()`, and mirrors the
111
+ program's `rawmode` event back onto `ctx.stdin.rawMode`.
112
+ 3. `LocalProcess.write` — `src/runtime/local-runtime-pod.ts`. Buffers input
113
+ until the task starts, then hands it to `core.writeStdin`.
114
+ 4. `createCoreModules` — `src/runtime/core-modules.ts`. With
115
+ `interactiveStdin: true`, `process.stdin` is a `PassThrough` that stays open;
116
+ `tty: true` sets `isTTY` on all three streams; `setRawMode` calls back into
117
+ `options.onRawMode`.
118
+ 5. `readline-module.ts`. `emitKeypressEvents(stream)` turns incoming data into
119
+ `keypress` events. `createInterface` defaults `terminal` from `output.isTTY`,
120
+ as Node does; a terminal `Interface` owns raw mode, keeps `line`/`cursor`
121
+ current, and echoes. **Prompt libraries read `rl.line` for the answer**, so
122
+ anything that stops it tracking shows up as an empty answer and a re-prompt,
123
+ not as an error.
124
+
125
+ `settle()` in `local-runtime-pod.ts` decides when a process is finished. It
126
+ counts pending timers, `core.readingStdin()` and `core.pendingRequests()` — an
127
+ in-flight HTTP request is event-loop work and schedules no timer of its own.
128
+ Check this first if a process exits before something asynchronous completes.
129
+
130
+ ## Known gaps
131
+
132
+ - **Vite 8 / Rolldown works in a browser, not under a Node host.** (The old note
133
+ saying it cannot run at all was wrong.) In a browser it needs two things from
134
+ the host app, both now in the README: COOP/COEP headers, and
135
+ `optimizeDeps.exclude: ["@rolldown/binding-wasm32-wasi"]` so the bundler does
136
+ not pre-bundle away the `import.meta.url` its WASI worker is created from.
137
+ With those, `npm create vite` installs and the dev server serves transformed
138
+ modules — verified in Chrome. Under Node the binding's other build makes a
139
+ `node:wasi` instance preopening the real filesystem root, which cannot be
140
+ redirected at the sandbox volume, so Rolldown never finds the project and Vite
141
+ answers with its fallback page. Fixing that means running the *browser* build
142
+ on Node, which needs a `fetch` that handles `file:` URLs and a global `Worker`
143
+ over `worker_threads`. Vite 7 works on both and is what the Node test pins.
144
+
145
+ - **`spawnSync`/`execSync`/`execFileSync` work under the Worker pod**, which is
146
+ the default. See *Isolation* in the README for the fallback rules. Under the
147
+ in-realm pod they still throw, naming the command — guest, child and event
148
+ loop share a thread there, so blocking the caller stops the child.
149
+ - **esbuild in a browser.** `host-esbuild.ts` borrows the host's `esbuild-wasm`
150
+ on Node and returns `null` in a browser, so browser Vite transforms fail.
151
+ - **No service worker**, so preview iframes have no URL. `box.request()` works
152
+ everywhere.
153
+ - **`curl` to a non-CORS host cannot work in a browser.** Platform limit, not a
154
+ bug; the error message says so. The npm registry does send CORS headers, which
155
+ is why installing packages works.
156
+ - **`child_process.execSync` and friends** cannot exist: they would have to
157
+ block the JS thread. They throw `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`.
158
+ - Vite prints two `util` externalization warnings from `readable-stream`, which
159
+ declares `"util": false` for browsers and falls back on its own. Harmless.
160
+
161
+ ## Python: blocking syscalls, and what they are built on
162
+
163
+ `src/runtime/python-syscalls.ts` is the layer that makes Pyodide behave like a
164
+ Linux Python rather than a sandboxed evaluator. The constraint it removes is
165
+ that **WebAssembly cannot wait for a JavaScript promise**: Pyodide's `setStdin`
166
+ callback is synchronous, so anything only the host can answer asynchronously
167
+ had to be answered immediately or not at all. Answering "not at all" is what
168
+ made `input()` raise `EOFError`, and it is the same wall behind `socket`,
169
+ `subprocess` and `time.sleep`.
170
+
171
+ The primitive is WebAssembly stack switching (JSPI), reached through Pyodide's
172
+ `run_sync`. It suspends the whole interpreter stack until a host promise
173
+ settles, so an ordinary `def` — nested arbitrarily deep — can block while the
174
+ event loop keeps running. Verified available unflagged in Node 25 and Chrome
175
+ 137+. **Adding a blocking syscall is now a method on the host facade**, not
176
+ another special case; that is the point of the file.
177
+
178
+ Three are wired:
179
+
180
+ - **stdin.** `sys.stdin` is rebuilt as a real `TextIOWrapper`, not a patched
181
+ `input()` — otherwise `csv.reader(sys.stdin)` stays broken. EOF still raises
182
+ `EOFError` as CPython does.
183
+ - **processes.** Only `subprocess.Popen` is replaced; `run`, `call`,
184
+ `check_call` and `check_output` are written in terms of it upstream, so the
185
+ family comes with it. The child is a *live* process on the host's event loop
186
+ (`kernel.spawn` + pipes), not a finished result: the parent suspends only
187
+ when it reads, waits or communicates, so `for line in p.stdout` follows a
188
+ child that is still running and `poll()` can say "running". `os.system` and
189
+ `os.popen` go to the same place — `os.system` previously returned 0 having
190
+ run *nothing*, which reads as success and is worse than an error.
191
+ - **HTTP.** `urllib` handlers call `performRequest` (`src/net/commands.ts`),
192
+ the same path as `curl`. Deliberately not `fetch`: that is what makes Python
193
+ obey the container's `allowOutbound` policy instead of routing around it.
194
+
195
+ ### Third-party HTTP stacks, and a network-policy escape
196
+
197
+ A library that brings its own transport does not go through `urllib`, and in
198
+ Pyodide several reach JavaScript's `fetch` directly. **`requests` used to reach
199
+ the internet from a container with `allowOutbound` off, while `curl` in the
200
+ same container was correctly refused** — a real hole in the sandbox, found by
201
+ testing the policy rather than the feature. `test/python-syscalls.test.ts`
202
+ guards it.
203
+
204
+ Such libraries cannot all be patched at boot, because pip installs them later.
205
+ So adapters are registered by module name and applied when that module is first
206
+ imported, through a hook on `builtins.__import__`. **Supporting another stack
207
+ is a small function registered there, not a change to the machinery.** The
208
+ `requests` adapter replaces `HTTPAdapter.send` — the one seam every call
209
+ crosses, below sessions/redirects/cookies/retries and above the urllib3
210
+ transport that would otherwise reach the network itself.
211
+
212
+ ### Hosts without stack switching
213
+
214
+ Everything blocking rests on JSPI, and Pyodide 0.28 ships JSPI-only — there is
215
+ no Asyncify build to fall back to. Where it is missing, the degradation is
216
+ deliberate rather than incidental:
217
+
218
+ - piped and redirected stdin still work, from the pre-drained buffer;
219
+ - anything that must genuinely wait (interactive input, a child process) says
220
+ so plainly instead of faking end-of-file;
221
+ - `requests` falls back to the library's own transport rather than breaking —
222
+ **but the network policy is not the part that degrades.** A host the
223
+ container forbids is still refused. Capability degrades; the sandbox does not.
224
+
225
+ The only way to get blocking without JSPI is a Worker plus
226
+ `SharedArrayBuffer`/`Atomics.wait` — the machinery `sync-channel.ts` already
227
+ has for the Node runtime. It is a real project: Pyodide would move off the main
228
+ thread and its Emscripten filesystem would have to reach the volume over the
229
+ channel, and in a browser it additionally requires cross-origin isolation
230
+ (COOP/COEP), which is a deployment requirement and not only code.
231
+
232
+ Five traps, each already paid for and each cheap to reintroduce:
233
+
234
+ 1. **Pyodide maps JS `null` to a truthy `JsNull` proxy; only `undefined`
235
+ becomes `None`.** `buffered()` returns `undefined` to mean "you must
236
+ suspend", and a `?? EMPTY` on that path silently turned it back into
237
+ end-of-file — the exact bug the bridge exists to remove.
238
+ 2. **One interpreter is shared per container**, so its streams belong to
239
+ whichever program is running. A Python child binds over its parent while the
240
+ parent is suspended, so every host call that can suspend goes through
241
+ `resuming()`, which puts the parent's binding back.
242
+ 3. **`kernel.spawn` does not close a child's output pipes when it exits** —
243
+ `Container.spawn` does that itself — and a reader waiting on a pipe that
244
+ never ends waits forever. This deadlocked the whole suite once.
245
+ 4. **`Popen` must not run at construction.** `subprocess.run` hands `input=` to
246
+ `communicate()`, never to `Popen`.
247
+ 5. **Pyodide builds urllib without `ssl`, so `urllib.request.HTTPSHandler` does
248
+ not exist** to subclass. One handler subclassing `HTTPHandler` serves both
249
+ schemes, which also keeps `build_opener` treating it as a replacement.
250
+
251
+ `asyncio.run` and `run_until_complete` are patched onto `run_sync` for the same
252
+ reason: programs execute under `eval_code_async`, so Pyodide's loop is always
253
+ already running and `asyncio.run` refuses to start a second one.
254
+
255
+ Known remaining gaps:
256
+
257
+ - **Python cannot accept connections.** `loop.create_server` is unimplemented
258
+ on Pyodide's WebLoop, so uvicorn and aiohttp install, import and start but
259
+ never bind — outbound requests work, inbound do not. Closing this means
260
+ widening the `RuntimePod` contract with a serve hook so Python can register
261
+ with `VirtualHttpRouter` (`src/runtime/virtual-http.ts`), the same router
262
+ that already routes `curl localhost:3000` to a Node server in the container,
263
+ plus an asyncio transport that frames HTTP between the router's structured
264
+ requests and the protocol's raw bytes. `router` is currently `protected` on
265
+ `LocalRuntimePod`; `WorkerRuntimePod` inherits it, so one implementation
266
+ covers both. For now the failure at least explains itself.
267
+
268
+ - A library with its own transport and no registered adapter can still reach
269
+ the network directly and escape the policy. `requests` and `urllib` are
270
+ covered; the hook makes the next one cheap, but it is opt-in by design.
271
+ - Package resolution: `loadPackagesFromImports` only sees Pyodide's lockfile
272
+ and `micropip` only installs pure-Python wheels, so `opencv-python` and
273
+ everything else needing a C extension built for wasm32-emscripten remains out
274
+ of reach. The fix is a resolution chain (lockfile → PyPI pure-Python → a wasm
275
+ binary-wheel index → clear failure), not a special case per library.
@@ -0,0 +1,39 @@
1
+ # Original experimental x86-64 engines
2
+
3
+ `createOriginalX64Backends()` returns two optional backends written in this
4
+ repository: an instruction-to-WebAssembly translator and a JavaScript instruction
5
+ interpreter. Neither embeds a third-party compiler or CPU emulator. Register the
6
+ returned backends with `box.kernel.binaries.register(backend)`.
7
+
8
+ The translator emits real Wasm integer operations, using mutable register globals
9
+ and an instruction cache. It translates lazily during execution, rather than
10
+ producing a distributable standalone Wasm file. The interpreter executes the same
11
+ decoded instructions directly. They currently support the same instruction subset;
12
+ register only the `emulation` backend to exercise the interpreter directly.
13
+
14
+ Supported inputs are freestanding, static, little-endian x86-64 ET_EXEC ELF files.
15
+ Loadable segments must fit a 16 MiB memory window and use addresses below 4 GiB.
16
+ Dynamic linking is rejected. Supported instructions: 32/64-bit immediate and
17
+ register MOV, register ADD/SUB/XOR/CMP, selected immediate ADD/SUB/CMP, RIP-relative
18
+ LEA, NOP, short JMP/JZ/JNZ, relative JMP, and SYSCALL. Only the zero flag is modeled.
19
+ There is no stack/argv ABI, arbitrary memory operand, SSE, libc, file-open syscall,
20
+ networking, threads, or Docker support. Unsupported instructions fail explicitly.
21
+
22
+ Linux calls: read stdin, write stdout/stderr, exit/exit_group, getpid. Unsupported
23
+ calls return ENOSYS. Segment read/write permissions are checked for I/O buffers;
24
+ self-modifying code is rejected. Each run owns its registers and memory. The
25
+ one-million-instruction default budget and periodic event-loop yields bound loops
26
+ and permit cancellation between batches. This is not a hardened security boundary
27
+ or a claim of native performance. Translation currently has substantial per-
28
+ instruction overhead; performance optimization is future work.
29
+
30
+ Tests use `test/fixtures/native/hello.s`, assembled by Clang for Linux. The checked
31
+ in ELF is built with the adjacent `package-elf.py` script. Both engines run the
32
+ same binary, including a conditional loop and Linux console output; an infinite
33
+ loop fixture verifies the instruction limit. Clang is only used to produce test
34
+ inputs; it is not part of either engine at runtime.
35
+
36
+ The local browser project registers both backends and exposes `native-hello`.
37
+ Opening `/?native=emulation` selects the interpreter. Node and Python commands
38
+ continue through their existing runtimes. This is an initial working CPU subset,
39
+ not arbitrary Linux package compatibility.
@@ -0,0 +1,49 @@
1
+ # Platform hardening
2
+
3
+ SandboxedJs is a virtual OS runtime for browser clients and Node hosts. It is
4
+ not a recording application. Continue developing its own JavaScript module
5
+ engine, virtual kernel, filesystem, network and CPython WebAssembly integration;
6
+ do not replace them with Nodepod or Pyodide.
7
+
8
+ ## Release gates
9
+
10
+ Completion requires evidence in both a real browser and a supported Node host.
11
+ A successful bundle alone does not establish browser runtime compatibility.
12
+
13
+ 1. Runtime lifecycle: repeated boot, cancellation, child-process termination,
14
+ disposal and startup failure leave no workers, timers, sockets or mounts.
15
+ 2. Isolation: concurrent guests cannot observe each other's environment or
16
+ filesystem. Worker execution and realm fallback must be tested separately.
17
+ 3. Package workloads: install, run, edit and rebuild representative frontend
18
+ and API projects. Test actual package imports rather than lookalike APIs.
19
+ 4. Python: test dotenv loading, imports, subprocesses, HTTP servers and async
20
+ I/O in the shipped interpreter. Resolve extension setup timeouts before
21
+ claiming the complete Python suite passes.
22
+ 5. Performance: measure cold and warm boot, installation, module loading,
23
+ rebuild latency, memory after disposal and large console output. Record
24
+ runtime version, host, workload and repetitions with each result.
25
+ 6. Distribution: verify published ESM, CommonJS, worker and static browser
26
+ assets, including missing assets and unsupported host features.
27
+
28
+ ## Current findings
29
+
30
+ - Corrected environment support to expose `util.parseEnv`; the previous
31
+ `process.parseEnv` addition did not match native Node. Added
32
+ `process.loadEnvFile` and native-Node differential parser fixtures.
33
+ - Typed-array inspection now reads at most 100 entries instead of copying the
34
+ entire array. DataView logging no longer attempts to iterate a non-iterable.
35
+ - The full test run was interrupted after Python extension setup timed out.
36
+ Follow-up isolated EPERM on the test's loopback listener and fixed missing
37
+ setup error handling. All 12 extension tests passed when loopback listening
38
+ was permitted. This is still not a full release pass.
39
+ - Several Node modules still have stubs: cluster, dgram, diagnostics_channel,
40
+ domain, http2, inspector, tls, v8, vm and worker_threads. Implement and test
41
+ usable APIs before marking those modules supported.
42
+ - The Python thread/syscall limitation documented in
43
+ browser-runtime-architecture.md needs interpreter-image work.
44
+ - Browser CORS, static asset hosting, cross-origin isolation requirements,
45
+ native addons and platform-specific binaries remain constraints. Arbitrary
46
+ packages cannot be promised to run solely from this JavaScript runtime.
47
+
48
+ The platform objective remains open. These gates are an implementation and
49
+ verification backlog, not a claim of complete Node, Python or Linux parity.
@@ -0,0 +1,97 @@
1
+ # `sbx_host_v1` — the host ABI
2
+
3
+ The contract between compiled guest code and the SandboxedJs kernel.
4
+
5
+ Everything a guest cannot do for itself — files, descriptors, pipes, readiness,
6
+ time, identity, entropy, and later sockets, processes and signals — crosses this
7
+ boundary and nothing else. Emscripten and WASI adapters translate *into* it;
8
+ they are not the source of truth. That distinction is the point: compiling a
9
+ program to WebAssembly does not give it an operating system, and pretending an
10
+ Emscripten build's own filesystem is the container's is how two divergent copies
11
+ of the same state come to exist.
12
+
13
+ ## Source of truth
14
+
15
+ `python-runtime/abi/host-v1.json` defines the version, the operation codes, the
16
+ errno values and the capability names. `make -C python-runtime abi` regenerates:
17
+
18
+ - `python-runtime/abi/sbx_host.h` — the C view.
19
+ - `src/python/host-abi.ts` — the TypeScript view.
20
+
21
+ Both are committed. Neither is edited by hand: a C file and a TypeScript file
22
+ that disagree about an opcode produce a wrong answer, not a link error.
23
+
24
+ ## Framing
25
+
26
+ ```
27
+ request: u16 version | u16 op | u32 request_id | u32 generation | u32 length | payload
28
+ response: u16 version | u16 op | u32 request_id | u32 generation | i32 status | u32 length | payload
29
+ ```
30
+
31
+ Little-endian, fixed width, UTF-8 for text, raw bytes for content, 64-bit file
32
+ offsets. `status` is the operation's result: `>= 0` on success, `-errno` on
33
+ failure, with the canonical (Linux/musl) numbers.
34
+
35
+ Three rules the transport must keep, each of which was a real defect before it
36
+ was written down:
37
+
38
+ 1. **A transport failure is never a successful empty result.** `read` returning
39
+ zero bytes means end of input. If losing the host can produce that same
40
+ answer, input silently vanishes instead of raising.
41
+ 2. **A stale answer is not an answer.** `request_id` and `generation` are
42
+ checked on the way back. A completion belonging to a previous incarnation of
43
+ a PID must not land on the current process's descriptor table.
44
+ 3. **A closed transport stays closed.** Once the host has gone, every later call
45
+ fails immediately. Without that, a killed process republishes a request over
46
+ the closed marker and hangs on its way out.
47
+
48
+ The guest writes a zero into `generation`; the transport stamps the real value,
49
+ because the transport belongs to one process and that is where the authority
50
+ lives. Nothing in guest-controlled data may be trusted as identity.
51
+
52
+ ## Blocking
53
+
54
+ Only the guest blocks, and the guest never owns shared state.
55
+
56
+ ```
57
+ Python calls read(fd)
58
+ → libc adapter invokes sbx_host_v1
59
+ → the process worker parks in Atomics.wait
60
+ → the kernel thread waits for input on its own event loop
61
+ → the kernel writes the response and notifies
62
+ → the worker resumes, read() returns
63
+ ```
64
+
65
+ Keyboard input, network responses and storage completions must reach the
66
+ *kernel*, not arrive as messages the blocked worker would have to process. A
67
+ blocked worker processes nothing.
68
+
69
+ This requires `SharedArrayBuffer` and `Atomics.wait`, so a browser host must be
70
+ cross-origin isolated, and the guest must never run on the main thread.
71
+
72
+ ## Status
73
+
74
+ Implemented and covered by `test/python-abi/`: files, descriptors, pipes,
75
+ readiness, time, identity, entropy. Reserved but not implemented: sockets,
76
+ processes, signals, storage, services, threads — `handshake` reports which is
77
+ which, so a guest can tell "not present" from "not implemented" rather than
78
+ discovering it at the first call.
79
+
80
+ ## Known limit
81
+
82
+ `unlink` and `rename` preserve open-file semantics only for names changed
83
+ *through this ABI*. A name removed by the shell or the Node side cannot be
84
+ intercepted, and an open description will then fail with `ENOENT` rather than
85
+ reading stale bytes. Making that hold everywhere means moving the operations
86
+ into the volume itself, which is M3 work.
87
+
88
+ ## Errno translation is not a formality
89
+
90
+ The canonical values here are Linux's. Emscripten's musl uses different
91
+ numbers — `ENOENT` is 44 there and 2 here, and 2 is `EACCES`. Passing a kernel
92
+ errno through an Emscripten adapter unchanged turns "no such file" into
93
+ "permission denied", and the symptom is a `PermissionError` carrying the errno
94
+ of an entirely different failure, several layers from the cause. Every adapter
95
+ translates at its own edge; `src/runtime/python/sbxfs.ts` does it by name,
96
+ against the build's own `ERRNO_CODES`, so it cannot drift from the interpreter
97
+ it is loaded into.
@@ -0,0 +1,94 @@
1
+ # Python on SandboxedJs — architecture
2
+
3
+ The goal is an operating-system compatibility layer with an owned Python
4
+ distribution on top of it, not an interpreter swap. Replacing Pyodide with a
5
+ differently-built CPython in the same lifecycle would inherit the same problems,
6
+ because the problems are not in the interpreter.
7
+
8
+ ## What "our own Python" means here
9
+
10
+ A **runtime distribution**, not a new language implementation. SandboxedJs owns
11
+ the OS-facing ABI, the process model, the filesystem integration, the build
12
+ pipeline and the tests. Upstream CPython supplies Python semantics, adapted by a
13
+ maintained patch series. Building from source gives ownership and
14
+ reproducibility; stability comes from process isolation, consistent semantics
15
+ and systematic testing.
16
+
17
+ ## Governing rules
18
+
19
+ 1. One active Python process owns one interpreter instance.
20
+ 2. One kernel authority owns shared mutable container resources.
21
+ 3. Blocking callers never own the services they are waiting for.
22
+ 4. All guest I/O crosses an explicit, versioned boundary.
23
+ 5. Processes share files and channels — not Python globals.
24
+ 6. Every resource has an owner and a cleanup path.
25
+
26
+ ## Shape
27
+
28
+ ```
29
+ browser app / shell kernel owner (volume, process table)
30
+ │ │
31
+ ├── preview service worker ───────────┤
32
+ │ ├── VFS + descriptors + storage
33
+ │ ├── process supervisor + signals
34
+ │ └── virtual sockets + net policy
35
+ │ ▲
36
+ python worker A ──── sbx_host_v1 ────────────────── ┤
37
+ python worker B ──── sbx_host_v1 ────────────────── ┤
38
+ node workers ────────────────────────────────────── ┘
39
+ ```
40
+
41
+ Kernel services stay on the host that owns them today; Python runs in dedicated
42
+ workers and calls back. Moving the kernel into its own worker is a later
43
+ optimisation with its own migration plan, driven by measured UI responsiveness,
44
+ and is not a prerequisite for any of this.
45
+
46
+ ## Three compatibility targets
47
+
48
+ | Target | Behaviour | Mechanism |
49
+ |---|---|---|
50
+ | Python development | scripts, REPL, venv, pip, subprocesses, local servers | CPython to Wasm plus this kernel |
51
+ | Scientific / ML | supported packages, model inference | recompiled extensions, dedicated compute services |
52
+ | Linux binaries | existing Linux executables and wheels | optional CPU emulation running a real Linux guest |
53
+
54
+ The first is the product. The third is a separate execution backend and must
55
+ never silently substitute for the first when an install fails: it changes
56
+ architecture, path semantics, performance and persistence.
57
+
58
+ ## What exists today
59
+
60
+ - `python-runtime/abi/` — the versioned ABI and its generated bindings.
61
+ - `src/kernel/open-file.ts`, `src/kernel/descriptors.ts` — pathnames, inodes,
62
+ open-file descriptions and per-process descriptor tables.
63
+ - `src/runtime/python/` — framing, the blocking transport, the kernel-side
64
+ dispatcher, the typed guest client, the Emscripten filesystem bridge, the
65
+ release manifest, and the per-process supervisor and worker.
66
+ - `python-runtime/native/probe/` — a C program that exercises the whole path.
67
+ - `python-runtime/scripts/` — the pinned build: fetch and verify, host Python,
68
+ cross-built CPython, and the packaged release with its manifest.
69
+ - `test/python-abi/`, `test/python-runtime/` — the baseline inventory and the
70
+ M1 and M2 gates.
71
+
72
+ ### The owned interpreter
73
+
74
+ `make -C python-runtime fetch python package` produces
75
+ `out/sbx-cpython-<version>-<profile>/`: `python.js`, `python.wasm`,
76
+ `python.data`, and a `runtime.json` naming the ABI it was built against and the
77
+ capabilities the profile actually has. A host selects it with
78
+ `configurePython({ backend: "sbx-cpython-wasm", manifest })`; Pyodide remains
79
+ the default until the parity gates pass.
80
+
81
+ Two things about the build are worth knowing before reading it:
82
+
83
+ - Upstream's browser target links a *page* — a classic script that assigns a
84
+ global `Module` and runs `main` on load. A process worker needs a factory it
85
+ instantiates once per process, so the final link is repeated with
86
+ `-sMODULARIZE -sEXPORT_ES6 -sINVOKE_RUN=0`. Only the link changes.
87
+ - Some of what a program asks an operating system for is compiled into libc
88
+ rather than routed through anything a host can serve. musl's `getpid` under
89
+ Emscripten returns a constant, so every process would report the same
90
+ identity however many interpreters were running. `-Wl,--wrap=` is what makes
91
+ those reachable, and `python-runtime/native/js/library_sbx_posix.js` is where
92
+ the first of them live.
93
+
94
+ See [release-gates.md](release-gates.md) for what each milestone has to prove.
@@ -0,0 +1,54 @@
1
+ # M0 — baseline inventory
2
+
3
+ What the *current* Python integration does, measured rather than remembered, so
4
+ the owned runtime has something to be compared against. Every entry is pinned by
5
+ a test in `test/python-abi/baseline.test.ts`, which asserts today's behaviour —
6
+ including the wrong answers, on purpose. Turning one of those assertions around
7
+ is what a milestone landing looks like.
8
+
9
+ Setup: `src/runtime/cpython.ts` caches **one Pyodide interpreter per container**
10
+ (keyed on the VFS) and runs each program with a fresh globals dict.
11
+
12
+ ## Findings
13
+
14
+ | # | Behaviour | Verified | Cause | Fixed by |
15
+ |---|---|---|---|---|
16
+ | 1 | A module left in `sys.modules` by one program is importable by the next, unrelated one | yes | fresh globals do not reset `sys.modules` | **fixed in M2** — one interpreter per process |
17
+ | 2 | `os.environ` does **not** leak between programs | yes | the environment is explicitly rebound per run | — (the exception, not the rule) |
18
+ | 3 | `asyncio.start_server` fails | yes | `python-syscalls.ts` replaces parts of `asyncio` and refuses server creation | M6 — virtual sockets |
19
+ | 4 | Every program reports the same `os.getpid()` | yes | one interpreter is one process | **fixed in M2** — a process per program, with `getpid` wrapped to ask the kernel |
20
+ | 5 | `subprocess.run` succeeds, via the container's command table | yes | a bridge, not a process: no fresh interpreter, no descriptor inheritance, no process groups | M3 |
21
+
22
+ Fresh globals reset none of: `sys.modules`, logging handlers, registered
23
+ callbacks, native extension state, or running tasks. Finding 2 is worth naming
24
+ precisely because it is the one piece of state the integration *does* rebind —
25
+ which is why "each program gets a clean slate" feels true until it isn't.
26
+
27
+ ## What this means for the plan
28
+
29
+ - Findings 1 and 4 are integration failures, not interpreter failures. A
30
+ differently-built CPython dropped into the same lifecycle would reproduce both.
31
+ That is the argument for building the kernel first.
32
+ - Finding 3 is a missing subsystem. No amount of interpreter work reaches it.
33
+ - Finding 5 is the one that reads as working and is not, which makes it the most
34
+ expensive to leave in place.
35
+
36
+ ## Not yet measured
37
+
38
+ Concurrency and server behaviour under load, native extension state across
39
+ process exits, and interpreter memory growth over repeated runs. These need the
40
+ process model of M3 before the measurement means anything.
41
+
42
+ ## Known gaps in the owned runtime (M2)
43
+
44
+ Recorded so they are not rediscovered as surprises. None of them is hidden at
45
+ runtime: each either refuses with a reason or is declared absent in
46
+ `runtime.json`.
47
+
48
+ | Gap | Symptom | Milestone |
49
+ |---|---|---|
50
+ | No packaging | `pip` is micropip and targets the Pyodide interpreter; under this backend it refuses rather than installing into the wrong interpreter | M4 |
51
+ | No `rlcompleter` / `_pyrepl` in the stdlib image | the REPL prints `warning: can't use pyrepl` and falls back to the basic prompt, which works | M2 follow-up — a `wasm_assets` inclusion |
52
+ | No sockets | anything binding a port, Uvicorn included, cannot start | M6 |
53
+ | No spawn from Python | `subprocess` has no backend on this runtime | M3 |
54
+ | Symlinks | `symlink()` returns ENOSYS through the kernel filesystem | M3 |