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.
- package/README.md +96 -63
- package/assets/logo.png +0 -0
- package/bin/sandboxedjs-egress.mjs +25 -10
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/service-worker.js +3 -2
- package/docs/agent/COMMANDS.md +85 -0
- package/docs/agent/DECISION-TREE.md +84 -0
- package/docs/agent/INVARIANTS.md +40 -0
- package/docs/agent/LAUNCH-PROMPT.md +37 -0
- package/docs/agent/LOOP.md +84 -0
- package/docs/agent/README.md +77 -0
- package/docs/agent/ROADMAP.md +37 -0
- package/docs/agent/STATE.md +93 -0
- package/docs/agent/tasks/00-verify-inherited-work.md +36 -0
- package/docs/agent/tasks/01-authoritative-metadata.md +34 -0
- package/docs/agent/tasks/02-native-dependencies.md +35 -0
- package/docs/agent/tasks/03-reproducible-inputs.md +27 -0
- package/docs/agent/tasks/04-build-frontends.md +27 -0
- package/docs/agent/tasks/05-registry-integration.md +27 -0
- package/docs/agent/tasks/06-package-cohorts.md +42 -0
- package/docs/agent/tasks/07-build-on-miss-boundary.md +31 -0
- package/docs/browser-runtime-architecture.md +142 -0
- package/docs/compatibility-implementation-plan.md +98 -0
- package/docs/developer-tool-packs.md +134 -0
- package/docs/frontend-automation.md +49 -0
- package/docs/fullstack-deployment.md +163 -0
- package/docs/handoff.md +275 -0
- package/docs/original-x64.md +39 -0
- package/docs/platform-hardening.md +49 -0
- package/docs/python/abi.md +97 -0
- package/docs/python/architecture.md +94 -0
- package/docs/python/baseline-inventory.md +54 -0
- package/docs/python/build-on-miss.md +198 -0
- package/docs/python/compatibility.md +206 -0
- package/docs/python/cross-build.md +354 -0
- package/docs/python/extensions.md +282 -0
- package/docs/python/release-gates.md +46 -0
- package/docs/python/virtual-sockets-plan.md +331 -0
- package/docs/runtime-lifecycle-fixes.md +39 -0
- package/docs/server-previews.md +268 -0
- package/docs/virtual-browser.md +120 -0
- package/package.json +5 -3
package/docs/handoff.md
ADDED
|
@@ -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 |
|