Skip to content

feat: Pyodide Python runtime via OS-isolated Deno subprocess#124

Open
NeilMazumdar wants to merge 16 commits into
Hazzng:mainfrom
NeilMazumdar:feature/pyodide-python-runtime
Open

feat: Pyodide Python runtime via OS-isolated Deno subprocess#124
NeilMazumdar wants to merge 16 commits into
Hazzng:mainfrom
NeilMazumdar:feature/pyodide-python-runtime

Conversation

@NeilMazumdar

@NeilMazumdar NeilMazumdar commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replaces the boolean python capability with python_runtime: "stdlib" | "pyodide" | null. stdlib keeps the existing air-gapped CPython-emscripten python3; pyodide runs numpy/pandas/scipy/openpyxl fully offline inside an OS-isolated Deno subprocess; null means no Python.
  • Delivers the issue's core requirement (Add a Pyodide Python runtime for in-sandbox data analysis (numpy/pandas/openpyxl) #118): create a pyodide sandbox → upload a CSV → python3 analyze.py runs import pandas, writes out.xlsx, and the file is retrievable via the files API.
  • Isolation is an explicit single-layer Deno boundary (zero-permission deny-belt + scrubbed env), with security treated as a first-class, adversarially-tested merge gate rather than an afterthought.

Design Decisions

Full rationale in thoughts/issue-118-pyodide-runtime/design.md. The decisions reviewers should understand:

  • Single-layer Deno isolation (explicit threat model). The pyodide runtime spawns a Deno child with a scrubbed env (env:{} + DENO_NO_UPDATE_CHECK=1) under a committed deny-belt — --no-prompt --deny-net --deny-run --deny-write --deny-env --deny-ffi --deny-sys --deny-import --no-remote --no-npm --cached-only --no-config — with --allow-read scoped to the vendored asset dir only. A full Python→JS escape lands capability-less. A Deno/V8 runtime escape is explicitly out of the threat model (compensating controls: no secrets in the child, read-only asset scope); gVisor/seccomp is a recommended operator add-on, not a hard requirement. This reverses the prior C1 audit decision and needs maintainer/security sign-off.
  • Committed length-prefixed JSON IPC over stdin/stdout with realm-lockdown. Every frame carries mandatory integrity fields (random requestId, monotonic seq, message type, child generation) plus per-frame/aggregate size caps and exactly-one-response-per-request. The Node side kills the child on any malformed/oversized/duplicate/out-of-sequence/wrong-generation frame. Spike S2 finding A: escaped JS can write raw bytes to stdout (import("node:fs").writeSync(1, …)), but it cannot produce an accepted frame — the integrity secrets are never exposed to untrusted code. This is the load-bearing control.
  • Rolling-deploy-safe migration (expand/contract). Adds nullable python_runtime TEXT CHECK IN ('stdlib','pyodide'), idempotent backfill WHERE python_runtime IS NULL (re-runs safely every boot under the no-tracking-table migration model). Mixed-version reads use COALESCE(python_runtime, CASE WHEN python THEN 'stdlib' END); new writes dual-write the legacy python boolean so old replicas keep working. The python column drop is deferred to a later release (N+1).
  • Per-session warm subprocess + residency LRU. One Deno child per session (amortizes the multi-second cold start), serialized by a per-subprocess mutex. A global residency registry caps resident children at MAX_RESIDENT_PYODIDE independent of SESSION_IDLE_MS, with atomic admission (lazy, post-semaphore) so concurrent cold starts can never exceed the cap. busy/starting workers are never evictable.
  • Memory posture (accepted availability risk). No guaranteed per-child OOM isolation — spike S3 confirmed node:22-slim non-root cannot write cgroup memory.max and RLIMIT_AS is unusable against V8's huge virtual reservation. The operator-set container memory limit is the only real guard; size it as MAX_RESIDENT_PYODIDE × per-proc ceiling + Node baseline + DB/Redis pools.
  • Cancellation = throw, never return. Timeout/abort/protocol-violation kills the child, retires that generation, and drains nothing — throwing triggers the script-transaction rollback, so a partial/garbage drain can never commit.

Changes

  • DB / contractpython_runtime field through Zod validation, types.ts, Postgres dialect (read COALESCE + dual-write), migration 0006_python_runtime.sql; create/list/get all echo it (fixes the prior single-GET omission); both SDKs (TS + Python), MCP tools, OpenAPI spec, and skill docs updated together (coordinated breaking release).
  • Pyodide runtimesrc/pyodide-runner/ (Deno-side harness + protocol), src/api/pyodide/ (Node-side PyodideSandbox manager, IPC client, residency LRU), src/api/commands/pyodide-command.ts (the python3/python custom commands + diff-and-drain file staging).
  • Concurrency — dedicated MAX_CONCURRENT_PYODIDE semaphore (+ queue/wait-timeout env vars) routed independently of the stdlib python semaphore; startup invariant MAX_RESIDENT_PYODIDE >= MAX_CONCURRENT_PYODIDE.
  • Offline packaging — vendored Deno binary + pinned Pyodide 0.29.x distribution + custom lockfile (numpy/pandas/scipy/openpyxl + transitive deps via micropip.freeze tooling); Dockerfile + build scripts.
  • Tests — unit suites for IPC integrity, manager lifecycle, residency LRU, and the pyodide command; integration suites for the migration, the end-to-end pyodide flow, and the adversarial escape + frame-forgery security gate.

How to Verify

  • Programmatic: pnpm typecheck && pnpm lint && pnpm test:unit (all green; 973 unit tests).
  • Programmatic: pnpm test:integration — the escape/forgery suites run only when the vendored Deno + Pyodide assets are present (skip cleanly otherwise). Note: on hosts with a low Postgres max_connections, run the multi-replica integration tests with --maxWorkers=2 to avoid connection-slot exhaustion (environmental, not a code issue).
  • Human (security sign-off): read escape.integration.test.ts + frame-forgery.integration.test.ts; confirm coverage of the C1-reversal surface and accept the single-layer threat model (Deno/V8 escape out of scope) for the deployment.
  • Human (acceptance): run the real flow (upload CSV → python3 analyze.py → download out.xlsx) through the product UI / LibreChat.
  • Human: validate memory under load and cold-start UX on the target host; confirm image portability (vendored Deno + assets) on a clean self-hosted install.

References

Note for reviewers

Rebased onto current upstream/main (tip 72a8857) — the branch was previously ~4 commits behind (MCP static-header auth #120, dev-postgres setup #122, SDK 0.3.1 #121, ingest guard #116). Conflicts in the SDK version/changelog files were resolved by keeping both entries (the SDK CHANGELOGs now carry upstream's 0.3.1 ingest-guard entry and this PR's 0.4.0 python_runtime break); the ingest-guard feature in sandbox.ts/sandbox.py is preserved. The PR is now conflict-free (MERGEABLE); post-rebase typecheck + lint + test:unit (1013 tests) all pass. History is intentionally one commit per phase (Phase 0 spikes → Phase 7 security gate) for independent review/revert.

Summary by CodeRabbit

  • Breaking Changes

    • Sandbox Python flag replaced: python: boolean → python_runtime: "stdlib" | "pyodide" | null. HTTP/API/SDKs/tools updated; create rejects legacy key. Migrate python=true → "stdlib", python=false → omit/null.
  • New Features

    • Optional Pyodide runtime (scientific packages) and CPython‑WASM "stdlib" mode.
    • Sandbox responses now include a network flag.
  • Documentation

    • SDK docs, examples, changelogs, and migration guidance updated for python_runtime and SDK v0.4.0.

@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d8287787-8d36-45fd-8ce5-0c9376b3b12c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replace sandbox boolean python with nullable python_runtime and add a Pyodide execution stack: vendored assets, Deno runner protocol, Node IPC manager and residency, command staging/drain, session wiring, DB migration, SDK/docs updates, and broad tests.

Changes

Pyodide runtime rollout

Layer / File(s) Summary
Contract propagation
.changeset/python-runtime-enum.md, src/api/routes/sandboxes.ts, src/api/openapi-spec.ts, src/api/mcp/tools.ts, src/sql-fs/types.ts, clients/python/*, clients/typescript/*, plugins/sql-fs/skills/*
Replaces python with python_runtime (`"stdlib"
DB migration & types
src/sql-fs/migrations/postgres/0006_python_runtime.sql, src/sql-fs/dialects/postgres.ts, src/sql-fs/types.ts
Adds nullable python_runtime column with CHECK, backfill from legacy python, and dual-write/read compatibility logic.
Vendoring & build
scripts/fetch-pyodide-assets.mjs, scripts/build-pyodide-lock.mjs, scripts/copy-pyodide-runner.mjs, Dockerfile, package.json, .dockerignore, .gitignore, biome.json, tsconfig.json
Adds asset vendoring and lock build scripts, copies runner TS into dist, vendors assets in Docker build, and updates build/test scripts and ignore rules.
Runner protocol & Deno runner
src/pyodide-runner/protocol.ts, src/pyodide-runner/runner.ts
Defines length-prefixed JSON frame protocol and implements the offline Deno runner that loads Pyodide, stages MEMFS, executes code, snapshots diffs, and emits framed responses.
Node IPC & manager
src/api/pyodide/ipc.ts, src/api/pyodide/manager.ts, src/api/pyodide/residency.ts
Node-side framing, inbound validation, PyodideSandbox manager (spawn/handshake/run/timeout/kill/respawn), and LRU residency registry for resident workers.
Commands, staging & drain
src/api/commands/pyodide-command.ts
Registers python3/python commands for pyodide sessions; stages cwd/ext scripts with per-file/total caps; validates and applies drain diffs safely.
Session wiring & throttling
src/api/session-manager.ts, src/api/pyodide-runtime-context.ts, src/api/lib/batch-exec.ts, src/api/routes/exec.ts
Adds pyodide semaphores, single-flight admission, lazy admission under semaphore, propagate EPYODIDE_TIMEOUT semantics, and integrate residency touch/release and teardown.
Tests & integration
src/api/pyodide/tests/**/*, src/api/tests/**/*, clients/*/tests/*, src/sql-fs/dialects/tests/*
Adds unit/integration suites for IPC framing/validation, manager behavior, residency invariants, frame-forgery and escape adversarial tests, drain-path validation, and end-to-end Pyodide integration tests (conditional).
Design & docs
thoughts/issue-118-pyodide-runtime/*, CLAUDE.md, changelogs, READMEs
Design docs, spikes, research findings, environment knobs, and operator guidance on memory posture and runtime configuration.

Estimated code review effort
🎯 5 (Critical) | ⏱️ ~90+ minutes

Possibly related PRs

  • Hazzng/sql-fs#115: Prior SDK sandbox API work introducing the legacy python?: boolean surface that this PR replaces with python_runtime.

Poem

🐰 I fetched the wheels and hid them tight,
I framed my messages in the night.
Deno whispered back a safe result,
I drained the files and sealed the vault.
Hooray — the rabbit kept runtime right.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

@NeilMazumdar NeilMazumdar force-pushed the feature/pyodide-python-runtime branch from 22cf3fd to 69dd10c Compare June 8, 2026 10:48

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
clients/python/tests/test_client.py (1)

1-667: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Test file exceeds 300-line limit — split by concern.

As per coding guidelines, test files should be kept under 300 lines and split by concern (e.g., test_client.auth.py, test_client.sandboxes.py, test_client.files.py, test_client.exec.py). This file is 667 lines (223% over the limit).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/python/tests/test_client.py` around lines 1 - 667, The test module is
too large (667 lines) and must be split into smaller files by concern; move
related test functions into new modules such as test_client.auth.py (e.g.
test_bootstrap_exchanges_auth_secret_for_token,
test_bootstrap_admin_secret_uses_admin_endpoint,
test_constructor_requires_credentials,
test_constructor_requires_sub_with_secret), test_client.sandboxes.py (all
sandbox CRUD tests: test_list_sandboxes, test_create_sandbox_returns_handle,
test_create_sandbox_with_network_passes_flag,
test_create_sandbox_network_default_omitted,
test_get_sandbox_404_raises_notfound, test_delete_sandbox), test_client.files.py
(file-related tests: test_read_file_with_stat_header,
test_write_file_sends_octet_stream, test_write_files_bulk,
test_delete_recursive_passes_query_param,
test_delete_nonempty_dir_raises_conflict, test_mkdir_with_recursive,
test_tree_returns_typed_entries, test_ingest_files_base64_encodes),
test_client.exec.py (exec-related and retry-policy tests:
test_exec_returns_flat_result, test_exec_timeout_raises_typed_exception,
test_exec_batch, test_exec_batch_read_only_forwards_flag,
test_exec_stream_yields_events_until_exit, test_exec_read_only_sends_flag,
test_exec_read_only_violation_raises_validation_error,
test_exec_batch_read_only_sends_flag, test_exec_default_does_not_retry_5xx,
test_exec_ecoherence_never_retries_even_with_retry_on_5xx,
test_exec_read_only_retries_5xx_automatically,
test_exec_read_only_retries_ecoherence,
test_exec_retry_on_5xx_opt_in_retries_runtime_busy,
test_exec_retry_on_5xx_default_false_does_not_send_flag,
test_exec_batch_retry_on_5xx_replays_whole_batch,
test_exec_batch_default_does_not_retry_5xx) and test_client.errors.py (error
mapping tests: test_400_maps_to_validation_error, test_403_maps_to_auth_error,
test_429_maps_to_rate_limit_with_retry_after); for each new file, preserve
imports (pytest, respx, httpx, make_client, BASE_URL, and exception classes),
ensure module-level fixtures/constants remain available or are duplicated, and
run the test suite to verify all imports and respx decorators still work.

Source: Coding guidelines

src/api/mcp/tools.ts (1)

37-56: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Expose network consistently in the MCP sandbox contract.

This tool still hard-codes network: false and drops network from the returned JSON, while the sandbox metadata surface treats it as part of the runtime state. That means MCP callers cannot create network-enabled JS sandboxes here, and sandbox_list also loses information for sandboxes created through the HTTP API.

Based on the runtime-contract propagation called out in the PR objectives, MCP should stay aligned with the public sandbox metadata surface.

Also applies to: 62-67, 97-105

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/mcp/tools.ts` around lines 37 - 56, The MCP sandbox creation
currently hard-codes network: false and omits network from persisted/returned
metadata; update the handler to read args.network (optional boolean) and
propagate it into runtimeOptions.network instead of the hard-coded false,
include that network value when calling sessionManager.getOrCreate and
sessionManager.persistSandboxMeta (add network to the persisted object alongside
python_runtime and javascript), and ensure any response object returned by this
endpoint includes the network field; apply the same change pattern to the other
similar MCP handlers referenced (the blocks around the other
getOrCreate/persistSandboxMeta calls).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@clients/typescript/CHANGELOG.md`:
- Around line 5-18: Revert the manual edits in CHANGELOG.md and instead create a
proper changeset entry describing the release changes (e.g., mention the python
→ python_runtime rename, SandboxRecord.network addition, and exported
PythonRuntime type) using the Changesets workflow; run pnpm changeset to add the
.md file under .changeset, commit that changeset, then run pnpm
changeset:version (or your repo's release step) to update CHANGELOG.md
automatically—do not keep manual edits in CHANGELOG.md.

In `@clients/typescript/src/models.ts`:
- Line 91: The assignment "python_runtime: (payload.python_runtime ?? null) as
PythonRuntime" unsafely asserts the API value; replace it with a validation step
that checks payload.python_runtime against the PythonRuntime enum/allowed values
(or a shared isPythonRuntime(value) type-guard/validator) before casting; if
invalid, set null or handle error consistently. Locate the mapping where
"python_runtime" is assigned in models.ts (the payload-to-model mapper) and add
the guard/validator, reusing or adding a small helper function (e.g.,
isPythonRuntime) if this pattern appears elsewhere.

In `@plugins/sql-fs/skills/api/ref/bash.md`:
- Around line 48-53: The current paragraph conflates performance/behavior for
both python_runtime modes; split the guidance into two distinct bullet blocks
labeled for python_runtime: "stdlib" and "pyodide" (reference the existing
`"stdlib"` and `"pyodide"` labels) and clearly state for `"stdlib"` that it is a
fresh CPython WASM interpreter per invocation with cold-start cost, limited RAM,
no persistence across calls and strict concurrency limits, while for `"pyodide"`
state that it runs in a long-lived Deno subprocess with preloaded
numpy/pandas/scipy/openpyxl, lower per-call cold-starts, larger
memory/concurrency capacity, and file I/O behavior (files under cwd are drained
back to sandbox FS); update or remove any shared bullets that imply the WASM
cold-start/RAM/concurrency profile applies to both modes.

In `@scripts/build-pyodide-lock.mjs`:
- Around line 53-57: The parseNameVersion function currently assumes
fileName.split("-") yields at least two segments; add defensive validation in
parseNameVersion to verify the split result contains a non-empty name and
version (and optionally validate .whl suffix or match against the same findWheel
regex) and if invalid throw or return a clear error so downstream lockfile
generation cannot be corrupted; update callers (e.g., where findWheel is used)
to handle the thrown error or skip invalid files.

In `@src/api/pyodide/ipc.ts`:
- Around line 136-148: assertFsEntry currently only checks that FsEntry.data is
a string, allowing non-base64 payloads to pass; update assertFsEntry to validate
that when entry.kind === "file" the data is valid base64 (and still require
empty string for dirs). Implement this by verifying entry.data matches a base64
pattern or attempting a safe decode and rejecting on failure, and throw
IpcIntegrityError(`${label}: invalid/missing base64 data`) from assertFsEntry to
ensure malformed payloads are rejected before drain.

In `@src/api/pyodide/manager.ts`:
- Around line 339-365: The write to child.stdin in `#sendRunFrame` (and the other
write site around lines 419-445) can emit async stream errors that bypass the
synchronous try/catch; before calling child.stdin.write(encodeFrame(frame))
attach temporary 'error' and 'close' handlers on child.stdin that call
this.#failOwned(err instanceof Error ? err : new Error(String(err)), true) (or
pass a PyodideChildExitError when appropriate), and remove those handlers
immediately after the write completes or the callback fires to avoid leaks;
ensure you still keep the existing null/undefined check for child.stdin and
perform the same handler setup/cleanup at both write sites so any
broken-pipe/destroyed stream is routed through `#failOwned`().

In `@src/api/pyodide/tests/integration/frame-forgery.integration.test.ts`:
- Around line 91-96: Add an afterEach that ensures the session created in
beforeEach is deleted: afterEach(async () => { if (session) await
sm.delete(session); session = undefined; }); This should reference the existing
session variable created by sm.getOrCreate(TENANT, ...) and use the
session-manager API (sm.delete) to remove the sandbox immediately after each
test so tests don't accumulate sessions; include a null-check and await the
deletion to avoid race conditions.

In `@src/api/tests/unit/session-manager.test.ts`:
- Around line 737-907: The Pyodide-specific tests grouped under the describe
titles "SessionManager Pyodide semaphore (MAX_CONCURRENT_PYODIDE) — Phase 6" and
"SessionManager Pyodide residency startup invariant — Phase 6" should be
extracted into the existing session-manager.pyodide.test.ts to keep concerns
separated; copy the entire describe blocks (including helper functions
stubBashExec, setEnv, PYODIDE const and any uses of T, makeCreateFs,
SessionManager, and the afterEach/active teardown usage) into that file, update
imports/fixtures there so T and makeCreateFs are available, and delete the
copied blocks from session-manager.test.ts so the original file drops below the
300-line guideline while preserving test behavior and names.

In `@thoughts/issue-118-pyodide-runtime/design.md`:
- Around line 173-178: This design currently requires shipping Pyodide (WASM)
which conflicts with the repo's "no WASM dependencies" constraint; update the
design.md to either (a) add an explicit decision record/exception that
authorizes including Pyodide (state who approved, scope, security/size
tradeoffs, and mitigation), or (b) provide an alternative non-WASM path (e.g.,
native CPython runtime or remote execution) and describe fallback behavior for
loadPyodide({ indexURL, lockFileURL, packageBaseUrl }) and the packaging steps
that use micropip.freeze; reference the Pyodide version pin (0.29.x), the
Docker/packaging step, and the preload list (numpy,pandas,scipy,openpyxl) so
reviewers can see where the policy applies.

In `@thoughts/issue-118-pyodide-runtime/spikes/s1-pyodide-deno.sh`:
- Line 71: Replace the ls|wc invocation with a robust find-based count: instead
of echoing the output of ls "${PYODIDE_DIR}" | wc -l, run a find rooted at
"${PYODIDE_DIR}" that counts regular files (and optionally directories) to avoid
miscounting with strange filenames; update the echo line that references
PYODIDE_DIR and the diagnostic text "[s1] pyodide assets: … files in
${PYODIDE_DIR}" so it prints the result of the find-based count rather than the
ls-based count.

In `@thoughts/issue-118-pyodide-runtime/spikes/S3-findings.md`:
- Around line 10-14: The fenced code block containing the cgroup/prlimit
findings should include a language specifier to satisfy the linter and improve
rendering; edit the block that currently starts with triple backticks and the
lines "cgroup_write_denied=1 rlimit_as_unusable=1 ..." and change the opening
fence to include a language token such as text or console (e.g., ```text) so the
block is properly annotated.

In `@tsconfig.json`:
- Line 22: tsconfig.json currently excludes "src/pyodide-runner/runner.ts",
bypassing type-checks for a security-critical entrypoint; either remove that
path from the "exclude" array so runner.ts is included in the TypeScript project
(ensuring the compiler options like strict and noUncheckedIndexedAccess apply),
or if exclusion is required, add a dedicated CI gate that runs tsc (or a
targeted tsc --project) against src/pyodide-runner/runner.ts and document this
new CI check in the repo README; reference the "exclude" entry and the file
src/pyodide-runner/runner.ts when making the change.

---

Outside diff comments:
In `@clients/python/tests/test_client.py`:
- Around line 1-667: The test module is too large (667 lines) and must be split
into smaller files by concern; move related test functions into new modules such
as test_client.auth.py (e.g. test_bootstrap_exchanges_auth_secret_for_token,
test_bootstrap_admin_secret_uses_admin_endpoint,
test_constructor_requires_credentials,
test_constructor_requires_sub_with_secret), test_client.sandboxes.py (all
sandbox CRUD tests: test_list_sandboxes, test_create_sandbox_returns_handle,
test_create_sandbox_with_network_passes_flag,
test_create_sandbox_network_default_omitted,
test_get_sandbox_404_raises_notfound, test_delete_sandbox), test_client.files.py
(file-related tests: test_read_file_with_stat_header,
test_write_file_sends_octet_stream, test_write_files_bulk,
test_delete_recursive_passes_query_param,
test_delete_nonempty_dir_raises_conflict, test_mkdir_with_recursive,
test_tree_returns_typed_entries, test_ingest_files_base64_encodes),
test_client.exec.py (exec-related and retry-policy tests:
test_exec_returns_flat_result, test_exec_timeout_raises_typed_exception,
test_exec_batch, test_exec_batch_read_only_forwards_flag,
test_exec_stream_yields_events_until_exit, test_exec_read_only_sends_flag,
test_exec_read_only_violation_raises_validation_error,
test_exec_batch_read_only_sends_flag, test_exec_default_does_not_retry_5xx,
test_exec_ecoherence_never_retries_even_with_retry_on_5xx,
test_exec_read_only_retries_5xx_automatically,
test_exec_read_only_retries_ecoherence,
test_exec_retry_on_5xx_opt_in_retries_runtime_busy,
test_exec_retry_on_5xx_default_false_does_not_send_flag,
test_exec_batch_retry_on_5xx_replays_whole_batch,
test_exec_batch_default_does_not_retry_5xx) and test_client.errors.py (error
mapping tests: test_400_maps_to_validation_error, test_403_maps_to_auth_error,
test_429_maps_to_rate_limit_with_retry_after); for each new file, preserve
imports (pytest, respx, httpx, make_client, BASE_URL, and exception classes),
ensure module-level fixtures/constants remain available or are duplicated, and
run the test suite to verify all imports and respx decorators still work.

In `@src/api/mcp/tools.ts`:
- Around line 37-56: The MCP sandbox creation currently hard-codes network:
false and omits network from persisted/returned metadata; update the handler to
read args.network (optional boolean) and propagate it into
runtimeOptions.network instead of the hard-coded false, include that network
value when calling sessionManager.getOrCreate and
sessionManager.persistSandboxMeta (add network to the persisted object alongside
python_runtime and javascript), and ensure any response object returned by this
endpoint includes the network field; apply the same change pattern to the other
similar MCP handlers referenced (the blocks around the other
getOrCreate/persistSandboxMeta calls).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bf216db2-3162-451e-8a20-ddaa584b8f30

📥 Commits

Reviewing files that changed from the base of the PR and between 72a8857 and 22cf3fd.

⛔ Files ignored due to path filters (1)
  • clients/python/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (83)
  • .changeset/python-runtime-enum.md
  • .dockerignore
  • .gitignore
  • CLAUDE.md
  • Dockerfile
  • biome.json
  • clients/python/CHANGELOG.md
  • clients/python/README.md
  • clients/python/examples/quickstart.py
  • clients/python/pyproject.toml
  • clients/python/src/sqlfs/__init__.py
  • clients/python/src/sqlfs/_version.py
  • clients/python/src/sqlfs/client.py
  • clients/python/src/sqlfs/models.py
  • clients/python/tests/test_client.py
  • clients/typescript/CHANGELOG.md
  • clients/typescript/README.md
  • clients/typescript/examples/e2e-local.ts
  • clients/typescript/examples/quickstart.ts
  • clients/typescript/package.json
  • clients/typescript/src/client.ts
  • clients/typescript/src/index.ts
  • clients/typescript/src/models.ts
  • clients/typescript/src/version.ts
  • clients/typescript/tests/sandboxes.test.ts
  • package.json
  • plugins/sql-fs/skills/api/SETUP.md
  • plugins/sql-fs/skills/api/ref/bash.md
  • plugins/sql-fs/skills/api/ref/endpoints.md
  • plugins/sql-fs/skills/py-sdk/SKILL.md
  • plugins/sql-fs/skills/py-sdk/ref/client.md
  • plugins/sql-fs/skills/py-sdk/ref/models.md
  • scripts/build-pyodide-lock.mjs
  • scripts/copy-pyodide-runner.mjs
  • scripts/fetch-pyodide-assets.mjs
  • src/api/commands/pyodide-command.ts
  • src/api/mcp/tools.ts
  • src/api/openapi-spec.ts
  • src/api/pyodide/ipc.ts
  • src/api/pyodide/manager.ts
  • src/api/pyodide/residency.ts
  • src/api/pyodide/tests/integration/escape.integration.test.ts
  • src/api/pyodide/tests/integration/frame-forgery.integration.test.ts
  • src/api/pyodide/tests/unit/fake-child.ts
  • src/api/pyodide/tests/unit/ipc.test.ts
  • src/api/pyodide/tests/unit/manager.test.ts
  • src/api/pyodide/tests/unit/pyodide-command.test.ts
  • src/api/pyodide/tests/unit/residency.test.ts
  • src/api/routes/sandboxes.ts
  • src/api/session-manager.ts
  • src/api/tests/integration/migrations.integration.test.ts
  • src/api/tests/integration/pyodide.integration.test.ts
  • src/api/tests/session-manager.script-tx.test.ts
  • src/api/tests/unit/exec-batch.test.ts
  • src/api/tests/unit/exec.test.ts
  • src/api/tests/unit/files.test.ts
  • src/api/tests/unit/ingest.test.ts
  • src/api/tests/unit/mcp-tools.test.ts
  • src/api/tests/unit/sandboxes.test.ts
  • src/api/tests/unit/session-manager.pyodide.test.ts
  • src/api/tests/unit/session-manager.rehydrate.test.ts
  • src/api/tests/unit/session-manager.test.ts
  • src/pyodide-runner/protocol.ts
  • src/pyodide-runner/runner.ts
  • src/sql-fs/dialects/postgres.ts
  • src/sql-fs/dialects/tests/unit/postgres.advisory-lock.test.ts
  • src/sql-fs/migrations/postgres/0006_python_runtime.sql
  • src/sql-fs/types.ts
  • thoughts/issue-118-pyodide-runtime/design.md
  • thoughts/issue-118-pyodide-runtime/plan.md
  • thoughts/issue-118-pyodide-runtime/questions.md
  • thoughts/issue-118-pyodide-runtime/research.md
  • thoughts/issue-118-pyodide-runtime/spikes/.gitignore
  • thoughts/issue-118-pyodide-runtime/spikes/S1-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/S2-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/S3-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/s1-pyodide-deno.sh
  • thoughts/issue-118-pyodide-runtime/spikes/s1_runner.ts
  • thoughts/issue-118-pyodide-runtime/spikes/s2-ipc.ts
  • thoughts/issue-118-pyodide-runtime/spikes/s3-memory.sh
  • thoughts/issue-118-pyodide-runtime/structure.md
  • thoughts/issue-118-pyodide-runtime/task.md
  • tsconfig.json

Comment thread clients/typescript/CHANGELOG.md
Comment thread clients/typescript/src/models.ts Outdated
Comment thread plugins/sql-fs/skills/api/ref/bash.md
Comment thread scripts/build-pyodide-lock.mjs
Comment thread src/api/pyodide/ipc.ts
Comment thread src/api/tests/unit/session-manager.test.ts
Comment thread thoughts/issue-118-pyodide-runtime/design.md
Comment thread thoughts/issue-118-pyodide-runtime/spikes/s1-pyodide-deno.sh Outdated
Comment thread thoughts/issue-118-pyodide-runtime/spikes/S3-findings.md Outdated
Comment thread tsconfig.json
@Hazzng

Hazzng commented Jun 8, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 69dd10c00b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/pyodide-runner/runner.ts
Comment thread src/pyodide-runner/runner.ts Outdated
Comment thread src/api/session-manager.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
plugins/sql-fs/skills/api/ref/bash.md (1)

60-73: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Split the performance and concurrency guidance by runtime mode.

The performance section still conflates stdlib and pyodide behavior. Lines 60-61 state "Every call pays the WASM cold-boot cost (~1.4 s)" which only applies to python_runtime: "stdlib", not to the Pyodide subprocess model. Similarly, the RAM cost (line 73) and concurrency limits (line 72) should clarify whether they apply to one or both modes.

According to the PR objectives, Pyodide has "~5–8s cold start" (subprocess spawn + V8 + Pyodide init), "~350–400 MB per execution", and separate MAX_CONCURRENT_PYODIDE (default 2). These are materially different from the stdlib figures shown here.

Split this section into mode-specific guidance or add inline qualifiers (e.g., "stdlib: ~1.4s, pyodide: ~5–8s on cold start").

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/sql-fs/skills/api/ref/bash.md` around lines 60 - 73, Update the
performance/concurrency guidance so it distinguishes runtime modes: change the
claim "Every call pays the WASM cold-boot cost (~1.4 s)" and the RAM/concurrency
figures into explicit, per-mode statements (e.g., for python_runtime ==
"stdlib": cold start ~1.4s, ~80 MB per python3 process, server-wide concurrency
cap 5; for pyodide subprocess mode: cold start ~5–8s, ~350–400 MB per execution,
and respect MAX_CONCURRENT_PYODIDE default 2). Edit the paragraph(s) in
plugins/sql-fs/skills/api/ref/bash.md that reference the cold-boot, RAM (~80 MB)
and concurrency (5) so they either become two short mode-specific bullet points
or include inline qualifiers referencing python_runtime and
MAX_CONCURRENT_PYODIDE, and ensure the guidance about persisting state and
avoiding pip/os.system/subprocess remains unchanged.
src/api/openapi-spec.ts (1)

745-756: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Document perScriptTimeoutMs in the batch exec schema.

src/api/routes/exec.ts accepts perScriptTimeoutMs, but the OpenAPI request body omits it. That breaks generated clients and hides the new timeout mode from API consumers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/openapi-spec.ts` around lines 745 - 756, The OpenAPI schema for the
batch exec request is missing the perScriptTimeoutMs property even though
routes/exec.ts accepts it; update the schema in src/api/openapi-spec.ts (the
same object containing timeoutMs, readOnly, retryOn5xx) to add a
perScriptTimeoutMs field (type: integer, provide example and sensible
minimum/maximum and a short description explaining its per-script timeout
semantics) so generated clients and docs include the new timeout mode; ensure
the new property name matches the parameter used in routes/exec.ts exactly and
is documented alongside timeoutMs.
src/api/mcp/tools.ts (1)

250-263: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Map internal Pyodide timeouts to MCP timeouts here as well.

This catch still only reports timeout when the local timer fired. execWithRuntimeThrottle() can now throw code === "EPYODIDE_TIMEOUT" first, which means bash_exec returns a generic internal error while the HTTP and batch paths return timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/mcp/tools.ts` around lines 250 - 263, The catch block inside
bash_exec should treat internal Pyodide timeouts the same as the local timer
timeout: after clearTimeout(timer) and extra.signal.removeEventListener("abort",
onDisconnect), detect if the caught error has e?.code === "EPYODIDE_TIMEOUT" (or
equivalent) and return the same timeout result object (stdout:"",
stderr:"timeout", exitCode:-1, exitSignal:null, timedOut:true, durationMs:
Date.now() - startMs) instead of throwing; only rethrow for other errors. Ensure
you reference the existing symbols timer, extra.signal, onDisconnect, startMs,
and the function execWithRuntimeThrottle in your change.
src/api/routes/exec.ts (1)

291-299: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

A resolved streamed exec should never be rewritten to timeout.

Unlike /exec-sync, the SSE path still checks timedOut after a successful execWithRuntimeThrottle() return. If the timer fires in the same tick after completion, the client sees a timeout even though the script already finished and may have committed writes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/routes/exec.ts` around lines 291 - 299, The code currently may set
timedOut in the timer callback after execWithRuntimeThrottle() already completed
and sent an exit event; add a separate "completed" (or "finished") boolean flag
that you set to true immediately before sending any exit SSE in the successful
exec path (where stream.writeSSE is called after execWithRuntimeThrottle()), and
update the timer callback to check completed before treating the run as a
timeout (i.e., only set timedOut/send timeout SSE if completed is false). Ensure
all code paths that send an exit SSE (the post-exec success path and any
error/early exit paths) set completed = true before returning so the timer
cannot overwrite a resolved run.
src/api/routes/sandboxes.ts (2)

46-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't treat malformed JSON as an empty create request.

A body like { still falls into this broad catch and the handler proceeds with all defaults, so POST /v1/sandboxes returns 201 and creates a sandbox instead of 400. That bypasses the new strict python_runtime validation path and turns client parse errors into side effects. Only an actually empty body should be ignored; parse failures should return INVALID_INPUT.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/routes/sandboxes.ts` around lines 46 - 58, The request handler
currently swallows JSON parse errors in the try/catch around c.req.json() (used
with createBodySchema), allowing malformed JSON like "{" to be treated as an
empty create and create a sandbox; change the flow so you first read the raw
request body as text, if raw.trim() === "" treat it as an empty body (keep
existing defaults), otherwise attempt JSON.parse or c.req.json() and if parsing
fails return c.json({ error: "validation_error", code: "INVALID_INPUT", details:
[...] }, 400) instead of proceeding; update the block around c.req.json(),
createBodySchema.safeParse, and the catch to differentiate empty body vs parse
failure and ensure parse failures always return INVALID_INPUT.

187-195: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize legacy metadata on the cold-replica GET path.

This fallback now serializes meta.python_runtime, meta.javascript, and meta.network as-is. If getSandboxMeta() returns a pre-migration/legacy entry, those values are undefined, and the JSON response drops the fields entirely. That breaks the new API contract exactly on the rolling-deploy path this PR is trying to keep compatible. Default the booleans to false and derive python_runtime from legacy metadata when present.

Based on the rolling-deploy compatibility objective for python_runtime, this fallback still needs to handle pre-migration metadata.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/routes/sandboxes.ts` around lines 187 - 195, The cold-replica GET
response currently returns meta.python_runtime, meta.javascript, and
meta.network as-is, which causes those fields to be omitted when
getSandboxMeta() returns legacy metadata; update the fallback serialization in
the endpoint that constructs the JSON response (the block returning c.json with
id/name/owner/createdAt/lastUsedAt) to default javascript and network to false
when undefined and to derive python_runtime from legacy metadata (e.g., coerce
legacy values into the new python_runtime boolean) so that the response always
includes python_runtime, javascript, and network (booleans) even for
pre-migration entries.
src/api/tests/unit/sandboxes.test.ts (1)

261-293: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add direct route coverage for the new python_runtime contract.

This file only updates a fixture to the renamed metadata shape. It still does not assert that POST /v1/sandboxes accepts "pyodide"/null, rejects legacy python, and that GET /v1/sandboxes/:id returns python_runtime on both warm and cold paths. Those are the load-bearing API changes in this PR.

As per coding guidelines, unit tests should cover both success and error cases with exact assertions; the PR objective here is the python_runtime API migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/tests/unit/sandboxes.test.ts` around lines 261 - 293, Add unit tests
that directly assert the new python_runtime contract: in
src/api/tests/unit/sandboxes.test.ts add cases using makeTestApp,
SessionManager, makeToken and InMemoryFs to (1) POST /v1/sandboxes succeeds when
body.python_runtime is "pyodide" and when it is null, (2) POST /v1/sandboxes
returns a 4xx when body.python_runtime is the legacy "python" value, and (3) GET
/v1/sandboxes/:id returns a python_runtime field on both warm paths (sandbox
present in the SessionManager pool) and cold paths (only getSandboxMetaFn
provides metadata) asserting the exact returned value matches the stored
metadata; reuse the existing helpers (makeTestApp, SessionManager
constructor/getSandboxMetaFn, makeToken) and mirror the style of the existing
test that checks createdAt for setup and assertions.

Source: Coding guidelines

♻️ Duplicate comments (5)
tsconfig.json (1)

22-22: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Exclusion of runner.ts still lacks documented CI type-checking gate.

The exclusion of src/pyodide-runner/runner.ts from TypeScript compilation remains without a documented alternative validation path. While the exclusion may be justified (Deno vs Node module systems), the past review's requirement for "a dedicated CI gate that fails on runner type errors" appears unmet based on the provided context.

Either:

  1. Remove the exclusion and add a Deno-compatible tsconfig, or
  2. Document the CI gate that validates runner.ts types (e.g., deno check in the build pipeline)

The runner is security-critical (IPC integrity, realm lockdown) and should not bypass static validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tsconfig.json` at line 22, The tsconfig.json currently excludes
"src/pyodide-runner/runner.ts" via the "exclude" entry which bypasses TypeScript
checks for the security-critical runner; either remove that exclusion and add a
Deno-compatible tsconfig (or adjust existing tsconfig settings and module
resolution) so runner.ts is compiled by your CI, or keep the exclusion but add
and document a CI gate that type-checks runner.ts (e.g., run `deno check` or an
equivalent Deno type-check step in the pipeline) and link that CI job in the
repo docs; update tsconfig.json and repository CI/docs accordingly and reference
the exact excluded path "src/pyodide-runner/runner.ts" when making the change.
clients/typescript/CHANGELOG.md (1)

5-18: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

CHANGELOG should be managed via Changesets, not manually edited.

Per coding guidelines and learnings, CHANGELOG.md is generated through the Changesets workflow. In a feature PR, only the .changeset/*.md file should be committed. The CHANGELOG.md update should happen in a separate release PR via pnpm changeset:version. Manual edits here bypass the project's release automation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/typescript/CHANGELOG.md` around lines 5 - 18, Revert the manual edits
to CHANGELOG.md and instead add a Changeset file under .changeset describing the
0.4.0 changes (mentioning the python -> python_runtime migration,
SandboxRecord.python_runtime, SandboxRecord.network, and exporting
PythonRuntime) with the appropriate release type; commit only the
.changeset/*.md in this feature PR and remove CHANGELOG.md from the commit, then
for the release flow run the project's release step (pnpm changeset:version) in
the release PR to regenerate CHANGELOG.md via the Changesets workflow.

Source: Learnings

clients/typescript/src/models.ts (1)

91-91: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate API response before type assertion.

Line 91 casts payload.python_runtime to PythonRuntime without validation. If the API returns an unexpected value (e.g., "invalid", 123), the type system accepts it but runtime behavior may be incorrect.

🛡️ Add validation guard
 export function sandboxRecordFromApi(payload: ApiObject): SandboxRecord {
+	const rawRuntime = payload.python_runtime ?? null;
+	const validRuntimes = new Set<PythonRuntime>(["stdlib", "pyodide", null]);
+	if (!validRuntimes.has(rawRuntime as PythonRuntime)) {
+		throw new Error(`Invalid python_runtime from API: ${String(rawRuntime)}`);
+	}
 	return {
 		id: String(payload.id),
 		name: payload.name == null ? null : String(payload.name),
 		owner: String(payload.owner),
 		createdAt: String(payload.createdAt),
-		python_runtime: (payload.python_runtime ?? null) as PythonRuntime,
+		python_runtime: rawRuntime as PythonRuntime,
 		javascript: Boolean(payload.javascript),
 		network: Boolean(payload.network),
 	};
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/typescript/src/models.ts` at line 91, The assignment that casts
payload.python_runtime to PythonRuntime must validate the value first: add a
guard (e.g., isValidPythonRuntime or use an enum validator in the same module)
and check payload.python_runtime is a string and one of the allowed
PythonRuntime values before casting; if validation fails, set python_runtime to
null or a safe default (or throw) instead of unconditionally casting. Update the
code paths that reference python_runtime (the payload variable and the
PythonRuntime type usage in the same model factory/constructor) to use this
validated value.
src/api/pyodide/ipc.ts (1)

136-148: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing base64 validation in assertFsEntry allows forged file payloads.

The past review concern is valid: assertFsEntry only checks typeof entry.data === "string" but does not validate that entry.data is valid base64 when entry.kind === "file". A forged result frame with non-base64 data would pass integrity validation and reach the drain layer, where decoding garbage could corrupt persisted files.

Add base64 validation for file entries to reject malformed payloads before they reach the drain.

🛡️ Defensive validation
 function assertFsEntry(entry: unknown, label: string): void {
 	if (!isObject(entry)) throw new IpcIntegrityError(`${label}: entry is not an object`);
 	if (typeof entry.path !== "string" || entry.path.length === 0) {
 		throw new IpcIntegrityError(`${label}: missing/invalid path`);
 	}
 	if (entry.kind !== "file" && entry.kind !== "dir") throw new IpcIntegrityError(`${label}: invalid kind`);
 	if (typeof entry.mode !== "number" || !Number.isInteger(entry.mode) || entry.mode < 0) {
 		throw new IpcIntegrityError(`${label}: missing/invalid mode`);
 	}
 	if (typeof entry.data !== "string") throw new IpcIntegrityError(`${label}: missing/invalid data`);
+	if (entry.kind === "file" && entry.data.length > 0) {
+		// Validate base64: should only contain [A-Za-z0-9+/] and optional trailing "=" padding
+		if (!/^[A-Za-z0-9+/]*={0,2}$/.test(entry.data)) {
+			throw new IpcIntegrityError(`${label}: file data is not valid base64`);
+		}
+	}
 	if (entry.kind === "dir" && entry.data !== "")
 		throw new IpcIntegrityError(`${label}: dir entry must have empty data`);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/pyodide/ipc.ts` around lines 136 - 148, The assertFsEntry function
currently only checks that entry.data is a string; update assertFsEntry to also
validate that when entry.kind === "file" the entry.data is valid base64 and
reject otherwise by throwing IpcIntegrityError. Locate assertFsEntry and add a
check for file entries that attempts a safe base64 validation (e.g. test against
a base64 regex or attempt a decode with Buffer.from(..., "base64")/atob and
verify the round-trip or that decoding doesn't throw) and throw new
IpcIntegrityError(`${label}: invalid base64 data`) on failure so
malformed/non-base64 payloads are rejected before they reach the drain.
src/api/pyodide/manager.ts (1)

367-396: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add async error handlers for child.stdin to prevent unhandled stream failures.

The past review concern remains valid: child.stdin.write(encodeFrame(frame)) at line 392 is wrapped in synchronous try/catch, but there are no child.stdin 'error' or 'close' event handlers. If the child process terminates between the null-check at line 387 and the write at line 392, the writable stream may emit async errors (e.g., EPIPE, ERR_STREAM_DESTROYED) that bypass the try/catch and are not routed through #failOwned().

Attach temporary error/close handlers to child.stdin before writing, and remove them after the write completes to ensure broken-pipe scenarios are properly handled.

🔒 Proposed fix for stdin error handling
 	`#sendRunFrame`(op: OwnedOp): void {
 		if (op.done) return;
 		const frame: RunRequest = {
 			type: "run",
 			requestId: this.#randomRequestId(),
 			seq: ++this.#seqCounter,
 			generation: this.#generation,
 			code: op.input.code,
 			argv: op.input.argv,
 			stdin: op.input.stdin,
 			files: op.input.files,
 			cwd: op.input.cwd,
 			env: op.input.env,
 		};
 		op.requestId = frame.requestId;
 		op.seq = frame.seq;
 		op.stage = "response";
 		this.#pending = { requestId: frame.requestId, seq: frame.seq };
 		this.#state = "busy";
 		const child = this.#child;
 		if (child?.stdin === null || child?.stdin === undefined) {
 			this.#failOwned(new PyodideChildExitError(null, null), true);
 			return;
 		}
+		const onStdinError = (err: Error): void => {
+			this.#failOwned(err, true);
+		};
+		const onStdinClose = (): void => {
+			this.#failOwned(new PyodideChildExitError(null, null), true);
+		};
+		child.stdin.once("error", onStdinError);
+		child.stdin.once("close", onStdinClose);
 		try {
 			child.stdin.write(encodeFrame(frame));
+			child.stdin.removeListener("error", onStdinError);
+			child.stdin.removeListener("close", onStdinClose);
 		} catch (err) {
+			child.stdin.removeListener("error", onStdinError);
+			child.stdin.removeListener("close", onStdinClose);
 			this.#failOwned(err instanceof Error ? err : new Error(String(err)), true);
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/pyodide/manager.ts` around lines 367 - 396, In sendRunFrame, add
temporary async handlers on child.stdin (use child.stdin.once('error', ...) and
once('close', ...)) before calling child.stdin.write(encodeFrame(frame)) so
async stream errors (EPIPE / ERR_STREAM_DESTROYED) get routed to
this.#failOwned(err, true); ensure these handlers are removed after the write
completes (use the write callback or drain/finish event) to avoid leaking
listeners; keep the existing null-check for child.stdin and still wrap the
synchronous write in try/catch, but rely on the temporary listeners to catch
asynchronous failures and forward them to `#failOwned`; reference sendRunFrame,
child.stdin, encodeFrame, and `#failOwned` when applying the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Dockerfile`:
- Around line 9-10: The RUN apt-get install invocation installs curl, unzip, and
bzip2 without version pins; change the RUN line that installs these build-time
packages (the RUN ... apt-get install -y --no-install-recommends curl unzip
bzip2 ...) to pin explicit Debian package versions (e.g. curl=<version>
unzip=<version> bzip2=<version>) so builds are reproducible — determine the
exact versions from the base image (using apt-cache policy in node:22-bookworm
or equivalent) and update the package list invocation to install those pinned
versions and still clean /var/lib/apt/lists/* afterward.

In `@plugins/sql-fs/skills/py-sdk/ref/client.md`:
- Line 57: Remove the empty blank line separating the two consecutive blockquote
blocks in plugins/sql-fs/skills/py-sdk/ref/client.md so the blockquotes are
directly adjacent (this fixes MD028); locate the two successive blockquote
sections in that file and delete the intervening blank line between them.

In `@scripts/fetch-pyodide-assets.mjs`:
- Around line 98-120: In fetchDeno(), add integrity verification for the
downloaded archive by computing a SHA-256 checksum of the downloaded zip (or the
extracted DENO_BIN) and comparing it to a trusted expected value before
unpacking or using the binary; introduce or use a checksum mapping keyed by
PINS.deno/denoTarget() (e.g., DENO_SHA_MAP or a manifest fetched alongside the
release) and if the computed checksum does not match the expected value throw an
error and abort; ensure you compute the hash from the same file referenced by
zip (or DENO_BIN after extraction) using a stable Node crypto hash, compare
constant-time, and only call execFileSync("unzip", ...) / chmodSync(DENO_BIN,
...) / log success after the verification passes.

In `@src/api/commands/pyodide-command.ts`:
- Around line 474-489: The guard currently only lstats the final resolved path;
change Pyodide drain logic to walk each ancestor segment of resolved (up to but
not including root) and lstat each parent to detect any symbolic links before
performing mkdir/writeFile/chmod; if any parent lstat shows a symlink, throw the
same PyodideDrainError (use the same message pattern referencing entry.path).
Ensure you perform this ancestor-symlink check prior to the existing entry.kind
"dir" branch and before writing/chmod so no symlink ancestors are followed.
- Around line 363-369: The walk function currently swallows errors from
fs.readdir by returning in the catch, causing incomplete staged snapshots;
change the catch in walk (the async function walk(dir: string)) to surface
filesystem errors instead of returning silently — either rethrow the caught
error or throw a new descriptive Error including the original error and the dir
(do not swallow), so callers (Pyodide exec / staged snapshot logic) will fail
fast on unreadable directories rather than proceed with a partial tree.

In `@src/api/pyodide/residency.ts`:
- Around line 173-180: The idle sweeper method `#sweep`() currently
fire-and-forgets worker.dispose(), which can produce unhandled promise
rejections; update the disposal call inside the loop that removes from
this.#residents to handle rejections (e.g., append a .catch(...) to the promise
or await in a safe async helper) and log the error (or surface it via your
existing logger) so disposal failures are captured; reference the `#sweep` method,
this.#residents iteration, the `#evictable` check, this.#idleMs timeout logic, and
the worker.dispose() call when making this change.

In `@src/api/pyodide/tests/integration/escape.integration.test.ts`:
- Around line 45-61: The test suite sets process.env.PYODIDE_ASSET_DIR and
process.env.DENO_BIN_PATH in beforeAll but only restores AUTH_SECRET in
afterAll; capture the original values (e.g., savedPyodideAssetDir and
savedDenoBin) alongside savedSecret in the beforeAll setup, and in afterAll
restore them (or delete the env prop if undefined) just like AUTH_SECRET is
handled so PYODIDE_ASSET_DIR and DENO_BIN_PATH do not leak to other tests;
update the beforeAll/afterAll blocks surrounding SessionManager/session setup
and shutdown to use these saved variables.

In `@src/api/pyodide/tests/integration/frame-forgery.integration.test.ts`:
- Around line 81-89: Save the original environment values for PYODIDE_ASSET_DIR
and DENO_BIN_PATH at the start of the suite (in the same scope as beforeAll),
then restore them in afterAll; specifically, in beforeAll capture previous
values (e.g. prevPyodideAssetDir, prevDenoBinPath) before mutating process.env,
and in afterAll restore process.env.PYODIDE_ASSET_DIR and
process.env.DENO_BIN_PATH back to those captured values (setting to
undefined/null if they were unset) and do the restoration in a finally block
around sm.shutdown() so restoration always runs even if shutdown fails.

In `@src/api/tests/integration/pyodide.integration.test.ts`:
- Around line 33-52: The test suite currently only cleans sandboxes in afterAll
which can leak state; add an afterEach that iterates the cleanup array and
destroys each sandbox (using destroySandbox("postgres", id)) and then clears the
cleanup array, and update individual tests that create sandboxes to use
try/finally so they push the created id onto the shared cleanup array and ensure
removal in the finally block; reference the existing cleanup array,
destroySandbox, and SessionManager/describe.skipIf block when making these
changes.

In `@src/api/tests/unit/mcp-tools.test.ts`:
- Around line 120-128: The test "sandbox_create echoes the python_runtime it was
given" only asserts python_runtime and can miss regressions in the full tool
payload; update the test that calls captureToolHandler("sandbox_create") /
getHandler() so it parses result.content[0].text and uses
expect(parsedBody).toEqual(expectedPayload) with a complete expected object
(include id, name, javascript, python_runtime and any wrapper shape returned)
instead of just toBe on python_runtime; apply the same change to the related
test block mentioned (lines ~130-151) to assert the full JSON response payload
for exact-match assertions.

---

Outside diff comments:
In `@plugins/sql-fs/skills/api/ref/bash.md`:
- Around line 60-73: Update the performance/concurrency guidance so it
distinguishes runtime modes: change the claim "Every call pays the WASM
cold-boot cost (~1.4 s)" and the RAM/concurrency figures into explicit, per-mode
statements (e.g., for python_runtime == "stdlib": cold start ~1.4s, ~80 MB per
python3 process, server-wide concurrency cap 5; for pyodide subprocess mode:
cold start ~5–8s, ~350–400 MB per execution, and respect MAX_CONCURRENT_PYODIDE
default 2). Edit the paragraph(s) in plugins/sql-fs/skills/api/ref/bash.md that
reference the cold-boot, RAM (~80 MB) and concurrency (5) so they either become
two short mode-specific bullet points or include inline qualifiers referencing
python_runtime and MAX_CONCURRENT_PYODIDE, and ensure the guidance about
persisting state and avoiding pip/os.system/subprocess remains unchanged.

In `@src/api/mcp/tools.ts`:
- Around line 250-263: The catch block inside bash_exec should treat internal
Pyodide timeouts the same as the local timer timeout: after clearTimeout(timer)
and extra.signal.removeEventListener("abort", onDisconnect), detect if the
caught error has e?.code === "EPYODIDE_TIMEOUT" (or equivalent) and return the
same timeout result object (stdout:"", stderr:"timeout", exitCode:-1,
exitSignal:null, timedOut:true, durationMs: Date.now() - startMs) instead of
throwing; only rethrow for other errors. Ensure you reference the existing
symbols timer, extra.signal, onDisconnect, startMs, and the function
execWithRuntimeThrottle in your change.

In `@src/api/openapi-spec.ts`:
- Around line 745-756: The OpenAPI schema for the batch exec request is missing
the perScriptTimeoutMs property even though routes/exec.ts accepts it; update
the schema in src/api/openapi-spec.ts (the same object containing timeoutMs,
readOnly, retryOn5xx) to add a perScriptTimeoutMs field (type: integer, provide
example and sensible minimum/maximum and a short description explaining its
per-script timeout semantics) so generated clients and docs include the new
timeout mode; ensure the new property name matches the parameter used in
routes/exec.ts exactly and is documented alongside timeoutMs.

In `@src/api/routes/exec.ts`:
- Around line 291-299: The code currently may set timedOut in the timer callback
after execWithRuntimeThrottle() already completed and sent an exit event; add a
separate "completed" (or "finished") boolean flag that you set to true
immediately before sending any exit SSE in the successful exec path (where
stream.writeSSE is called after execWithRuntimeThrottle()), and update the timer
callback to check completed before treating the run as a timeout (i.e., only set
timedOut/send timeout SSE if completed is false). Ensure all code paths that
send an exit SSE (the post-exec success path and any error/early exit paths) set
completed = true before returning so the timer cannot overwrite a resolved run.

In `@src/api/routes/sandboxes.ts`:
- Around line 46-58: The request handler currently swallows JSON parse errors in
the try/catch around c.req.json() (used with createBodySchema), allowing
malformed JSON like "{" to be treated as an empty create and create a sandbox;
change the flow so you first read the raw request body as text, if raw.trim()
=== "" treat it as an empty body (keep existing defaults), otherwise attempt
JSON.parse or c.req.json() and if parsing fails return c.json({ error:
"validation_error", code: "INVALID_INPUT", details: [...] }, 400) instead of
proceeding; update the block around c.req.json(), createBodySchema.safeParse,
and the catch to differentiate empty body vs parse failure and ensure parse
failures always return INVALID_INPUT.
- Around line 187-195: The cold-replica GET response currently returns
meta.python_runtime, meta.javascript, and meta.network as-is, which causes those
fields to be omitted when getSandboxMeta() returns legacy metadata; update the
fallback serialization in the endpoint that constructs the JSON response (the
block returning c.json with id/name/owner/createdAt/lastUsedAt) to default
javascript and network to false when undefined and to derive python_runtime from
legacy metadata (e.g., coerce legacy values into the new python_runtime boolean)
so that the response always includes python_runtime, javascript, and network
(booleans) even for pre-migration entries.

In `@src/api/tests/unit/sandboxes.test.ts`:
- Around line 261-293: Add unit tests that directly assert the new
python_runtime contract: in src/api/tests/unit/sandboxes.test.ts add cases using
makeTestApp, SessionManager, makeToken and InMemoryFs to (1) POST /v1/sandboxes
succeeds when body.python_runtime is "pyodide" and when it is null, (2) POST
/v1/sandboxes returns a 4xx when body.python_runtime is the legacy "python"
value, and (3) GET /v1/sandboxes/:id returns a python_runtime field on both warm
paths (sandbox present in the SessionManager pool) and cold paths (only
getSandboxMetaFn provides metadata) asserting the exact returned value matches
the stored metadata; reuse the existing helpers (makeTestApp, SessionManager
constructor/getSandboxMetaFn, makeToken) and mirror the style of the existing
test that checks createdAt for setup and assertions.

---

Duplicate comments:
In `@clients/typescript/CHANGELOG.md`:
- Around line 5-18: Revert the manual edits to CHANGELOG.md and instead add a
Changeset file under .changeset describing the 0.4.0 changes (mentioning the
python -> python_runtime migration, SandboxRecord.python_runtime,
SandboxRecord.network, and exporting PythonRuntime) with the appropriate release
type; commit only the .changeset/*.md in this feature PR and remove CHANGELOG.md
from the commit, then for the release flow run the project's release step (pnpm
changeset:version) in the release PR to regenerate CHANGELOG.md via the
Changesets workflow.

In `@clients/typescript/src/models.ts`:
- Line 91: The assignment that casts payload.python_runtime to PythonRuntime
must validate the value first: add a guard (e.g., isValidPythonRuntime or use an
enum validator in the same module) and check payload.python_runtime is a string
and one of the allowed PythonRuntime values before casting; if validation fails,
set python_runtime to null or a safe default (or throw) instead of
unconditionally casting. Update the code paths that reference python_runtime
(the payload variable and the PythonRuntime type usage in the same model
factory/constructor) to use this validated value.

In `@src/api/pyodide/ipc.ts`:
- Around line 136-148: The assertFsEntry function currently only checks that
entry.data is a string; update assertFsEntry to also validate that when
entry.kind === "file" the entry.data is valid base64 and reject otherwise by
throwing IpcIntegrityError. Locate assertFsEntry and add a check for file
entries that attempts a safe base64 validation (e.g. test against a base64 regex
or attempt a decode with Buffer.from(..., "base64")/atob and verify the
round-trip or that decoding doesn't throw) and throw new
IpcIntegrityError(`${label}: invalid base64 data`) on failure so
malformed/non-base64 payloads are rejected before they reach the drain.

In `@src/api/pyodide/manager.ts`:
- Around line 367-396: In sendRunFrame, add temporary async handlers on
child.stdin (use child.stdin.once('error', ...) and once('close', ...)) before
calling child.stdin.write(encodeFrame(frame)) so async stream errors (EPIPE /
ERR_STREAM_DESTROYED) get routed to this.#failOwned(err, true); ensure these
handlers are removed after the write completes (use the write callback or
drain/finish event) to avoid leaking listeners; keep the existing null-check for
child.stdin and still wrap the synchronous write in try/catch, but rely on the
temporary listeners to catch asynchronous failures and forward them to
`#failOwned`; reference sendRunFrame, child.stdin, encodeFrame, and `#failOwned`
when applying the change.

In `@tsconfig.json`:
- Line 22: The tsconfig.json currently excludes "src/pyodide-runner/runner.ts"
via the "exclude" entry which bypasses TypeScript checks for the
security-critical runner; either remove that exclusion and add a Deno-compatible
tsconfig (or adjust existing tsconfig settings and module resolution) so
runner.ts is compiled by your CI, or keep the exclusion but add and document a
CI gate that type-checks runner.ts (e.g., run `deno check` or an equivalent Deno
type-check step in the pipeline) and link that CI job in the repo docs; update
tsconfig.json and repository CI/docs accordingly and reference the exact
excluded path "src/pyodide-runner/runner.ts" when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 50b88281-c087-498b-88ac-6dc9f7a75ef2

📥 Commits

Reviewing files that changed from the base of the PR and between 22cf3fd and 698aa12.

⛔ Files ignored due to path filters (1)
  • clients/python/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (86)
  • .changeset/python-runtime-enum.md
  • .dockerignore
  • .gitignore
  • CLAUDE.md
  • Dockerfile
  • biome.json
  • clients/python/CHANGELOG.md
  • clients/python/README.md
  • clients/python/examples/quickstart.py
  • clients/python/pyproject.toml
  • clients/python/src/sqlfs/__init__.py
  • clients/python/src/sqlfs/_version.py
  • clients/python/src/sqlfs/client.py
  • clients/python/src/sqlfs/models.py
  • clients/python/tests/test_client.py
  • clients/typescript/CHANGELOG.md
  • clients/typescript/README.md
  • clients/typescript/examples/e2e-local.ts
  • clients/typescript/examples/quickstart.ts
  • clients/typescript/package.json
  • clients/typescript/src/client.ts
  • clients/typescript/src/index.ts
  • clients/typescript/src/models.ts
  • clients/typescript/src/version.ts
  • clients/typescript/tests/sandboxes.test.ts
  • package.json
  • plugins/sql-fs/skills/api/SETUP.md
  • plugins/sql-fs/skills/api/ref/bash.md
  • plugins/sql-fs/skills/api/ref/endpoints.md
  • plugins/sql-fs/skills/py-sdk/SKILL.md
  • plugins/sql-fs/skills/py-sdk/ref/client.md
  • plugins/sql-fs/skills/py-sdk/ref/models.md
  • scripts/build-pyodide-lock.mjs
  • scripts/copy-pyodide-runner.mjs
  • scripts/fetch-pyodide-assets.mjs
  • src/api/commands/pyodide-command.ts
  • src/api/lib/batch-exec.ts
  • src/api/mcp/tools.ts
  • src/api/openapi-spec.ts
  • src/api/pyodide-runtime-context.ts
  • src/api/pyodide/ipc.ts
  • src/api/pyodide/manager.ts
  • src/api/pyodide/residency.ts
  • src/api/pyodide/tests/integration/escape.integration.test.ts
  • src/api/pyodide/tests/integration/frame-forgery.integration.test.ts
  • src/api/pyodide/tests/unit/fake-child.ts
  • src/api/pyodide/tests/unit/ipc.test.ts
  • src/api/pyodide/tests/unit/manager.test.ts
  • src/api/pyodide/tests/unit/pyodide-command.test.ts
  • src/api/pyodide/tests/unit/residency.test.ts
  • src/api/routes/exec.ts
  • src/api/routes/sandboxes.ts
  • src/api/session-manager.ts
  • src/api/tests/integration/migrations.integration.test.ts
  • src/api/tests/integration/pyodide.integration.test.ts
  • src/api/tests/session-manager.script-tx.test.ts
  • src/api/tests/unit/exec-batch.test.ts
  • src/api/tests/unit/exec.test.ts
  • src/api/tests/unit/files.test.ts
  • src/api/tests/unit/ingest.test.ts
  • src/api/tests/unit/mcp-tools.test.ts
  • src/api/tests/unit/sandboxes.test.ts
  • src/api/tests/unit/session-manager.pyodide.test.ts
  • src/api/tests/unit/session-manager.rehydrate.test.ts
  • src/api/tests/unit/session-manager.test.ts
  • src/pyodide-runner/protocol.ts
  • src/pyodide-runner/runner.ts
  • src/sql-fs/dialects/postgres.ts
  • src/sql-fs/dialects/tests/unit/postgres.advisory-lock.test.ts
  • src/sql-fs/migrations/postgres/0006_python_runtime.sql
  • src/sql-fs/types.ts
  • thoughts/issue-118-pyodide-runtime/design.md
  • thoughts/issue-118-pyodide-runtime/plan.md
  • thoughts/issue-118-pyodide-runtime/questions.md
  • thoughts/issue-118-pyodide-runtime/research.md
  • thoughts/issue-118-pyodide-runtime/spikes/.gitignore
  • thoughts/issue-118-pyodide-runtime/spikes/S1-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/S2-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/S3-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/s1-pyodide-deno.sh
  • thoughts/issue-118-pyodide-runtime/spikes/s1_runner.ts
  • thoughts/issue-118-pyodide-runtime/spikes/s2-ipc.ts
  • thoughts/issue-118-pyodide-runtime/spikes/s3-memory.sh
  • thoughts/issue-118-pyodide-runtime/structure.md
  • thoughts/issue-118-pyodide-runtime/task.md
  • tsconfig.json

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
plugins/sql-fs/skills/api/ref/bash.md (1)

60-73: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Split the performance and concurrency guidance by runtime mode.

The performance section still conflates stdlib and pyodide behavior. Lines 60-61 state "Every call pays the WASM cold-boot cost (~1.4 s)" which only applies to python_runtime: "stdlib", not to the Pyodide subprocess model. Similarly, the RAM cost (line 73) and concurrency limits (line 72) should clarify whether they apply to one or both modes.

According to the PR objectives, Pyodide has "~5–8s cold start" (subprocess spawn + V8 + Pyodide init), "~350–400 MB per execution", and separate MAX_CONCURRENT_PYODIDE (default 2). These are materially different from the stdlib figures shown here.

Split this section into mode-specific guidance or add inline qualifiers (e.g., "stdlib: ~1.4s, pyodide: ~5–8s on cold start").

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/sql-fs/skills/api/ref/bash.md` around lines 60 - 73, Update the
performance/concurrency guidance so it distinguishes runtime modes: change the
claim "Every call pays the WASM cold-boot cost (~1.4 s)" and the RAM/concurrency
figures into explicit, per-mode statements (e.g., for python_runtime ==
"stdlib": cold start ~1.4s, ~80 MB per python3 process, server-wide concurrency
cap 5; for pyodide subprocess mode: cold start ~5–8s, ~350–400 MB per execution,
and respect MAX_CONCURRENT_PYODIDE default 2). Edit the paragraph(s) in
plugins/sql-fs/skills/api/ref/bash.md that reference the cold-boot, RAM (~80 MB)
and concurrency (5) so they either become two short mode-specific bullet points
or include inline qualifiers referencing python_runtime and
MAX_CONCURRENT_PYODIDE, and ensure the guidance about persisting state and
avoiding pip/os.system/subprocess remains unchanged.
src/api/openapi-spec.ts (1)

745-756: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Document perScriptTimeoutMs in the batch exec schema.

src/api/routes/exec.ts accepts perScriptTimeoutMs, but the OpenAPI request body omits it. That breaks generated clients and hides the new timeout mode from API consumers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/openapi-spec.ts` around lines 745 - 756, The OpenAPI schema for the
batch exec request is missing the perScriptTimeoutMs property even though
routes/exec.ts accepts it; update the schema in src/api/openapi-spec.ts (the
same object containing timeoutMs, readOnly, retryOn5xx) to add a
perScriptTimeoutMs field (type: integer, provide example and sensible
minimum/maximum and a short description explaining its per-script timeout
semantics) so generated clients and docs include the new timeout mode; ensure
the new property name matches the parameter used in routes/exec.ts exactly and
is documented alongside timeoutMs.
src/api/mcp/tools.ts (1)

250-263: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Map internal Pyodide timeouts to MCP timeouts here as well.

This catch still only reports timeout when the local timer fired. execWithRuntimeThrottle() can now throw code === "EPYODIDE_TIMEOUT" first, which means bash_exec returns a generic internal error while the HTTP and batch paths return timeout.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/mcp/tools.ts` around lines 250 - 263, The catch block inside
bash_exec should treat internal Pyodide timeouts the same as the local timer
timeout: after clearTimeout(timer) and extra.signal.removeEventListener("abort",
onDisconnect), detect if the caught error has e?.code === "EPYODIDE_TIMEOUT" (or
equivalent) and return the same timeout result object (stdout:"",
stderr:"timeout", exitCode:-1, exitSignal:null, timedOut:true, durationMs:
Date.now() - startMs) instead of throwing; only rethrow for other errors. Ensure
you reference the existing symbols timer, extra.signal, onDisconnect, startMs,
and the function execWithRuntimeThrottle in your change.
src/api/routes/exec.ts (1)

291-299: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

A resolved streamed exec should never be rewritten to timeout.

Unlike /exec-sync, the SSE path still checks timedOut after a successful execWithRuntimeThrottle() return. If the timer fires in the same tick after completion, the client sees a timeout even though the script already finished and may have committed writes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/routes/exec.ts` around lines 291 - 299, The code currently may set
timedOut in the timer callback after execWithRuntimeThrottle() already completed
and sent an exit event; add a separate "completed" (or "finished") boolean flag
that you set to true immediately before sending any exit SSE in the successful
exec path (where stream.writeSSE is called after execWithRuntimeThrottle()), and
update the timer callback to check completed before treating the run as a
timeout (i.e., only set timedOut/send timeout SSE if completed is false). Ensure
all code paths that send an exit SSE (the post-exec success path and any
error/early exit paths) set completed = true before returning so the timer
cannot overwrite a resolved run.
src/api/routes/sandboxes.ts (2)

46-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't treat malformed JSON as an empty create request.

A body like { still falls into this broad catch and the handler proceeds with all defaults, so POST /v1/sandboxes returns 201 and creates a sandbox instead of 400. That bypasses the new strict python_runtime validation path and turns client parse errors into side effects. Only an actually empty body should be ignored; parse failures should return INVALID_INPUT.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/routes/sandboxes.ts` around lines 46 - 58, The request handler
currently swallows JSON parse errors in the try/catch around c.req.json() (used
with createBodySchema), allowing malformed JSON like "{" to be treated as an
empty create and create a sandbox; change the flow so you first read the raw
request body as text, if raw.trim() === "" treat it as an empty body (keep
existing defaults), otherwise attempt JSON.parse or c.req.json() and if parsing
fails return c.json({ error: "validation_error", code: "INVALID_INPUT", details:
[...] }, 400) instead of proceeding; update the block around c.req.json(),
createBodySchema.safeParse, and the catch to differentiate empty body vs parse
failure and ensure parse failures always return INVALID_INPUT.

187-195: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize legacy metadata on the cold-replica GET path.

This fallback now serializes meta.python_runtime, meta.javascript, and meta.network as-is. If getSandboxMeta() returns a pre-migration/legacy entry, those values are undefined, and the JSON response drops the fields entirely. That breaks the new API contract exactly on the rolling-deploy path this PR is trying to keep compatible. Default the booleans to false and derive python_runtime from legacy metadata when present.

Based on the rolling-deploy compatibility objective for python_runtime, this fallback still needs to handle pre-migration metadata.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/routes/sandboxes.ts` around lines 187 - 195, The cold-replica GET
response currently returns meta.python_runtime, meta.javascript, and
meta.network as-is, which causes those fields to be omitted when
getSandboxMeta() returns legacy metadata; update the fallback serialization in
the endpoint that constructs the JSON response (the block returning c.json with
id/name/owner/createdAt/lastUsedAt) to default javascript and network to false
when undefined and to derive python_runtime from legacy metadata (e.g., coerce
legacy values into the new python_runtime boolean) so that the response always
includes python_runtime, javascript, and network (booleans) even for
pre-migration entries.
src/api/tests/unit/sandboxes.test.ts (1)

261-293: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add direct route coverage for the new python_runtime contract.

This file only updates a fixture to the renamed metadata shape. It still does not assert that POST /v1/sandboxes accepts "pyodide"/null, rejects legacy python, and that GET /v1/sandboxes/:id returns python_runtime on both warm and cold paths. Those are the load-bearing API changes in this PR.

As per coding guidelines, unit tests should cover both success and error cases with exact assertions; the PR objective here is the python_runtime API migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/tests/unit/sandboxes.test.ts` around lines 261 - 293, Add unit tests
that directly assert the new python_runtime contract: in
src/api/tests/unit/sandboxes.test.ts add cases using makeTestApp,
SessionManager, makeToken and InMemoryFs to (1) POST /v1/sandboxes succeeds when
body.python_runtime is "pyodide" and when it is null, (2) POST /v1/sandboxes
returns a 4xx when body.python_runtime is the legacy "python" value, and (3) GET
/v1/sandboxes/:id returns a python_runtime field on both warm paths (sandbox
present in the SessionManager pool) and cold paths (only getSandboxMetaFn
provides metadata) asserting the exact returned value matches the stored
metadata; reuse the existing helpers (makeTestApp, SessionManager
constructor/getSandboxMetaFn, makeToken) and mirror the style of the existing
test that checks createdAt for setup and assertions.

Source: Coding guidelines

♻️ Duplicate comments (5)
tsconfig.json (1)

22-22: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Exclusion of runner.ts still lacks documented CI type-checking gate.

The exclusion of src/pyodide-runner/runner.ts from TypeScript compilation remains without a documented alternative validation path. While the exclusion may be justified (Deno vs Node module systems), the past review's requirement for "a dedicated CI gate that fails on runner type errors" appears unmet based on the provided context.

Either:

  1. Remove the exclusion and add a Deno-compatible tsconfig, or
  2. Document the CI gate that validates runner.ts types (e.g., deno check in the build pipeline)

The runner is security-critical (IPC integrity, realm lockdown) and should not bypass static validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tsconfig.json` at line 22, The tsconfig.json currently excludes
"src/pyodide-runner/runner.ts" via the "exclude" entry which bypasses TypeScript
checks for the security-critical runner; either remove that exclusion and add a
Deno-compatible tsconfig (or adjust existing tsconfig settings and module
resolution) so runner.ts is compiled by your CI, or keep the exclusion but add
and document a CI gate that type-checks runner.ts (e.g., run `deno check` or an
equivalent Deno type-check step in the pipeline) and link that CI job in the
repo docs; update tsconfig.json and repository CI/docs accordingly and reference
the exact excluded path "src/pyodide-runner/runner.ts" when making the change.
clients/typescript/CHANGELOG.md (1)

5-18: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

CHANGELOG should be managed via Changesets, not manually edited.

Per coding guidelines and learnings, CHANGELOG.md is generated through the Changesets workflow. In a feature PR, only the .changeset/*.md file should be committed. The CHANGELOG.md update should happen in a separate release PR via pnpm changeset:version. Manual edits here bypass the project's release automation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/typescript/CHANGELOG.md` around lines 5 - 18, Revert the manual edits
to CHANGELOG.md and instead add a Changeset file under .changeset describing the
0.4.0 changes (mentioning the python -> python_runtime migration,
SandboxRecord.python_runtime, SandboxRecord.network, and exporting
PythonRuntime) with the appropriate release type; commit only the
.changeset/*.md in this feature PR and remove CHANGELOG.md from the commit, then
for the release flow run the project's release step (pnpm changeset:version) in
the release PR to regenerate CHANGELOG.md via the Changesets workflow.

Source: Learnings

clients/typescript/src/models.ts (1)

91-91: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate API response before type assertion.

Line 91 casts payload.python_runtime to PythonRuntime without validation. If the API returns an unexpected value (e.g., "invalid", 123), the type system accepts it but runtime behavior may be incorrect.

🛡️ Add validation guard
 export function sandboxRecordFromApi(payload: ApiObject): SandboxRecord {
+	const rawRuntime = payload.python_runtime ?? null;
+	const validRuntimes = new Set<PythonRuntime>(["stdlib", "pyodide", null]);
+	if (!validRuntimes.has(rawRuntime as PythonRuntime)) {
+		throw new Error(`Invalid python_runtime from API: ${String(rawRuntime)}`);
+	}
 	return {
 		id: String(payload.id),
 		name: payload.name == null ? null : String(payload.name),
 		owner: String(payload.owner),
 		createdAt: String(payload.createdAt),
-		python_runtime: (payload.python_runtime ?? null) as PythonRuntime,
+		python_runtime: rawRuntime as PythonRuntime,
 		javascript: Boolean(payload.javascript),
 		network: Boolean(payload.network),
 	};
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@clients/typescript/src/models.ts` at line 91, The assignment that casts
payload.python_runtime to PythonRuntime must validate the value first: add a
guard (e.g., isValidPythonRuntime or use an enum validator in the same module)
and check payload.python_runtime is a string and one of the allowed
PythonRuntime values before casting; if validation fails, set python_runtime to
null or a safe default (or throw) instead of unconditionally casting. Update the
code paths that reference python_runtime (the payload variable and the
PythonRuntime type usage in the same model factory/constructor) to use this
validated value.
src/api/pyodide/ipc.ts (1)

136-148: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing base64 validation in assertFsEntry allows forged file payloads.

The past review concern is valid: assertFsEntry only checks typeof entry.data === "string" but does not validate that entry.data is valid base64 when entry.kind === "file". A forged result frame with non-base64 data would pass integrity validation and reach the drain layer, where decoding garbage could corrupt persisted files.

Add base64 validation for file entries to reject malformed payloads before they reach the drain.

🛡️ Defensive validation
 function assertFsEntry(entry: unknown, label: string): void {
 	if (!isObject(entry)) throw new IpcIntegrityError(`${label}: entry is not an object`);
 	if (typeof entry.path !== "string" || entry.path.length === 0) {
 		throw new IpcIntegrityError(`${label}: missing/invalid path`);
 	}
 	if (entry.kind !== "file" && entry.kind !== "dir") throw new IpcIntegrityError(`${label}: invalid kind`);
 	if (typeof entry.mode !== "number" || !Number.isInteger(entry.mode) || entry.mode < 0) {
 		throw new IpcIntegrityError(`${label}: missing/invalid mode`);
 	}
 	if (typeof entry.data !== "string") throw new IpcIntegrityError(`${label}: missing/invalid data`);
+	if (entry.kind === "file" && entry.data.length > 0) {
+		// Validate base64: should only contain [A-Za-z0-9+/] and optional trailing "=" padding
+		if (!/^[A-Za-z0-9+/]*={0,2}$/.test(entry.data)) {
+			throw new IpcIntegrityError(`${label}: file data is not valid base64`);
+		}
+	}
 	if (entry.kind === "dir" && entry.data !== "")
 		throw new IpcIntegrityError(`${label}: dir entry must have empty data`);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/pyodide/ipc.ts` around lines 136 - 148, The assertFsEntry function
currently only checks that entry.data is a string; update assertFsEntry to also
validate that when entry.kind === "file" the entry.data is valid base64 and
reject otherwise by throwing IpcIntegrityError. Locate assertFsEntry and add a
check for file entries that attempts a safe base64 validation (e.g. test against
a base64 regex or attempt a decode with Buffer.from(..., "base64")/atob and
verify the round-trip or that decoding doesn't throw) and throw new
IpcIntegrityError(`${label}: invalid base64 data`) on failure so
malformed/non-base64 payloads are rejected before they reach the drain.
src/api/pyodide/manager.ts (1)

367-396: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add async error handlers for child.stdin to prevent unhandled stream failures.

The past review concern remains valid: child.stdin.write(encodeFrame(frame)) at line 392 is wrapped in synchronous try/catch, but there are no child.stdin 'error' or 'close' event handlers. If the child process terminates between the null-check at line 387 and the write at line 392, the writable stream may emit async errors (e.g., EPIPE, ERR_STREAM_DESTROYED) that bypass the try/catch and are not routed through #failOwned().

Attach temporary error/close handlers to child.stdin before writing, and remove them after the write completes to ensure broken-pipe scenarios are properly handled.

🔒 Proposed fix for stdin error handling
 	`#sendRunFrame`(op: OwnedOp): void {
 		if (op.done) return;
 		const frame: RunRequest = {
 			type: "run",
 			requestId: this.#randomRequestId(),
 			seq: ++this.#seqCounter,
 			generation: this.#generation,
 			code: op.input.code,
 			argv: op.input.argv,
 			stdin: op.input.stdin,
 			files: op.input.files,
 			cwd: op.input.cwd,
 			env: op.input.env,
 		};
 		op.requestId = frame.requestId;
 		op.seq = frame.seq;
 		op.stage = "response";
 		this.#pending = { requestId: frame.requestId, seq: frame.seq };
 		this.#state = "busy";
 		const child = this.#child;
 		if (child?.stdin === null || child?.stdin === undefined) {
 			this.#failOwned(new PyodideChildExitError(null, null), true);
 			return;
 		}
+		const onStdinError = (err: Error): void => {
+			this.#failOwned(err, true);
+		};
+		const onStdinClose = (): void => {
+			this.#failOwned(new PyodideChildExitError(null, null), true);
+		};
+		child.stdin.once("error", onStdinError);
+		child.stdin.once("close", onStdinClose);
 		try {
 			child.stdin.write(encodeFrame(frame));
+			child.stdin.removeListener("error", onStdinError);
+			child.stdin.removeListener("close", onStdinClose);
 		} catch (err) {
+			child.stdin.removeListener("error", onStdinError);
+			child.stdin.removeListener("close", onStdinClose);
 			this.#failOwned(err instanceof Error ? err : new Error(String(err)), true);
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/pyodide/manager.ts` around lines 367 - 396, In sendRunFrame, add
temporary async handlers on child.stdin (use child.stdin.once('error', ...) and
once('close', ...)) before calling child.stdin.write(encodeFrame(frame)) so
async stream errors (EPIPE / ERR_STREAM_DESTROYED) get routed to
this.#failOwned(err, true); ensure these handlers are removed after the write
completes (use the write callback or drain/finish event) to avoid leaking
listeners; keep the existing null-check for child.stdin and still wrap the
synchronous write in try/catch, but rely on the temporary listeners to catch
asynchronous failures and forward them to `#failOwned`; reference sendRunFrame,
child.stdin, encodeFrame, and `#failOwned` when applying the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Dockerfile`:
- Around line 9-10: The RUN apt-get install invocation installs curl, unzip, and
bzip2 without version pins; change the RUN line that installs these build-time
packages (the RUN ... apt-get install -y --no-install-recommends curl unzip
bzip2 ...) to pin explicit Debian package versions (e.g. curl=<version>
unzip=<version> bzip2=<version>) so builds are reproducible — determine the
exact versions from the base image (using apt-cache policy in node:22-bookworm
or equivalent) and update the package list invocation to install those pinned
versions and still clean /var/lib/apt/lists/* afterward.

In `@plugins/sql-fs/skills/py-sdk/ref/client.md`:
- Line 57: Remove the empty blank line separating the two consecutive blockquote
blocks in plugins/sql-fs/skills/py-sdk/ref/client.md so the blockquotes are
directly adjacent (this fixes MD028); locate the two successive blockquote
sections in that file and delete the intervening blank line between them.

In `@scripts/fetch-pyodide-assets.mjs`:
- Around line 98-120: In fetchDeno(), add integrity verification for the
downloaded archive by computing a SHA-256 checksum of the downloaded zip (or the
extracted DENO_BIN) and comparing it to a trusted expected value before
unpacking or using the binary; introduce or use a checksum mapping keyed by
PINS.deno/denoTarget() (e.g., DENO_SHA_MAP or a manifest fetched alongside the
release) and if the computed checksum does not match the expected value throw an
error and abort; ensure you compute the hash from the same file referenced by
zip (or DENO_BIN after extraction) using a stable Node crypto hash, compare
constant-time, and only call execFileSync("unzip", ...) / chmodSync(DENO_BIN,
...) / log success after the verification passes.

In `@src/api/commands/pyodide-command.ts`:
- Around line 474-489: The guard currently only lstats the final resolved path;
change Pyodide drain logic to walk each ancestor segment of resolved (up to but
not including root) and lstat each parent to detect any symbolic links before
performing mkdir/writeFile/chmod; if any parent lstat shows a symlink, throw the
same PyodideDrainError (use the same message pattern referencing entry.path).
Ensure you perform this ancestor-symlink check prior to the existing entry.kind
"dir" branch and before writing/chmod so no symlink ancestors are followed.
- Around line 363-369: The walk function currently swallows errors from
fs.readdir by returning in the catch, causing incomplete staged snapshots;
change the catch in walk (the async function walk(dir: string)) to surface
filesystem errors instead of returning silently — either rethrow the caught
error or throw a new descriptive Error including the original error and the dir
(do not swallow), so callers (Pyodide exec / staged snapshot logic) will fail
fast on unreadable directories rather than proceed with a partial tree.

In `@src/api/pyodide/residency.ts`:
- Around line 173-180: The idle sweeper method `#sweep`() currently
fire-and-forgets worker.dispose(), which can produce unhandled promise
rejections; update the disposal call inside the loop that removes from
this.#residents to handle rejections (e.g., append a .catch(...) to the promise
or await in a safe async helper) and log the error (or surface it via your
existing logger) so disposal failures are captured; reference the `#sweep` method,
this.#residents iteration, the `#evictable` check, this.#idleMs timeout logic, and
the worker.dispose() call when making this change.

In `@src/api/pyodide/tests/integration/escape.integration.test.ts`:
- Around line 45-61: The test suite sets process.env.PYODIDE_ASSET_DIR and
process.env.DENO_BIN_PATH in beforeAll but only restores AUTH_SECRET in
afterAll; capture the original values (e.g., savedPyodideAssetDir and
savedDenoBin) alongside savedSecret in the beforeAll setup, and in afterAll
restore them (or delete the env prop if undefined) just like AUTH_SECRET is
handled so PYODIDE_ASSET_DIR and DENO_BIN_PATH do not leak to other tests;
update the beforeAll/afterAll blocks surrounding SessionManager/session setup
and shutdown to use these saved variables.

In `@src/api/pyodide/tests/integration/frame-forgery.integration.test.ts`:
- Around line 81-89: Save the original environment values for PYODIDE_ASSET_DIR
and DENO_BIN_PATH at the start of the suite (in the same scope as beforeAll),
then restore them in afterAll; specifically, in beforeAll capture previous
values (e.g. prevPyodideAssetDir, prevDenoBinPath) before mutating process.env,
and in afterAll restore process.env.PYODIDE_ASSET_DIR and
process.env.DENO_BIN_PATH back to those captured values (setting to
undefined/null if they were unset) and do the restoration in a finally block
around sm.shutdown() so restoration always runs even if shutdown fails.

In `@src/api/tests/integration/pyodide.integration.test.ts`:
- Around line 33-52: The test suite currently only cleans sandboxes in afterAll
which can leak state; add an afterEach that iterates the cleanup array and
destroys each sandbox (using destroySandbox("postgres", id)) and then clears the
cleanup array, and update individual tests that create sandboxes to use
try/finally so they push the created id onto the shared cleanup array and ensure
removal in the finally block; reference the existing cleanup array,
destroySandbox, and SessionManager/describe.skipIf block when making these
changes.

In `@src/api/tests/unit/mcp-tools.test.ts`:
- Around line 120-128: The test "sandbox_create echoes the python_runtime it was
given" only asserts python_runtime and can miss regressions in the full tool
payload; update the test that calls captureToolHandler("sandbox_create") /
getHandler() so it parses result.content[0].text and uses
expect(parsedBody).toEqual(expectedPayload) with a complete expected object
(include id, name, javascript, python_runtime and any wrapper shape returned)
instead of just toBe on python_runtime; apply the same change to the related
test block mentioned (lines ~130-151) to assert the full JSON response payload
for exact-match assertions.

---

Outside diff comments:
In `@plugins/sql-fs/skills/api/ref/bash.md`:
- Around line 60-73: Update the performance/concurrency guidance so it
distinguishes runtime modes: change the claim "Every call pays the WASM
cold-boot cost (~1.4 s)" and the RAM/concurrency figures into explicit, per-mode
statements (e.g., for python_runtime == "stdlib": cold start ~1.4s, ~80 MB per
python3 process, server-wide concurrency cap 5; for pyodide subprocess mode:
cold start ~5–8s, ~350–400 MB per execution, and respect MAX_CONCURRENT_PYODIDE
default 2). Edit the paragraph(s) in plugins/sql-fs/skills/api/ref/bash.md that
reference the cold-boot, RAM (~80 MB) and concurrency (5) so they either become
two short mode-specific bullet points or include inline qualifiers referencing
python_runtime and MAX_CONCURRENT_PYODIDE, and ensure the guidance about
persisting state and avoiding pip/os.system/subprocess remains unchanged.

In `@src/api/mcp/tools.ts`:
- Around line 250-263: The catch block inside bash_exec should treat internal
Pyodide timeouts the same as the local timer timeout: after clearTimeout(timer)
and extra.signal.removeEventListener("abort", onDisconnect), detect if the
caught error has e?.code === "EPYODIDE_TIMEOUT" (or equivalent) and return the
same timeout result object (stdout:"", stderr:"timeout", exitCode:-1,
exitSignal:null, timedOut:true, durationMs: Date.now() - startMs) instead of
throwing; only rethrow for other errors. Ensure you reference the existing
symbols timer, extra.signal, onDisconnect, startMs, and the function
execWithRuntimeThrottle in your change.

In `@src/api/openapi-spec.ts`:
- Around line 745-756: The OpenAPI schema for the batch exec request is missing
the perScriptTimeoutMs property even though routes/exec.ts accepts it; update
the schema in src/api/openapi-spec.ts (the same object containing timeoutMs,
readOnly, retryOn5xx) to add a perScriptTimeoutMs field (type: integer, provide
example and sensible minimum/maximum and a short description explaining its
per-script timeout semantics) so generated clients and docs include the new
timeout mode; ensure the new property name matches the parameter used in
routes/exec.ts exactly and is documented alongside timeoutMs.

In `@src/api/routes/exec.ts`:
- Around line 291-299: The code currently may set timedOut in the timer callback
after execWithRuntimeThrottle() already completed and sent an exit event; add a
separate "completed" (or "finished") boolean flag that you set to true
immediately before sending any exit SSE in the successful exec path (where
stream.writeSSE is called after execWithRuntimeThrottle()), and update the timer
callback to check completed before treating the run as a timeout (i.e., only set
timedOut/send timeout SSE if completed is false). Ensure all code paths that
send an exit SSE (the post-exec success path and any error/early exit paths) set
completed = true before returning so the timer cannot overwrite a resolved run.

In `@src/api/routes/sandboxes.ts`:
- Around line 46-58: The request handler currently swallows JSON parse errors in
the try/catch around c.req.json() (used with createBodySchema), allowing
malformed JSON like "{" to be treated as an empty create and create a sandbox;
change the flow so you first read the raw request body as text, if raw.trim()
=== "" treat it as an empty body (keep existing defaults), otherwise attempt
JSON.parse or c.req.json() and if parsing fails return c.json({ error:
"validation_error", code: "INVALID_INPUT", details: [...] }, 400) instead of
proceeding; update the block around c.req.json(), createBodySchema.safeParse,
and the catch to differentiate empty body vs parse failure and ensure parse
failures always return INVALID_INPUT.
- Around line 187-195: The cold-replica GET response currently returns
meta.python_runtime, meta.javascript, and meta.network as-is, which causes those
fields to be omitted when getSandboxMeta() returns legacy metadata; update the
fallback serialization in the endpoint that constructs the JSON response (the
block returning c.json with id/name/owner/createdAt/lastUsedAt) to default
javascript and network to false when undefined and to derive python_runtime from
legacy metadata (e.g., coerce legacy values into the new python_runtime boolean)
so that the response always includes python_runtime, javascript, and network
(booleans) even for pre-migration entries.

In `@src/api/tests/unit/sandboxes.test.ts`:
- Around line 261-293: Add unit tests that directly assert the new
python_runtime contract: in src/api/tests/unit/sandboxes.test.ts add cases using
makeTestApp, SessionManager, makeToken and InMemoryFs to (1) POST /v1/sandboxes
succeeds when body.python_runtime is "pyodide" and when it is null, (2) POST
/v1/sandboxes returns a 4xx when body.python_runtime is the legacy "python"
value, and (3) GET /v1/sandboxes/:id returns a python_runtime field on both warm
paths (sandbox present in the SessionManager pool) and cold paths (only
getSandboxMetaFn provides metadata) asserting the exact returned value matches
the stored metadata; reuse the existing helpers (makeTestApp, SessionManager
constructor/getSandboxMetaFn, makeToken) and mirror the style of the existing
test that checks createdAt for setup and assertions.

---

Duplicate comments:
In `@clients/typescript/CHANGELOG.md`:
- Around line 5-18: Revert the manual edits to CHANGELOG.md and instead add a
Changeset file under .changeset describing the 0.4.0 changes (mentioning the
python -> python_runtime migration, SandboxRecord.python_runtime,
SandboxRecord.network, and exporting PythonRuntime) with the appropriate release
type; commit only the .changeset/*.md in this feature PR and remove CHANGELOG.md
from the commit, then for the release flow run the project's release step (pnpm
changeset:version) in the release PR to regenerate CHANGELOG.md via the
Changesets workflow.

In `@clients/typescript/src/models.ts`:
- Line 91: The assignment that casts payload.python_runtime to PythonRuntime
must validate the value first: add a guard (e.g., isValidPythonRuntime or use an
enum validator in the same module) and check payload.python_runtime is a string
and one of the allowed PythonRuntime values before casting; if validation fails,
set python_runtime to null or a safe default (or throw) instead of
unconditionally casting. Update the code paths that reference python_runtime
(the payload variable and the PythonRuntime type usage in the same model
factory/constructor) to use this validated value.

In `@src/api/pyodide/ipc.ts`:
- Around line 136-148: The assertFsEntry function currently only checks that
entry.data is a string; update assertFsEntry to also validate that when
entry.kind === "file" the entry.data is valid base64 and reject otherwise by
throwing IpcIntegrityError. Locate assertFsEntry and add a check for file
entries that attempts a safe base64 validation (e.g. test against a base64 regex
or attempt a decode with Buffer.from(..., "base64")/atob and verify the
round-trip or that decoding doesn't throw) and throw new
IpcIntegrityError(`${label}: invalid base64 data`) on failure so
malformed/non-base64 payloads are rejected before they reach the drain.

In `@src/api/pyodide/manager.ts`:
- Around line 367-396: In sendRunFrame, add temporary async handlers on
child.stdin (use child.stdin.once('error', ...) and once('close', ...)) before
calling child.stdin.write(encodeFrame(frame)) so async stream errors (EPIPE /
ERR_STREAM_DESTROYED) get routed to this.#failOwned(err, true); ensure these
handlers are removed after the write completes (use the write callback or
drain/finish event) to avoid leaking listeners; keep the existing null-check for
child.stdin and still wrap the synchronous write in try/catch, but rely on the
temporary listeners to catch asynchronous failures and forward them to
`#failOwned`; reference sendRunFrame, child.stdin, encodeFrame, and `#failOwned`
when applying the change.

In `@tsconfig.json`:
- Line 22: The tsconfig.json currently excludes "src/pyodide-runner/runner.ts"
via the "exclude" entry which bypasses TypeScript checks for the
security-critical runner; either remove that exclusion and add a Deno-compatible
tsconfig (or adjust existing tsconfig settings and module resolution) so
runner.ts is compiled by your CI, or keep the exclusion but add and document a
CI gate that type-checks runner.ts (e.g., run `deno check` or an equivalent Deno
type-check step in the pipeline) and link that CI job in the repo docs; update
tsconfig.json and repository CI/docs accordingly and reference the exact
excluded path "src/pyodide-runner/runner.ts" when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 50b88281-c087-498b-88ac-6dc9f7a75ef2

📥 Commits

Reviewing files that changed from the base of the PR and between 22cf3fd and 698aa12.

⛔ Files ignored due to path filters (1)
  • clients/python/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (86)
  • .changeset/python-runtime-enum.md
  • .dockerignore
  • .gitignore
  • CLAUDE.md
  • Dockerfile
  • biome.json
  • clients/python/CHANGELOG.md
  • clients/python/README.md
  • clients/python/examples/quickstart.py
  • clients/python/pyproject.toml
  • clients/python/src/sqlfs/__init__.py
  • clients/python/src/sqlfs/_version.py
  • clients/python/src/sqlfs/client.py
  • clients/python/src/sqlfs/models.py
  • clients/python/tests/test_client.py
  • clients/typescript/CHANGELOG.md
  • clients/typescript/README.md
  • clients/typescript/examples/e2e-local.ts
  • clients/typescript/examples/quickstart.ts
  • clients/typescript/package.json
  • clients/typescript/src/client.ts
  • clients/typescript/src/index.ts
  • clients/typescript/src/models.ts
  • clients/typescript/src/version.ts
  • clients/typescript/tests/sandboxes.test.ts
  • package.json
  • plugins/sql-fs/skills/api/SETUP.md
  • plugins/sql-fs/skills/api/ref/bash.md
  • plugins/sql-fs/skills/api/ref/endpoints.md
  • plugins/sql-fs/skills/py-sdk/SKILL.md
  • plugins/sql-fs/skills/py-sdk/ref/client.md
  • plugins/sql-fs/skills/py-sdk/ref/models.md
  • scripts/build-pyodide-lock.mjs
  • scripts/copy-pyodide-runner.mjs
  • scripts/fetch-pyodide-assets.mjs
  • src/api/commands/pyodide-command.ts
  • src/api/lib/batch-exec.ts
  • src/api/mcp/tools.ts
  • src/api/openapi-spec.ts
  • src/api/pyodide-runtime-context.ts
  • src/api/pyodide/ipc.ts
  • src/api/pyodide/manager.ts
  • src/api/pyodide/residency.ts
  • src/api/pyodide/tests/integration/escape.integration.test.ts
  • src/api/pyodide/tests/integration/frame-forgery.integration.test.ts
  • src/api/pyodide/tests/unit/fake-child.ts
  • src/api/pyodide/tests/unit/ipc.test.ts
  • src/api/pyodide/tests/unit/manager.test.ts
  • src/api/pyodide/tests/unit/pyodide-command.test.ts
  • src/api/pyodide/tests/unit/residency.test.ts
  • src/api/routes/exec.ts
  • src/api/routes/sandboxes.ts
  • src/api/session-manager.ts
  • src/api/tests/integration/migrations.integration.test.ts
  • src/api/tests/integration/pyodide.integration.test.ts
  • src/api/tests/session-manager.script-tx.test.ts
  • src/api/tests/unit/exec-batch.test.ts
  • src/api/tests/unit/exec.test.ts
  • src/api/tests/unit/files.test.ts
  • src/api/tests/unit/ingest.test.ts
  • src/api/tests/unit/mcp-tools.test.ts
  • src/api/tests/unit/sandboxes.test.ts
  • src/api/tests/unit/session-manager.pyodide.test.ts
  • src/api/tests/unit/session-manager.rehydrate.test.ts
  • src/api/tests/unit/session-manager.test.ts
  • src/pyodide-runner/protocol.ts
  • src/pyodide-runner/runner.ts
  • src/sql-fs/dialects/postgres.ts
  • src/sql-fs/dialects/tests/unit/postgres.advisory-lock.test.ts
  • src/sql-fs/migrations/postgres/0006_python_runtime.sql
  • src/sql-fs/types.ts
  • thoughts/issue-118-pyodide-runtime/design.md
  • thoughts/issue-118-pyodide-runtime/plan.md
  • thoughts/issue-118-pyodide-runtime/questions.md
  • thoughts/issue-118-pyodide-runtime/research.md
  • thoughts/issue-118-pyodide-runtime/spikes/.gitignore
  • thoughts/issue-118-pyodide-runtime/spikes/S1-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/S2-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/S3-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/s1-pyodide-deno.sh
  • thoughts/issue-118-pyodide-runtime/spikes/s1_runner.ts
  • thoughts/issue-118-pyodide-runtime/spikes/s2-ipc.ts
  • thoughts/issue-118-pyodide-runtime/spikes/s3-memory.sh
  • thoughts/issue-118-pyodide-runtime/structure.md
  • thoughts/issue-118-pyodide-runtime/task.md
  • tsconfig.json
🛑 Comments failed to post (10)
Dockerfile (1)

9-10: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider pinning versions for build-time dependencies.

Hadolint flags apt-get install without version pins. While curl, unzip, and bzip2 are stable POSIX utilities used only in the discarded builder stage, pinning versions ensures fully reproducible builds and guards against supply-chain concerns.

🔒 Example with pinned versions
-RUN apt-get update && apt-get install -y --no-install-recommends curl unzip bzip2 \
+RUN apt-get update && apt-get install -y --no-install-recommends \
+  curl=7.88.1-10+deb12u8 \
+  unzip=6.0-28 \
+  bzip2=1.0.8-5+b1 \
   && rm -rf /var/lib/apt/lists/*

Note: Pin to versions available in node:22-bookworm (Debian 12). Check with docker run --rm node:22-bookworm apt-cache policy curl unzip bzip2.

🧰 Tools
🪛 Hadolint (2.14.0)

[warning] 9-9: Pin versions in apt get install. Instead of apt-get install <package> use apt-get install <package>=<version>

(DL3008)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 9 - 10, The RUN apt-get install invocation installs
curl, unzip, and bzip2 without version pins; change the RUN line that installs
these build-time packages (the RUN ... apt-get install -y
--no-install-recommends curl unzip bzip2 ...) to pin explicit Debian package
versions (e.g. curl=<version> unzip=<version> bzip2=<version>) so builds are
reproducible — determine the exact versions from the base image (using apt-cache
policy in node:22-bookworm or equivalent) and update the package list invocation
to install those pinned versions and still clean /var/lib/apt/lists/* afterward.

Source: Linters/SAST tools

plugins/sql-fs/skills/py-sdk/ref/client.md (1)

57-57: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Remove blank line between blockquotes per MD028.

Markdownlint flags a blank line between consecutive blockquotes. Remove the empty line between the two blockquote blocks (lines 53–56 and 58–61).

🧹 Proposed fix
 > you pass `allow_oversized=True`.
-
 > Sizing note: the server caps the whole HTTP request body (default 256 MB) and
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

 > you pass `allow_oversized=True`.
 > Sizing note: the server caps the whole HTTP request body (default 256 MB) and
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 57-57: Blank line inside blockquote

(MD028, no-blanks-blockquote)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/sql-fs/skills/py-sdk/ref/client.md` at line 57, Remove the empty
blank line separating the two consecutive blockquote blocks in
plugins/sql-fs/skills/py-sdk/ref/client.md so the blockquotes are directly
adjacent (this fixes MD028); locate the two successive blockquote sections in
that file and delete the intervening blank line between them.

Source: Linters/SAST tools

scripts/fetch-pyodide-assets.mjs (1)

98-120: ⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant region
sed -n '1,200p' scripts/fetch-pyodide-assets.mjs | nl -ba | sed -n '70,160p'

# Search for any SHA/checksum verification related to Deno
rg -n --hidden --no-ignore-vcs "DENO|deno.*sha|sha.*deno|checksum|checksums|integrity|SRI|sha256" scripts/fetch-pyodide-assets.mjs

# Find where the downloaded Deno binary is later used/executed
rg -n --hidden --no-ignore-vcs "DENO_BIN|deno\b|\bexec.*deno\b|\bspawn.*deno\b|${?DENO_BIN}|\bDENO_DIR\b" scripts/fetch-pyodide-assets.mjs || true

Repository: Hazzng/sql-fs

Length of output: 100


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant region with line numbers (without nl)
awk '{print NR "\t" $0}' scripts/fetch-pyodide-assets.mjs | sed -n '70,160p'

# Search for any SHA/checksum verification related to Deno
rg -n --hidden --no-ignore-vcs "DENO|deno.*sha|sha.*deno|checksum|checksums|integrity|SRI|sha256" scripts/fetch-pyodide-assets.mjs

# Find where the downloaded Deno binary is later used/executed
rg -n --hidden --no-ignore-vcs "DENO_BIN|deno\b|\bexec.*deno\b|\bspawn.*deno\b|\bdeno run\b|\$\{?DENO_BIN\}?|\bDENO_DIR\b" scripts/fetch-pyodide-assets.mjs || true

Repository: Hazzng/sql-fs

Length of output: 5632


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the file header / constants including PINS and SHA256
awk '{print NR "\t" $0}' scripts/fetch-pyodide-assets.mjs | sed -n '1,90p'

# Find any SHA256 map entries and whether they mention Deno/deno-zip/deno binary
rg -n "const SHA256|SHA256\s*=\s*{|deno.*sha|SHA256\[[^\]]*deno|verify\([^)]*DENO|verify\([^)]*deno" scripts/fetch-pyodide-assets.mjs

# Specifically look for any verify() calls after fetchDeno()
rg -n "fetchDeno\(\)|verify\(" scripts/fetch-pyodide-assets.mjs

# Show remainder to see if Deno is ever executed later
awk '{print NR "\t" $0}' scripts/fetch-pyodide-assets.mjs | sed -n '160,260p'

Repository: Hazzng/sql-fs

Length of output: 4328


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show rest of denoTarget/fetchDeno area
awk '{print NR "\t" $0}' scripts/fetch-pyodide-assets.mjs | sed -n '80,170p'

# Show the end of the file (where deno/pyodide are probably used)
awk '{print NR "\t" $0}' scripts/fetch-pyodide-assets.mjs | sed -n '170,260p'

# Simple searches
rg -n "verify\(" scripts/fetch-pyodide-assets.mjs
rg -n "fetchDeno" scripts/fetch-pyodide-assets.mjs
rg -n "DENO_BIN|DENO_DIR" scripts/fetch-pyodide-assets.mjs
rg -n "SHA256\\[" scripts/fetch-pyodide-assets.mjs

Repository: Hazzng/sql-fs

Length of output: 4334


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for uses of the vendored deno binary across repo
rg -n --hidden --no-ignore-vcs "vendor/deno/deno|vendor/deno|DENO_BIN|deno-(v|)(run|$)|deno run|execFileSync\\(.*deno|spawn\\(.*deno" .

# Also see where this script is invoked
rg -n --hidden --no-ignore-vcs "fetch-pyodide-assets\\.mjs" . || true

Repository: Hazzng/sql-fs

Length of output: 13089


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show deno resolution/spawn logic in manager.ts
sed -n '1,260p' src/api/pyodide/manager.ts | awk '{print NR "\t" $0}' | sed -n '120,260p'

# Show the portion where spawn/exec uses denoBin
sed -n '260,520p' src/api/pyodide/manager.ts | awk '{print NR "\t" $0}' | sed -n '1,260p'

# Inspect runner for how it invokes deno (if at all)
sed -n '1,240p' src/pyodide-runner/runner.ts | awk '{print NR "\t" $0}' | sed -n '1,120p'

# Search for checksum/integrity logic in the pyodide module
rg -n "sha256|checksum|integrity|verify\\(" src/api/pyodide src/pyodide-runner

Repository: Hazzng/sql-fs

Length of output: 23773


Verify Deno archive/binary integrity (add per-target SHA-256 or checksum-manifest verification).

fetchDeno() only checks that the extracted vendor/deno/deno exists (plus chmod and macOS xattr removal); it does not SHA-256 pin or validate the downloaded deno-${target}.zip / binary. That vendor/deno/deno is later spawned by PyodideSandbox.#spawnChild() using the vendored DENO_BIN_PATH, so a tampered Deno binary would compromise the host at runtime.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/fetch-pyodide-assets.mjs` around lines 98 - 120, In fetchDeno(), add
integrity verification for the downloaded archive by computing a SHA-256
checksum of the downloaded zip (or the extracted DENO_BIN) and comparing it to a
trusted expected value before unpacking or using the binary; introduce or use a
checksum mapping keyed by PINS.deno/denoTarget() (e.g., DENO_SHA_MAP or a
manifest fetched alongside the release) and if the computed checksum does not
match the expected value throw an error and abort; ensure you compute the hash
from the same file referenced by zip (or DENO_BIN after extraction) using a
stable Node crypto hash, compare constant-time, and only call
execFileSync("unzip", ...) / chmodSync(DENO_BIN, ...) / log success after the
verification passes.
src/api/commands/pyodide-command.ts (2)

363-369: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't silently drop unreadable directories from the staged snapshot.

Returning from the readdir() catch makes Pyodide run against a partial cwd tree. In the locked session that turns a real filesystem error into false ENOENT/stale-state behavior instead of failing the exec.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/commands/pyodide-command.ts` around lines 363 - 369, The walk
function currently swallows errors from fs.readdir by returning in the catch,
causing incomplete staged snapshots; change the catch in walk (the async
function walk(dir: string)) to surface filesystem errors instead of returning
silently — either rethrow the caught error or throw a new descriptive Error
including the original error and the dir (do not swallow), so callers (Pyodide
exec / staged snapshot logic) will fail fast on unreadable directories rather
than proceed with a partial tree.

474-489: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block ancestor symlinks during drain, not just the leaf path.

The current guard only lstat()s resolved itself. If /cwd/link is a symlink, draining link/out.txt will still follow that ancestor symlink and write through a path this layer is supposed to deny. Walk each parent segment before mkdir/writeFile/chmod.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/commands/pyodide-command.ts` around lines 474 - 489, The guard
currently only lstats the final resolved path; change Pyodide drain logic to
walk each ancestor segment of resolved (up to but not including root) and lstat
each parent to detect any symbolic links before performing
mkdir/writeFile/chmod; if any parent lstat shows a symlink, throw the same
PyodideDrainError (use the same message pattern referencing entry.path). Ensure
you perform this ancestor-symlink check prior to the existing entry.kind "dir"
branch and before writing/chmod so no symlink ancestors are followed.
src/api/pyodide/residency.ts (1)

173-180: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Catch idle-sweep disposal failures.

#sweep() fire-and-forgets worker.dispose(). If that promise rejects, the interval callback produces an unhandled rejection from a background timer, which can destabilize the process under stricter Node settings.

🩹 Suggested fix
 			if (now - touched >= this.#idleMs) {
 				this.#residents.delete(worker);
-				void worker.dispose();
+				void worker.dispose().catch(() => {
+					// best-effort — the worker is already deregistered
+				});
 			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

	`#sweep`(): void {
		const now = Date.now();
		// Snapshot entries — dispose() mutates the map mid-iteration.
		for (const [worker, touched] of [...this.#residents]) {
			if (!this.#evictable(worker)) continue;
			if (now - touched >= this.#idleMs) {
				this.#residents.delete(worker);
				void worker.dispose().catch(() => {
					// best-effort — the worker is already deregistered
				});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/pyodide/residency.ts` around lines 173 - 180, The idle sweeper method
`#sweep`() currently fire-and-forgets worker.dispose(), which can produce
unhandled promise rejections; update the disposal call inside the loop that
removes from this.#residents to handle rejections (e.g., append a .catch(...) to
the promise or await in a safe async helper) and log the error (or surface it
via your existing logger) so disposal failures are captured; reference the
`#sweep` method, this.#residents iteration, the `#evictable` check, this.#idleMs
timeout logic, and the worker.dispose() call when making this change.
src/api/pyodide/tests/integration/escape.integration.test.ts (1)

45-61: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore PYODIDE_ASSET_DIR and DENO_BIN_PATH after the suite.

This suite overwrites both env vars in beforeAll but only restores AUTH_SECRET. Any later test file that runs in the same worker inherits these vendored paths, so test order can change behavior. Snapshot and restore/delete both vars in afterAll.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/pyodide/tests/integration/escape.integration.test.ts` around lines 45
- 61, The test suite sets process.env.PYODIDE_ASSET_DIR and
process.env.DENO_BIN_PATH in beforeAll but only restores AUTH_SECRET in
afterAll; capture the original values (e.g., savedPyodideAssetDir and
savedDenoBin) alongside savedSecret in the beforeAll setup, and in afterAll
restore them (or delete the env prop if undefined) just like AUTH_SECRET is
handled so PYODIDE_ASSET_DIR and DENO_BIN_PATH do not leak to other tests;
update the beforeAll/afterAll blocks surrounding SessionManager/session setup
and shutdown to use these saved variables.
src/api/pyodide/tests/integration/frame-forgery.integration.test.ts (1)

81-89: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore the vendored runtime env after the suite.

beforeAll mutates PYODIDE_ASSET_DIR and DENO_BIN_PATH, but afterAll never restores the previous values. That leaks configuration into later suites in the same worker and makes their behavior depend on execution order.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/pyodide/tests/integration/frame-forgery.integration.test.ts` around
lines 81 - 89, Save the original environment values for PYODIDE_ASSET_DIR and
DENO_BIN_PATH at the start of the suite (in the same scope as beforeAll), then
restore them in afterAll; specifically, in beforeAll capture previous values
(e.g. prevPyodideAssetDir, prevDenoBinPath) before mutating process.env, and in
afterAll restore process.env.PYODIDE_ASSET_DIR and process.env.DENO_BIN_PATH
back to those captured values (setting to undefined/null if they were unset) and
do the restoration in a finally block around sm.shutdown() so restoration always
runs even if shutdown fails.
src/api/tests/integration/pyodide.integration.test.ts (1)

33-52: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move sandbox cleanup to afterEach and use per-test try/finally.

These cases create real sandboxes, but cleanup only happens once in afterAll. A failing test can leak state into later cases, and this breaks the integration-test cleanup contract for the repo.

As per coding guidelines, "Every test that creates a sandbox must delete it in afterEach" and integration tests should "Use try/finally."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/tests/integration/pyodide.integration.test.ts` around lines 33 - 52,
The test suite currently only cleans sandboxes in afterAll which can leak state;
add an afterEach that iterates the cleanup array and destroys each sandbox
(using destroySandbox("postgres", id)) and then clears the cleanup array, and
update individual tests that create sandboxes to use try/finally so they push
the created id onto the shared cleanup array and ensure removal in the finally
block; reference the existing cleanup array, destroySandbox, and
SessionManager/describe.skipIf block when making these changes.

Source: Coding guidelines

src/api/tests/unit/mcp-tools.test.ts (1)

120-128: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Assert the full tool payloads, not just python_runtime.

These tests still pass if id, name, javascript, or the response wrapping regresses, so they don't really pin the echoed contract. Prefer toEqual(...) on the full parsed JSON body. As per coding guidelines, "Assert full output: Prefer exact match (toBe, toEqual) over partial (toContain)."

Also applies to: 130-151

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/tests/unit/mcp-tools.test.ts` around lines 120 - 128, The test
"sandbox_create echoes the python_runtime it was given" only asserts
python_runtime and can miss regressions in the full tool payload; update the
test that calls captureToolHandler("sandbox_create") / getHandler() so it parses
result.content[0].text and uses expect(parsedBody).toEqual(expectedPayload) with
a complete expected object (include id, name, javascript, python_runtime and any
wrapper shape returned) instead of just toBe on python_runtime; apply the same
change to the related test block mentioned (lines ~130-151) to assert the full
JSON response payload for exact-match assertions.

Source: Coding guidelines

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@clients/typescript/src/models.ts`:
- Around line 85-90: The thrown Error in function toPythonRuntime currently
lacks a `code` property; update the error creation in toPythonRuntime(value:
unknown) so it constructs an Error object with the message `unexpected
python_runtime from server: ...` and assign a standard error code (e.g.,
`EINVALID` or another appropriate code) to its `code` property before throwing;
ensure the thrown value remains an Error instance (not a raw string) and keep
the existing JSON.stringify(value) message content.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 71b5347e-2491-43b7-9286-71d8f7d09c29

📥 Commits

Reviewing files that changed from the base of the PR and between 698aa12 and 8a222ae.

📒 Files selected for processing (19)
  • clients/typescript/src/models.ts
  • package.json
  • scripts/build-pyodide-lock.mjs
  • scripts/fetch-pyodide-assets.mjs
  • src/api/commands/pyodide-command.ts
  • src/api/pyodide/ipc.ts
  • src/api/pyodide/manager.ts
  • src/api/pyodide/residency.ts
  • src/api/pyodide/tests/integration/escape.integration.test.ts
  • src/api/pyodide/tests/integration/frame-forgery.integration.test.ts
  • src/api/pyodide/tests/unit/ipc.test.ts
  • src/api/pyodide/tests/unit/pyodide-command.test.ts
  • src/api/pyodide/tests/unit/residency.test.ts
  • src/api/session-manager.ts
  • src/api/tests/integration/pyodide.integration.test.ts
  • src/api/tests/unit/mcp-tools.test.ts
  • src/pyodide-runner/runner.ts
  • thoughts/issue-118-pyodide-runtime/spikes/S3-findings.md
  • thoughts/issue-118-pyodide-runtime/spikes/s1-pyodide-deno.sh

Comment thread clients/typescript/src/models.ts
Adds the full question/research/design/structure/plan artifact set for the
Pyodide Python runtime work: python_runtime enum migration, OS-isolated Deno
subprocess execution, committed IPC with frame-integrity invariants, file
diff-and-drain, concurrency/residency caps, and the adversarial escape suite.
…hild memory

Validate the three architecture gates for the Pyodide runtime (issue Hazzng#118)
before building product phases. All three spike scripts pass (exit 0):

- S1: Pyodide 0.29.4 loads fully offline on Deno v2.8.2 under the committed
  deny-belt and runs a pandas->openpyxl xlsx round-trip with zero network.
- S2: realm lockdown + frame-integrity validation; surfaced that lockdown is
  not stdout containment (import(node:fs).writeSync reaches stdout), so
  Node-side validation with secret requestId/seq/generation is load-bearing.
- S3: non-root cannot set cgroup memory.max and prlimit --as is unusable for
  RSS (V8/WASM vaddr >> RSS) -> container memory limit is the guard.

Records findings per spike and propagates the S2 finding into the Phase 3/4/7
plan and design wording (no false stdout-containment claims). Assets are
git-ignored and reproduced by the spike scripts.
…ory posture

Bound in-flight pyodide execs (pyodideSem, MAX_CONCURRENT_PYODIDE) and resident
subprocesses (PyodideResidency LRU, MAX_RESIDENT_PYODIDE) independently. Admission
is lazy and post-semaphore so it always holds a slot, guaranteeing resident
subprocesses never exceed MAX_RESIDENT; eviction/idle-kill disposes idle workers
and the owning session re-admits a fresh manager on its next exec. Enforce the
MAX_RESIDENT_PYODIDE >= MAX_CONCURRENT_PYODIDE startup invariant. Document the
accepted memory posture (container limit is the guard; per-child OOM isolation is
not provided) and all new env vars.
…e gate)

Add the security sign-off suites that run untrusted Python in the real
OS-isolated Deno child. escape.integration proves each escape fails closed
(no secret read, no network, no host-FS, no subprocess, no FFI; fresh-globals
isolation). frame-forgery.integration proves the load-bearing IPC integrity
control: a forged frame injected via the real node:fs.writeSync(1,…) escape
hatch is rejected on its unguessable integrity fields, killing the child and
draining nothing, then the session cold-starts a fresh generation. Extend the
test:integration script to include the new pyodide integration directory.
Addresses the maintainer + Codex review findings on the pyodide runtime:

- Runner: explicit ordered init sequence with a pre-`ready` adversarial
  self-test and an init try/catch that fails the spawn on error; snapshot
  and restore the FULL os.environ per run so env is strictly per-execution.
- Commands: read script files through one capped + symlink-refusing helper
  before decoding; reject read-only execs that report any mutation before
  touching the FS; stage out-of-cwd scripts at a reserved non-drainable
  path; validate the full drain manifest (uniqueness, reserved-prefix,
  write/delete ancestor conflicts, file-as-directory) before any write.
- Timeout: surface the internal EPYODIDE_TIMEOUT as a fatal throw that
  aborts the script transaction, mapped to 408 / SSE / batch timeout.
- Manager: resolve a bare Deno binary to an absolute path (the child env
  omits PATH); inject exported bash env into Python os.environ per run.
- Residency: clarify that admission is lazy/post-semaphore and the spawn
  thunk is cheap (no expensive init under the mutex).

Adds unit + integration coverage and updates the design record.
- runner: set/restore sys.path[0] for file scripts (sibling-import parity);
  track entry KIND in the diff baseline so file↔dir replacements drain.
- drain: validate file payloads are base64 (ipc); reject symlinked ancestors
  and handle wrong-kind targets; stop swallowing readdir() staging errors.
- residency: refresh the idle clock per Pyodide command (not just per
  bash.exec); catch dispose() rejections in the idle sweep.
- manager: handle async child.stdin stream errors (broken pipe).
- supply chain: pin the Deno binary sha256 for all four supported targets;
  fetch hard-fails on an unpinned or mismatched binary.
- tooling: add typecheck:runner (deno check) gate for the tsc-excluded
  runner; TS SDK validates python_runtime; parseNameVersion guards wheels.
- tests: per-execution env isolation + sys.path + file↔dir coverage; restore
  PYODIDE_ASSET_DIR/DENO_BIN_PATH and clean up sandboxes per test.
Per the error-handling guideline, sandboxRecordFromApi and streamEventFromApi
now throw Error instances carrying a code (EINVALID_PYTHON_RUNTIME /
EUNKNOWN_SSE_EVENT) instead of bare Errors.
Upstream added 0006_blob_last_referenced_at.sql; renumber the python_runtime
migration to avoid the duplicate 0006 prefix. Content unchanged (idempotent;
runs after the blob-gc migration).
@NeilMazumdar NeilMazumdar force-pushed the feature/pyodide-python-runtime branch from 6ad66e6 to baecea7 Compare June 9, 2026 04:49
@Hazzng

Hazzng commented Jun 9, 2026

Copy link
Copy Markdown
Owner

🐛 scripts/fetch-pyodide-assets.mjs — stale pyodide.mjs checksum pin blocks provisioning

Hit this while spinning up the Pyodide runtime locally (macOS, aarch64-apple-darwin):

node scripts/fetch-pyodide-assets.mjs
…
Error: checksum mismatch for pyodide.mjs:
  expected c8dffeefeb6f9c4bf635baf0cdb51f4da06df0e3aab4fe1a99b8ad3570065461
  got      8fdfed5eaf81bde14bcdeaeea11f2672675b2362248f8537446b6fda5e4a4751

The Deno binary downloads + verifies fine; this is a hard failure on the Pyodide dist verify (scripts/fetch-pyodide-assets.mjs:50).

It's only the loader shim — the critical artifacts match exactly

Downloaded from the pinned source (github.com/pyodide/pyodide/releases/download/0.29.4/pyodide-0.29.4.tar.bz2):

Artifact Pinned Downloaded
pyodide.mjs (17 KB JS loader) c8dffeef…65461 8fdfed5e…4a751 ❌ mismatch
pyodide.asm.wasm (8.6 MB runtime) 10090fe4…81e3 10090fe4…81e3 ✅ exact
python_stdlib.zip (2.4 MB) 92cb24fa…0efb4 92cb24fa…0efb4 ✅ exact

The wasm runtime and stdlib (the security-critical execution payload) are byte-identical to the pins — only the small JS bootstrap loader re-rolled upstream within the 0.29.4 release after the pin was recorded. So this reads as a benign upstream loader rebuild, not tampering; the pin is just stale.

Suggested fix

Re-pin SHA256["pyodide.mjs"] to 8fdfed5eaf81bde14bcdeaeea11f2672675b2362248f8537446b6fda5e4a4751 (after independently confirming against Pyodide's published checksums, since it's a supply-chain pin). pyodide.asm.wasm and python_stdlib.zip need no change.

Once re-pinned, provisioning proceeds (re-running the fetch skips the ~408 MB re-download and just verifies + grabs the openpyxl/et_xmlfile wheels). Happy to PR the pin update if useful.

- runner: preload numpy/pandas/matplotlib (swap scipy out) so the pyodide
  runtime covers CSV/Excel analysis + plotting; loaded by name from the
  stock lock with automatic dependency resolution.
- fetch-pyodide-assets: re-pin pyodide.mjs to the actual 0.29.4 GitHub
  release hash. The previous pin was stale (only the JS loader re-rolled
  upstream; pyodide.asm.wasm + python_stdlib.zip are byte-identical), which
  hard-failed the offline checksum guard during provisioning.
- README: document the python -> python_runtime breaking change, the
  stdlib vs pyodide runtimes, the preloaded package set, and memory/cap
  sizing (16 MiB file cap + MAX_CONCURRENT_PYODIDE=1 on a 2 GB host).
@Hazzng

Hazzng commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Local testing findings + suggested changes (memory, packages, plotting)

Ran the runtime end-to-end locally (macOS, Neon DB, Deno v2.8.2 + Pyodide 0.29.4 vendored). Sharing the changes I made + the empirical memory characterization. I can't push to the fork, so the diffs are inline below.

1. Provisioning blocker — re-pin pyodide.mjs (one-liner)

scripts/fetch-pyodide-assets.mjs hard-fails the checksum guard: the stock pin is stale (only the JS loader re-rolled upstream; pyodide.asm.wasm + python_stdlib.zip are byte-identical to the pins). Fix:

-	"pyodide.mjs": "c8dffeefeb6f9c4bf635baf0cdb51f4da06df0e3aab4fe1a99b8ad3570065461",
+	"pyodide.mjs": "8fdfed5eaf81bde14bcdeaeea11f2672675b2362248f8537446b6fda5e4a4751",

2. Package set is whatever the runner preloads — users can't extend it at runtime

Verified: a user script can only import the preloaded set + pure stdlib. import sympy/matplotlib/...ModuleNotFoundError, and pyodide_js.loadPackage(...) from user code does not work (post-lockdown). All ~380 wheels ship on disk but are unreachable unless preloaded. sqlite3 is also split out (not in default stdlib).

To enable plotting for the CSV/Excel use case I swapped scipy → matplotlib (loads by name from the stock lock, deps auto-resolve, renders PNGs fine):

-	await pyodide.loadPackage(["numpy", "pandas", "scipy"]);
+	await pyodide.loadPackage(["numpy", "pandas", "matplotlib"]);

(openpyxl stays on the file:// path — by-name fails on et_xmlfile resolution via the custom lock.)

3. Memory characterization (cap=1 unless noted) - Add to README.md

Scenario Worker peak Combined peak
Idle (fresh container) ~98 MiB
Pyodide floor, trivial script (numpy/pandas preloaded) ~580 MB
stdlib-only Pyodide (packages NOT preloaded) ~317 MB
pandas on 18 MB CSV (290k rows, 70 MB DataFrame) ~1.0 GB ~1.6 GB
+ matplotlib plot ~1.0 GB ~1.2 GB
50 MB CSV (raised stage cap) ~1.6–2.0 GB ~3.0 GB
mixed: 4 plots + 40 bash @cap=2 ~2.0 GB (2 workers) ~2.5 GB
mixed: 4 plots + 40 bash @cap=1 ~1.2 GB (1 worker) ~1.6 GB

Notes:

  • bash + the stdlib runtime are ~free — all real memory pressure is Pyodide workers; the cap bounds it (workers × ~1 GB).
  • chunksize/dtype-downcast do NOT reduce Pyodide memory — WASM heap never shrinks (peak = high-water) and the file is fully staged regardless; chunked variants measured higher (~2.0 GB vs ~1.6 GB naive). Reduce columns/rows before upload instead.
  • The node ingest spike scales ~5× file size (50 MB CSV → ~2.6 GB transient on the node side from base64 buffering) — worth streaming large uploads.

Sizing rec: 2 GB host → MAX_CONCURRENT_PYODIDE=1 + PYODIDE_MAX_FILE_BYTES=16777216 (16 MiB). The shipped 32 MiB default is borderline for 2 GB (a 50 MB file is rejected by it, by design — confirmed EPYODIDE_DRAIN ... exceeds the per-file stage cap).

4. Plot retrieval works end-to-end

matplotlib savefig under cwd → drains back to the sandbox FS → retrievable via GET /v1/sandboxes/{id}/files/<path> (200, image/png, valid PNG). No dedicated export needed.

Hazzng added 2 commits June 10, 2026 08:36
Profiling an 18.8 MB-CSV workload showed the spike was IPC/staging
machinery, not pandas. Three measured fixes:

- O(n) chunk-list framing in the Deno runner stdin loop and the Node
  manager #onStdoutData (was O(n^2): a full-buffer realloc+copy per pipe
  chunk for a ~25 MB base64 frame). Steady state ~1316MB -> ~804MB child.
- Streamed SHA-256 diff baseline in runOne: store size+hash per staged
  file instead of holding every file's bytes for the whole run and
  re-reading the tree afterwards for a byte compare.
- Lazy offline package loading: only PYODIDE_PRELOAD_PACKAGES (default
  numpy,pandas) is resident at init; other distribution packages load on
  first import from the local lock. matplotlib defaults to the headless
  Agg backend so savefig() works in the child (webagg needs the DOM).

New env vars PYODIDE_PRELOAD_PACKAGES and PYODIDE_MAX_CHILD_RSS_BYTES
(optional RSS-based child retirement after a high-water run). Adds the
memory-analysis and streaming/staging follow-up plan under thoughts/.

US-118: pyodide runtime memory optimization
The drain response is one length-prefixed frame carrying all created/
modified files, but the inbound per-frame cap defaulted to 64 MiB while
the staging total cap is 128 MiB — so a drain above ~64 MiB was killed
before the documented total was reachable.

- Raise PYODIDE_MAX_FRAME_BYTES_DEFAULT 64 -> 192 MiB and
  PYODIDE_MAX_AGGREGATE_BYTES_DEFAULT 96 -> 256 MiB (clears 128 MiB x
  ~1.33 base64 expansion; aggregate stays >= frame; both below the
  protocol 256 MiB ceiling).
- Wire PYODIDE_MAX_FRAME_BYTES / PYODIDE_MAX_AGGREGATE_BYTES env
  overrides in the manager constructor — they were documented but never
  read.

Interim stopgap; the per-file streaming that removes the monolithic-
frame coupling is tracked as Phase 1 in the follow-up plan.

US-118: pyodide IPC framing
@Hazzng

Hazzng commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Changes from commits 10c8ef3 + 2bc9c79 (US-118)

Two follow-up commits landed on this branch since last review. Summary below.


10c8ef3 — perf(pyodide): cut runtime memory ~1.6 GB → ~1.1 GB

Profiling an 18.8 MB-CSV workload showed the spike was IPC/staging machinery, not pandas. Three fixes:

1. O(n) chunk-list framing (manager + runner)

#onStdoutData in manager.ts and the stdin loop in runner.ts were both O(n²): each incoming pipe chunk triggered a Buffer.concat / new Uint8Array over the entire accumulated buffer. Replaced with a chunks: Buffer[] list + totalBytes counter. The full concat only happens once a complete frame is confirmed available, and only the relevant slice is re-emitted as rest.

Steady-state child RSS: ~1316 MB → ~804 MB on the test workload.

2. Streamed SHA-256 diff baseline (runner.ts runOne)

The pre-run baseline previously held every staged file's full bytes in a Map<string, Uint8Array> for byte comparison after execution. Replaced with Map<string, { size: number; sha256: string }> — only the hash and size are retained, the bytes are released immediately. Post-run diff checks size first (fast path) then SHA-256 on the new bytes.

3. Lazy offline package loading + matplotlib Agg backend

  • PYODIDE_PRELOAD_PACKAGES (default numpy,pandas) controls what's loaded at child init; everything else in the offline lock is loaded on first import via loadImportedPackages() (AST-based import scan → vendored package map → loadPackage).
  • os.environ.setdefault("MPLBACKEND", "Agg") is injected before user code runs so matplotlib.pyplot.savefig() works without a display.

New env vars:

  • PYODIDE_PRELOAD_PACKAGES — comma-separated, default numpy,pandas
  • PYODIDE_MAX_CHILD_RSS_BYTES — optional; retires a child after a completed run when its RSS exceeds this threshold (bounds idle heap retention, does not cap the in-flight spike)

New injectable readChildRssBytes option on PyodideSandboxOptions for test isolation (reads /proc/<pid>/status on Linux, falls back to ps).


2bc9c79 — fix(pyodide): raise IPC frame caps so 128 MiB drains succeed (Phase 0)

Root cause: The drain response is a single length-prefixed frame carrying all created/modified files, but PYODIDE_MAX_FRAME_BYTES_DEFAULT was 64 MiB while PYODIDE_MAX_TOTAL_BYTES (staging total cap) is 128 MiB × ~1.33 base64 expansion = ~170 MiB max drain frame. Any drain above ~64 MiB was killed by the manager before the documented 128 MiB total was reachable.

Changes:

  • PYODIDE_MAX_FRAME_BYTES_DEFAULT: 64 MiB → 192 MiB (clears 128 MiB × 1.33; stays below the protocol 256 MiB ceiling)
  • PYODIDE_MAX_AGGREGATE_BYTES_DEFAULT: 96 MiB → 256 MiB (must stay ≥ frame cap)
  • Wired PYODIDE_MAX_FRAME_BYTES / PYODIDE_MAX_AGGREGATE_BYTES env overrides in the PyodideSandbox constructor — these were documented in CLAUDE.md/README but were never actually read.
  • Added byteCapFromEnv() helper for safe positive-integer parsing from env.
  • Added IpcFrameTooLargeError import to manager (used by the new eager-reject path in #onStdoutData).

This is a stopgap (Phase 0). The per-file streaming that removes the monolithic-frame coupling entirely is tracked as Phase 1 in thoughts/issue-118-pyodide-runtime/streaming-staging-plan.md.


Test coverage: Both commits include unit test additions in manager.test.ts covering the new RSS-retirement path and the updated IPC cap wiring.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a Pyodide Python runtime for in-sandbox data analysis (numpy/pandas/openpyxl)

2 participants