# Handoff — SandboxedJS

## Repo state

`npx tsc --noEmit` passes, `npm run build` passes, and **316 tests pass**
including the network acceptance suite:

```bash
SANDBOXEDJS_CLEAN_NETWORK_TESTS=1 npx vitest run
```

(The network tests hit the real npm registry and take ~2 min: Express,
`npx serve`, `npm install` + `npx`, `npm create vite`, a Vite 7 dev server, and
an interactive clack prompt driven through `Terminal`.)

## Context

`@scelar/nodepod` was removed entirely (licence: Commons Clause). The Node.js
runtime is this package's own, under `src/runtime/`. It must stay
browser-capable: **nothing in `src/runtime/` may import a `node:` builtin**
except through `nodeOnlyModule`/`nodeBuiltin` (`src/util/binary.ts`) on a path
that returns `null` off Node. Several npm shims had to be replaced because they
are not browser-safe — see `util-module.ts`, `assert-module.ts`,
`zlib-module.ts`, `url-module.ts`, `readline-module.ts`, `readable-from.ts`.

## Verified in a real browser

Chrome, against the Vite host app at
`/Users/shazi/Practice/sandboxedjs tes/browser-server` (`npm run dev`). That
project has a `check.html` / `src/browser-check.ts` page that boots a container
and drives `Terminal` through the whole set; open `/check.html` and it prints a
pass/fail line per check:

- `readline.question` answered, with the typed text echoed as it is typed
- `https.get` from inside the sandbox reaching the npm registry
- clipboard round-trip through `pbcopy`/`pbpaste`
- `npm create vite@latest` accepting a project name and advancing
- `ctrl+c` cancelling a prompt and returning to the shell
- `npx serve --debug` starting with no errors and serving a file

## How isolation is put together

`WorkerRuntimePod` (`src/runtime/worker-runtime-pod.ts`) extends
`LocalRuntimePod` and overrides `spawn` alone; everything else in the contract
is identical. Both are held to `test/pod-contract.ts`, which is the thing to
extend when either changes.

The topology is forced, not chosen. A synchronous call must block its caller
while the work it waits on still progresses, so the blocking side cannot own
the shared state — otherwise a child needing the filesystem would call into a
frozen thread. Hence: guest in the Worker, volume and kernel on the host.

- `sync-channel.ts` — `SharedArrayBuffer` + `Atomics.wait`, chunked both ways.
  Only the client blocks; the server answers from its ordinary event loop.
- `remote-volume.ts` — `RuntimeVolume` over that channel. Because the interface
  was already the seam, `core-modules`, `Vfs` and the kernel are untouched.
- `sync-syscalls.ts` — the filesystem and `spawnSync` share one channel.
- `worker-entry.ts` — the guest half, built as its own bundle.

Two packaging traps, both already paid for:

1. The guest bundle must be **fully self-contained** (`noExternal` in
   `tsup.config.ts`). The main bundle leaves `buffer` and friends external and
   lets the host's bundler map them onto polyfills; the Worker is fetched as
   its own module graph and gets no such help, so an external `buffer` resolves
   to raw CommonJS with no named exports and the Worker dies before it runs.
2. `new URL("./worker-entry.js", import.meta.url)` **does** survive Vite's
   dependency pre-bundling — verified in Chrome, it fetches from
   `node_modules/sandboxedjs/dist/`. Do not "fix" this with a blob URL: a blob
   worker has no module-resolution context and could no longer `import()` the
   host's esbuild or Rolldown binding.

## Verifying the preview service worker

The in-app browser pane refuses service worker registration outright — even a
one-line worker fails — so the preview cannot be checked there and will always
report itself unavailable. Real Chrome works. The quickest check is
`/swcheck.html` in the harness, which stands up a tiny server and asserts that
an absolute-path subresource is routed; `/preview.html` runs the full
`create-vite` flow and takes about half a minute. Headless Chrome over CDP works
too, which is how this was verified.

Three things bit during that verification and are easy to re-introduce:
`navigator.serviceWorker.ready` never resolves for a worker whose scope excludes
the registering page; the claim path arrives under the worker's scope directory,
so its pattern must not be anchored to the start; and a framed response needs
`Cross-Origin-Embedder-Policy` as well as `Cross-Origin-Resource-Policy` or an
isolated parent refuses it.

## A trap worth remembering: inherited stdin is a terminal

`stdio: "inherit"` hands a child the parent's standard input, and a terminal
never ends. Give it a pipe and anything that reads to end-of-file first will
wait forever — and `node` does that in `captureStdin` (`src/runtime/node.ts`)
before it runs a script. The symptom is remote from the cause: a shell script
that launches node prints nothing at all and hangs, with no error anywhere.

`KernelChildProcess` (`node-child-process-bridge.ts`) therefore marks an
inheriting child's stdin `isTTY`/`interactive`. `test/sync-child-process.test.ts`
pins the reduced case — `spawnSync('sh', ['-c', 'node -e …'], {stdio:"inherit"})`
— which is much cheaper to run than the create-vite flow it was found in.

## The interactive-input chain, for future work

1. `Terminal.key()` — `src/container/terminal.ts`. While a command runs it
   writes each keystroke to `this.currentStdin` (a `Pipe`). **Raw mode changes
   what it does**: no local echo, no CR→LF translation, and `ctrl+c`/`ctrl+d`
   are passed through as keystrokes rather than becoming a signal and EOF.
2. `node` command — `src/runtime/node.ts`, `execute()`. Forwards `ctx.stdin`
   into `proc.write(...)`, propagates EOF via `proc.endInput()`, and mirrors the
   program's `rawmode` event back onto `ctx.stdin.rawMode`.
3. `LocalProcess.write` — `src/runtime/local-runtime-pod.ts`. Buffers input
   until the task starts, then hands it to `core.writeStdin`.
4. `createCoreModules` — `src/runtime/core-modules.ts`. With
   `interactiveStdin: true`, `process.stdin` is a `PassThrough` that stays open;
   `tty: true` sets `isTTY` on all three streams; `setRawMode` calls back into
   `options.onRawMode`.
5. `readline-module.ts`. `emitKeypressEvents(stream)` turns incoming data into
   `keypress` events. `createInterface` defaults `terminal` from `output.isTTY`,
   as Node does; a terminal `Interface` owns raw mode, keeps `line`/`cursor`
   current, and echoes. **Prompt libraries read `rl.line` for the answer**, so
   anything that stops it tracking shows up as an empty answer and a re-prompt,
   not as an error.

`settle()` in `local-runtime-pod.ts` decides when a process is finished. It
counts pending timers, `core.readingStdin()` and `core.pendingRequests()` — an
in-flight HTTP request is event-loop work and schedules no timer of its own.
Check this first if a process exits before something asynchronous completes.

## Known gaps

- **Vite 8 / Rolldown works in a browser, not under a Node host.** (The old note
  saying it cannot run at all was wrong.) In a browser it needs two things from
  the host app, both now in the README: COOP/COEP headers, and
  `optimizeDeps.exclude: ["@rolldown/binding-wasm32-wasi"]` so the bundler does
  not pre-bundle away the `import.meta.url` its WASI worker is created from.
  With those, `npm create vite` installs and the dev server serves transformed
  modules — verified in Chrome. Under Node the binding's other build makes a
  `node:wasi` instance preopening the real filesystem root, which cannot be
  redirected at the sandbox volume, so Rolldown never finds the project and Vite
  answers with its fallback page. Fixing that means running the *browser* build
  on Node, which needs a `fetch` that handles `file:` URLs and a global `Worker`
  over `worker_threads`. Vite 7 works on both and is what the Node test pins.

- **`spawnSync`/`execSync`/`execFileSync` work under the Worker pod**, which is
  the default. See *Isolation* in the README for the fallback rules. Under the
  in-realm pod they still throw, naming the command — guest, child and event
  loop share a thread there, so blocking the caller stops the child.
- **esbuild in a browser.** `host-esbuild.ts` borrows the host's `esbuild-wasm`
  on Node and returns `null` in a browser, so browser Vite transforms fail.
- **No service worker**, so preview iframes have no URL. `box.request()` works
  everywhere.
- **`curl` to a non-CORS host cannot work in a browser.** Platform limit, not a
  bug; the error message says so. The npm registry does send CORS headers, which
  is why installing packages works.
- **`child_process.execSync` and friends** cannot exist: they would have to
  block the JS thread. They throw `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`.
- Vite prints two `util` externalization warnings from `readable-stream`, which
  declares `"util": false` for browsers and falls back on its own. Harmless.

## Python: blocking syscalls, and what they are built on

`src/runtime/python-syscalls.ts` is the layer that makes Pyodide behave like a
Linux Python rather than a sandboxed evaluator. The constraint it removes is
that **WebAssembly cannot wait for a JavaScript promise**: Pyodide's `setStdin`
callback is synchronous, so anything only the host can answer asynchronously
had to be answered immediately or not at all. Answering "not at all" is what
made `input()` raise `EOFError`, and it is the same wall behind `socket`,
`subprocess` and `time.sleep`.

The primitive is WebAssembly stack switching (JSPI), reached through Pyodide's
`run_sync`. It suspends the whole interpreter stack until a host promise
settles, so an ordinary `def` — nested arbitrarily deep — can block while the
event loop keeps running. Verified available unflagged in Node 25 and Chrome
137+. **Adding a blocking syscall is now a method on the host facade**, not
another special case; that is the point of the file.

Three are wired:

- **stdin.** `sys.stdin` is rebuilt as a real `TextIOWrapper`, not a patched
  `input()` — otherwise `csv.reader(sys.stdin)` stays broken. EOF still raises
  `EOFError` as CPython does.
- **processes.** Only `subprocess.Popen` is replaced; `run`, `call`,
  `check_call` and `check_output` are written in terms of it upstream, so the
  family comes with it. The child is a *live* process on the host's event loop
  (`kernel.spawn` + pipes), not a finished result: the parent suspends only
  when it reads, waits or communicates, so `for line in p.stdout` follows a
  child that is still running and `poll()` can say "running". `os.system` and
  `os.popen` go to the same place — `os.system` previously returned 0 having
  run *nothing*, which reads as success and is worse than an error.
- **HTTP.** `urllib` handlers call `performRequest` (`src/net/commands.ts`),
  the same path as `curl`. Deliberately not `fetch`: that is what makes Python
  obey the container's `allowOutbound` policy instead of routing around it.

### Third-party HTTP stacks, and a network-policy escape

A library that brings its own transport does not go through `urllib`, and in
Pyodide several reach JavaScript's `fetch` directly. **`requests` used to reach
the internet from a container with `allowOutbound` off, while `curl` in the
same container was correctly refused** — a real hole in the sandbox, found by
testing the policy rather than the feature. `test/python-syscalls.test.ts`
guards it.

Such libraries cannot all be patched at boot, because pip installs them later.
So adapters are registered by module name and applied when that module is first
imported, through a hook on `builtins.__import__`. **Supporting another stack
is a small function registered there, not a change to the machinery.** The
`requests` adapter replaces `HTTPAdapter.send` — the one seam every call
crosses, below sessions/redirects/cookies/retries and above the urllib3
transport that would otherwise reach the network itself.

### Hosts without stack switching

Everything blocking rests on JSPI, and Pyodide 0.28 ships JSPI-only — there is
no Asyncify build to fall back to. Where it is missing, the degradation is
deliberate rather than incidental:

- piped and redirected stdin still work, from the pre-drained buffer;
- anything that must genuinely wait (interactive input, a child process) says
  so plainly instead of faking end-of-file;
- `requests` falls back to the library's own transport rather than breaking —
  **but the network policy is not the part that degrades.** A host the
  container forbids is still refused. Capability degrades; the sandbox does not.

The only way to get blocking without JSPI is a Worker plus
`SharedArrayBuffer`/`Atomics.wait` — the machinery `sync-channel.ts` already
has for the Node runtime. It is a real project: Pyodide would move off the main
thread and its Emscripten filesystem would have to reach the volume over the
channel, and in a browser it additionally requires cross-origin isolation
(COOP/COEP), which is a deployment requirement and not only code.

Five traps, each already paid for and each cheap to reintroduce:

1. **Pyodide maps JS `null` to a truthy `JsNull` proxy; only `undefined`
   becomes `None`.** `buffered()` returns `undefined` to mean "you must
   suspend", and a `?? EMPTY` on that path silently turned it back into
   end-of-file — the exact bug the bridge exists to remove.
2. **One interpreter is shared per container**, so its streams belong to
   whichever program is running. A Python child binds over its parent while the
   parent is suspended, so every host call that can suspend goes through
   `resuming()`, which puts the parent's binding back.
3. **`kernel.spawn` does not close a child's output pipes when it exits** —
   `Container.spawn` does that itself — and a reader waiting on a pipe that
   never ends waits forever. This deadlocked the whole suite once.
4. **`Popen` must not run at construction.** `subprocess.run` hands `input=` to
   `communicate()`, never to `Popen`.
5. **Pyodide builds urllib without `ssl`, so `urllib.request.HTTPSHandler` does
   not exist** to subclass. One handler subclassing `HTTPHandler` serves both
   schemes, which also keeps `build_opener` treating it as a replacement.

`asyncio.run` and `run_until_complete` are patched onto `run_sync` for the same
reason: programs execute under `eval_code_async`, so Pyodide's loop is always
already running and `asyncio.run` refuses to start a second one.

Known remaining gaps:

- **Python cannot accept connections.** `loop.create_server` is unimplemented
  on Pyodide's WebLoop, so uvicorn and aiohttp install, import and start but
  never bind — outbound requests work, inbound do not. Closing this means
  widening the `RuntimePod` contract with a serve hook so Python can register
  with `VirtualHttpRouter` (`src/runtime/virtual-http.ts`), the same router
  that already routes `curl localhost:3000` to a Node server in the container,
  plus an asyncio transport that frames HTTP between the router's structured
  requests and the protocol's raw bytes. `router` is currently `protected` on
  `LocalRuntimePod`; `WorkerRuntimePod` inherits it, so one implementation
  covers both. For now the failure at least explains itself.

- A library with its own transport and no registered adapter can still reach
  the network directly and escape the policy. `requests` and `urllib` are
  covered; the hook makes the next one cheap, but it is opt-in by design.
- Package resolution: `loadPackagesFromImports` only sees Pyodide's lockfile
  and `micropip` only installs pure-Python wheels, so `opencv-python` and
  everything else needing a C extension built for wasm32-emscripten remains out
  of reach. The fix is a resolution chain (lockfile → PyPI pure-Python → a wasm
  binary-wheel index → clear failure), not a special case per library.
