sandboxedjs 0.2.11 → 0.2.13

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 (48) hide show
  1. package/README.md +21 -9
  2. package/assets/logo.png +0 -0
  3. package/bin/sandboxedjs-egress.mjs +25 -10
  4. package/dist/index.cjs +87 -3
  5. package/dist/index.js +87 -3
  6. package/dist/python/python.data +140 -141
  7. package/dist/python/python.js +1 -1
  8. package/dist/python/python.wasm +0 -0
  9. package/dist/python/runtime.json +3 -3
  10. package/dist/python-worker.js +9 -0
  11. package/dist/service-worker.js +3 -2
  12. package/docs/agent/COMMANDS.md +85 -0
  13. package/docs/agent/DECISION-TREE.md +84 -0
  14. package/docs/agent/INVARIANTS.md +40 -0
  15. package/docs/agent/LAUNCH-PROMPT.md +37 -0
  16. package/docs/agent/LOOP.md +84 -0
  17. package/docs/agent/README.md +77 -0
  18. package/docs/agent/ROADMAP.md +37 -0
  19. package/docs/agent/STATE.md +93 -0
  20. package/docs/agent/tasks/00-verify-inherited-work.md +36 -0
  21. package/docs/agent/tasks/01-authoritative-metadata.md +34 -0
  22. package/docs/agent/tasks/02-native-dependencies.md +35 -0
  23. package/docs/agent/tasks/03-reproducible-inputs.md +27 -0
  24. package/docs/agent/tasks/04-build-frontends.md +27 -0
  25. package/docs/agent/tasks/05-registry-integration.md +27 -0
  26. package/docs/agent/tasks/06-package-cohorts.md +42 -0
  27. package/docs/agent/tasks/07-build-on-miss-boundary.md +31 -0
  28. package/docs/browser-runtime-architecture.md +132 -0
  29. package/docs/compatibility-implementation-plan.md +98 -0
  30. package/docs/developer-tool-packs.md +134 -0
  31. package/docs/frontend-automation.md +49 -0
  32. package/docs/fullstack-deployment.md +163 -0
  33. package/docs/handoff.md +275 -0
  34. package/docs/original-x64.md +39 -0
  35. package/docs/platform-hardening.md +49 -0
  36. package/docs/python/abi.md +97 -0
  37. package/docs/python/architecture.md +94 -0
  38. package/docs/python/baseline-inventory.md +54 -0
  39. package/docs/python/build-on-miss.md +198 -0
  40. package/docs/python/compatibility.md +206 -0
  41. package/docs/python/cross-build.md +354 -0
  42. package/docs/python/extensions.md +282 -0
  43. package/docs/python/release-gates.md +46 -0
  44. package/docs/python/virtual-sockets-plan.md +331 -0
  45. package/docs/runtime-lifecycle-fixes.md +39 -0
  46. package/docs/server-previews.md +268 -0
  47. package/docs/virtual-browser.md +120 -0
  48. package/package.json +6 -3
@@ -0,0 +1,163 @@
1
+ # Full-stack projects across browser and Node hosts
2
+
3
+ The guest project can keep its frontend, FastAPI backend, and `http://127.0.0.1:8000`
4
+ API URLs. Hosting prerequisites belong to the outer application that embeds SandboxedJS.
5
+ They cannot all be removed by a JavaScript library.
6
+
7
+ | Where SandboxedJS executes | Frontend → guest backend | Backend → external API | Lifetime |
8
+ | --- | --- | --- | --- |
9
+ | Browser, served by a static CDN | `createPreview(box)` rewrites loopback fetch/XHR/EventSource requests into virtual ports | Direct when browser policies permit; otherwise a deployed egress function or an explicitly configured relay | Owner tab stays open |
10
+ | Long-running Node process | `box.request()` or an application HTTP adapter; `box.expose()` for local access | Node performs outbound requests | Process stays alive |
11
+ | Node serverless function | Use `box.request()` within the invocation; return its result through the platform's HTTP response | Node performs outbound requests | Invocation only; do not rely on a background server surviving the response |
12
+
13
+ A Vercel-hosted browser app is still a **browser** runtime. Serving its assets from Vercel
14
+ does not give code in the visitor's browser Node networking permissions. A purely static
15
+ host cannot itself relay an API that rejects cross-origin browser calls. Use a function on
16
+ that host, or provide `network.proxy` pointing to a relay you operate.
17
+
18
+ ## Browser host setup
19
+
20
+ 1. Serve over HTTPS (localhost is suitable for development).
21
+ 2. Send these HTTP response headers on the outer document:
22
+
23
+ ```http
24
+ Cross-Origin-Opener-Policy: same-origin
25
+ Cross-Origin-Embedder-Policy: require-corp
26
+ ```
27
+
28
+ 3. Serve the matching worker and Python runtime assets from the installed package. When
29
+ copying them into `public`, copy them on **every production build**, not just on `dev`:
30
+
31
+ ```json
32
+ {
33
+ "scripts": {
34
+ "predev": "npm run stage:runtime",
35
+ "prebuild": "npm run stage:runtime",
36
+ "build": "tsc && vite build"
37
+ }
38
+ }
39
+ ```
40
+
41
+ The staging script must copy `dist/service-worker.js` from the same installed
42
+ `sandboxedjs` as the host bundle. Copy the guest worker, Python worker, and Python
43
+ distribution if your host explicitly serves those from `public` too. Do not keep an
44
+ independently maintained old service worker. Alternatively, let your bundler emit the
45
+ default worker asset used by `createPreview()`.
46
+
47
+ 4. Register the emitted worker and use its URL for the iframe:
48
+
49
+ ```ts
50
+ const preview = await createPreview(box, { scriptUrl: '/service-worker.js' });
51
+ if (!preview) throw new Error('Preview worker could not be registered');
52
+ iframe.src = preview.urlFor(3000);
53
+ ```
54
+
55
+ Without a copied/custom worker, omit `scriptUrl`. Preserve the same-origin worker asset
56
+ as a file; do not inline it as a data URL. A strict guest CSP that rejects the injected
57
+ scripts, compressed HTML, or `{ websocket: false }` prevents the current loopback shim
58
+ from being installed. Use normal uncompressed preview HTML with injection enabled.
59
+
60
+ 5. Enable outbound access for package installation and API calls:
61
+ `network: { allowOutbound: true }`. Start both guest servers, check their exit/output
62
+ and `waitForPort()` results, and keep the owning container alive.
63
+
64
+ ## External APIs: create the relay in the right directory
65
+
66
+ Run from the **host application root**, not the guest's `/workspace` shell:
67
+
68
+ ```sh
69
+ # Cloudflare Pages
70
+ npx sandboxedjs-egress init --target cloudflare --allow ollama.com,api.openai.com
71
+ # Vercel
72
+ npx sandboxedjs-egress init --target vercel --allow ollama.com,api.openai.com
73
+ # Netlify
74
+ npx sandboxedjs-egress init --target netlify --allow ollama.com,api.openai.com
75
+ ```
76
+
77
+ Use one command for the platform you deploy to. An explicit project directory may appear
78
+ before or after the flags. Older CLI builds incorrectly treated a flag value as the
79
+ project directory; if you see a folder called `ollama.com,api.openai.com` or `cloudflare`,
80
+ upgrade the CLI and generate the function at the root instead.
81
+
82
+ Expected paths:
83
+
84
+ | Platform | File relative to host project root | Probe URL |
85
+ | --- | --- | --- |
86
+ | Pages | `functions/__sandboxedjs__/egress.ts` | `/__sandboxedjs__/egress` |
87
+ | Vercel | `api/__sandboxedjs__/egress.ts` | `/api/__sandboxedjs__/egress` |
88
+ | Netlify | `netlify/functions/sandboxedjs-egress.ts` | `/.netlify/functions/sandboxedjs-egress` |
89
+
90
+ A GET to the deployed probe must return JSON containing
91
+ `{"sandboxedjs":"egress","protocol":1}`, not the site's HTML. Containers discover these
92
+ paths automatically. Development uses `npx sandboxedjs-egress --allow ollama.com` or
93
+ `npx sandboxedjs-serve dist`; Vite alone does not execute Pages Functions.
94
+
95
+ Deploy the function along with the static build. Cloudflare dashboard drag-and-drop does
96
+ not compile a `functions` directory: use Git integration or Wrangler from the project root.
97
+ See [Cloudflare's deployment documentation](https://developers.cloudflare.com/pages/get-started/direct-upload/).
98
+
99
+ Keep the relay's allowlist narrow and apply your application's authentication and rate limits
100
+ at the host route. Guest `.env` files and browser-delivered keys are visible to the visitor;
101
+ operator-owned credentials belong in server-side secrets. Do not commit actual keys.
102
+
103
+ ## FastAPI and a static frontend
104
+
105
+ Inside one container, use the project's normal commands:
106
+
107
+ ```sh
108
+ cd /workspace/backend
109
+ pip install -r requirements.txt
110
+ fastapi run main.py --host 0.0.0.0 --port 8000 &
111
+ cd /workspace/frontend
112
+ npx serve . -l 3000
113
+ ```
114
+
115
+ Preview port 3000 with `preview.urlFor(3000)`. Its fetch to
116
+ `http://127.0.0.1:8000/agent/run/stream` must appear in browser developer tools as a
117
+ same-origin `/…/__sbx__/8000/agent/run/stream` request. No CORS relay is needed for this
118
+ internal hop. An external Ollama request made by FastAPI uses the separate egress path.
119
+
120
+ The bridge currently buffers response bodies. A finite SSE response arrives after the
121
+ backend finishes, preserving its event text, but tokens do not appear progressively.
122
+ An indefinitely open stream will time out. `createPreview(box, { timeoutMs: 600_000 })`
123
+ changes the preview timeout; it cannot change a hosting platform's function limits.
124
+
125
+ ## Node and serverless usage
126
+
127
+ The stable transport API is `box.request(port, { method, path, headers, body })`. Start the
128
+ backend and wait for its virtual port before invoking it. Return `response.bytes`, status,
129
+ and headers using your framework's response adapter. Clean up the container when its
130
+ owning request/session ends. `box.expose()` opens a local socket and is useful for local
131
+ Node development; it does not create a public route in Vercel.
132
+
133
+ Serverless workers may be frozen or discarded between invocations. Store files/session
134
+ state externally if needed and reconstruct the container, or use a persistent host for
135
+ interactive sessions. Package support, available workers/WASM, memory, payload sizes and
136
+ [function duration limits](https://vercel.com/docs/functions/limitations) still apply.
137
+ This project does not promise that an arbitrary browser workload runs unchanged inside an
138
+ edge runtime.
139
+
140
+ ## Diagnose the actual failed hop
141
+
142
+ - **Frontend still requests `127.0.0.1:8000` in developer tools:** check the deployed worker
143
+ for `installPreviewFetch`, rebuild staged assets, then reload the host and reopen the
144
+ preview. Also check CSP/injection restrictions. Changing FastAPI CORS does not fix an
145
+ address that points at the visitor's machine.
146
+ - **Same-origin `__sbx__/8000` returns 502:** check backend process output and
147
+ `await box.request(8000, { path: '/docs' })`.
148
+ - **Backend receives the request but its AI call fails:** verify the egress probe, allowed
149
+ hostname, upstream credentials and upstream response. Test authenticated and ordinary
150
+ user sessions independently; application permissions may differ.
151
+ - **504 after a long wait:** inspect backend completion and buffering/host time limits.
152
+ - **Works locally but not after build:** inspect actual deployed headers, worker bytes,
153
+ copied runtime versions and function deployment. A successful asset build alone does
154
+ not deploy a function.
155
+
156
+ ## Regression evidence
157
+
158
+ `test/preview-worker-build.test.ts` evaluates the distributed service worker, executes its
159
+ injected script, and verifies the loopback POST route and navigation paths.
160
+ `test/egress-cli.test.ts` checks generated paths and flag order for all three platforms.
161
+ `test/fixtures/fullstack-browser` builds a real static browser fixture with a Python
162
+ FastAPI backend, a separate frontend, and a finite SSE POST. It needs no AI credentials;
163
+ see its README for running it.
@@ -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.