sandboxedjs 0.2.11 → 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 +16 -9
  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,134 @@
1
+ # Optional developer tools and binary backends
2
+
3
+ This is an extension mechanism and two limited developer-tool adapters, not
4
+ universal Linux compatibility. The core has no new third-party dependencies,
5
+ paid endpoint, account requirement, or automatic downloads.
6
+
7
+ ## Working developer tools
8
+
9
+ Install engines in the **host application**, then pass them to the adapters:
10
+
11
+ ```sh
12
+ npm install sandboxedjs isomorphic-git @electric-sql/pglite
13
+ ```
14
+
15
+ ```js
16
+ import git from 'isomorphic-git';
17
+ import { PGlite } from '@electric-sql/pglite';
18
+ import { createContainer, createGitCommand, createSqlCommand } from 'sandboxedjs';
19
+
20
+ const box = await createContainer({ cwd: '/app' });
21
+ const db = new PGlite();
22
+ box.kernel.installCommand(createGitCommand(git));
23
+ box.kernel.installCommand(createSqlCommand(db));
24
+
25
+ await box.exec('git init');
26
+ await box.fs.writeFile('/app/hello.txt', 'hello');
27
+ await box.exec('git add hello.txt');
28
+ await box.exec('git commit -m first', {
29
+ env: { GIT_AUTHOR_NAME: 'Your Name', GIT_AUTHOR_EMAIL: 'you@example.com' },
30
+ });
31
+ console.log(await box.exec('git log'));
32
+ console.log(await box.exec('sql -c "SELECT 42 AS answer"'));
33
+
34
+ // The application owns the database lifetime and persistence configuration.
35
+ await db.close();
36
+ box.dispose();
37
+ ```
38
+
39
+ Git supports `init`, `add PATH...`, `commit -m MESSAGE`, `status`, and `log`.
40
+ These are local operations backed by isomorphic-git and the credential-aware
41
+ container filesystem. Clone, fetch, push, branches, checkout and full Git CLI
42
+ compatibility are not implemented. No CORS proxy is configured implicitly.
43
+
44
+ `sql -c SQL` and `sql -f FILE` print result rows as JSON. This is an embedded
45
+ SQL adapter, not `psql`, a PostgreSQL server, or a TCP endpoint for `pg` clients.
46
+ Database storage belongs to PGlite, separately from the container filesystem.
47
+ Use its persistence options in the host; container snapshots do not capture it.
48
+ The supplied database API does not provide hard query cancellation; a timeout
49
+ is not a transaction rollback. Do not share the same database across tenants.
50
+
51
+ Both adapters are trusted host integrations. This pass validated real Git
52
+ commits with isomorphic-git 1.42.2 and real SQL with PGlite 0.5.8 on Node,
53
+ not a browser UI. Their upstream engines
54
+ support browsers; host asset loading must still be configured and verified.
55
+
56
+ ## Precompiled command packs
57
+
58
+ Packs live outside the core. A host can load their manifests and artifact bytes
59
+ from its own storage and call:
60
+
61
+ ```js
62
+ import { installWasmCommands } from 'sandboxedjs';
63
+ installWasmCommands(box.kernel, [
64
+ { name: 'my-tool', bytes: wasmBytes, sha256: trustedManifest.sha256 },
65
+ ]);
66
+ await box.exec('my-tool --help');
67
+ ```
68
+
69
+ Supply a wasm32-wasi command compatible with this runtime's WASI preview1 host.
70
+ Hashes detect changed artifact bytes; trust in the manifest comes from the host.
71
+ Names, collisions, hashes and Wasm validity are checked before commands are
72
+ installed. No network fetch, registry, package archive or license acceptance is
73
+ hidden in this API. WASI imports and behaviors still need compatibility checks;
74
+ valid Wasm alone does not prove that a command can execute here.
75
+
76
+ ## ELF execution and fallback
77
+
78
+ The kernel now recognizes executable ELF files instead of treating them as
79
+ shell scripts. It consults `box.kernel.binaries` in this order:
80
+
81
+ 1. `compatibility`: exact binary hashes mapped to tested ports.
82
+ 2. `translation`: a supplied compiler producing compatible WASI commands.
83
+ 3. `emulation`: a supplied emulator backend.
84
+
85
+ `createWasmCompatibilityBackend(id, entries)` registers mappings of
86
+ `{ elfSha256, wasm: { name, bytes, sha256 } }`. Matching content rather than a
87
+ command name prevents silently substituting a port for a different version.
88
+
89
+ `createTranslationBackend(translator, maxCacheBytes?)` wraps a compiler with a
90
+ 32 MiB default in-memory LRU output cache. The compiler supplies `id`,
91
+ `supports(info)` and `translate(request, signal)`. Give it a versioned identity.
92
+ Return null only when the input is unsupported. Thrown errors are surfaced.
93
+ This wrapper **does not contain an ELF compiler**. Its checks use a fixture
94
+ translator, not a claim that native instructions were translated in the test.
95
+ Ordinary elfconv/Emscripten output cannot be assumed to match our WASI ABI.
96
+
97
+ An emulator implements `BinaryBackend`, with `tier: 'emulation'` and
98
+ `prepare(request, signal)`. Register it with `box.kernel.binaries.register()`;
99
+ the returned function unregisters it. `list()` reports installed providers.
100
+ An original experimental x86-64 translator and interpreter are now available
101
+ through `createOriginalX64Backends()`. They support a small freestanding subset;
102
+ see [Original engines](original-x64.md). No Linux image is shipped.
103
+
104
+ Preparation returns either `{ supported: false, reason }` or
105
+ `{ supported: true, program: { run(ctx) } }`. Preparation must not execute guest
106
+ code or modify guest files. Backends receive an AbortSignal and must honor it.
107
+ After `run()` starts, its exit code is final: retrying on another backend could
108
+ duplicate writes or other effects. A native program with no supported backend
109
+ exits 126 with a diagnostic. Hard interruption requires a backend worker; the
110
+ existing in-realm WASI runner cannot interrupt a tight compute loop.
111
+
112
+ ## Package priorities
113
+
114
+ | Priority | Tools | Approach and current status |
115
+ | --- | --- | --- |
116
+ | 1 | Git | Optional isomorphic-git adapter; local subset implemented |
117
+ | 1 | PostgreSQL SQL | Optional PGlite adapter implemented; embedded SQL only |
118
+ | 2 | jq, ripgrep, SQLite CLI, diff/patch | Candidate separate WASI packs; not bundled or validated here |
119
+ | 3 | C/C++ compiler and build tools | Separate large toolchain pack; compilation and subprocess support need work |
120
+ | Later | Redis-compatible services | Evaluate a separate engine and exact protocol/command coverage |
121
+ | Separate project | Docker Engine | Requires Linux kernel facilities; not a small WASI shim |
122
+
123
+ Broad binary translation and emulation coverage remain substantive follow-up work.
124
+ Do not advertise arbitrary ELF, Docker, complete PostgreSQL service compatibility,
125
+ or WebContainer performance parity based on this extension layer.
126
+
127
+ ## Cost and licensing
128
+
129
+ SandboxedJs remains MIT. Upstream PGlite and isomorphic-git publish permissive
130
+ licenses, but retain the notices and check the specific versions you distribute.
131
+ A compatibility layer does not remove a tool's license obligations or make
132
+ enterprise software free. Host-supplied packs let users choose their own tools
133
+ and licenses. Hosting, compilation and network services have real resource costs;
134
+ this API does not require a paid provider or promise free third-party hosting.
@@ -0,0 +1,49 @@
1
+ # Frontend browser automation
2
+
3
+ `createFrontendPlaywright(options?)` provides a Playwright-shaped API that runs
4
+ entirely in the page hosting SandboxedJs. It drives a same-origin `<iframe>`
5
+ using the browser the user already has. It does **not** bundle, download or
6
+ launch Chromium, and it is not Playwright.
7
+
8
+ ```js
9
+ import { createFrontendPlaywright } from 'sandboxedjs';
10
+
11
+ const pw = createFrontendPlaywright({ mount: document.querySelector('#tests') });
12
+ const browser = await pw.chromium.launch();
13
+ const page = await browser.newPage();
14
+
15
+ await page.goto('/preview/index.html');
16
+ await page.getByRole('textbox', { name: 'Email' }).fill('a@example.com');
17
+ await page.getByRole('button', { name: 'Submit' }).click();
18
+ await pw.expect(page.getByText('Thanks')).toBeVisible();
19
+
20
+ await pw.dispose();
21
+ ```
22
+
23
+ ## Supported
24
+
25
+ `page`: `goto`, `setContent`, `title`, `content`, `evaluate`,
26
+ `setDefaultTimeout`, `close`; locators via `locator`, `getByTestId`,
27
+ `getByText`, and `getByRole` for `button`, `textbox`, `link`, `checkbox`
28
+ (plus any explicit `role=` attribute).
29
+
30
+ `locator`: `first`, `nth`, `locator`, `count`, `isVisible`, `textContent`,
31
+ `innerText`, `inputValue`, `getAttribute`, `click`, `fill`, `waitFor`.
32
+ Locators are strict: more than one match throws unless you narrow with
33
+ `first()`/`nth()`. Actions wait for the element to be visible and enabled.
34
+
35
+ `expect(locator)`: `toBeVisible`, `toHaveText`.
36
+
37
+ ## Not supported
38
+
39
+ `screenshot`, `route`/network interception, cross-origin navigation, browser
40
+ launch options, and `firefox`/`webkit` all throw
41
+ `FrontendAutomationUnsupported`. Clicks and typing are dispatched DOM events,
42
+ not OS-level trusted input; `capabilities.trustedEvents` is `false`. `goto`
43
+ returns `null` rather than a Response. `evaluate` uses `eval` inside the frame
44
+ and fails where the page's CSP forbids it.
45
+
46
+ Pass `resolveUrl` to map a container's virtual server URL to the same-origin
47
+ preview URL served by the SandboxedJs service worker. Inspect
48
+ `pw.capabilities` before relying on any behavior; treat this as a labeled
49
+ subset for smoke-testing your own preview output, not a Chromium replacement.
@@ -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.