Skip to content
59 changes: 56 additions & 3 deletions clients/python/src/sqlfs/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,57 @@
#: client via ``Client(max_file_size=...)`` or disable with ``max_file_size=0``.
DEFAULT_MAX_FILE_SIZE = 64 * 1024 * 1024

#: Largest file the ``python3`` runtime (CPython compiled to WASM) can read via
#: ``open()``. CPython reads sandbox files through an 8 MiB SharedArrayBuffer
#: IPC bridge; ``open()`` on a larger file fails with an opaque
#: ``FileNotFoundError`` / ``OSError``. The bytes still ingest fine and stay
#: readable from bash (``cat``/``grep``/``awk``) and ``js-exec`` — only
#: ``python3`` cannot read them.
CPYTHON_READ_LIMIT = 8 * 1024 * 1024


def _content_size(content: Union[str, bytes]) -> int:
"""Raw byte length of a write payload (``str`` measured as UTF-8)."""
return len(content.encode("utf-8")) if isinstance(content, str) else len(content)


def _enforce_cpython_read_limit(
files: Mapping[str, Union[str, bytes]],
*,
allow_oversized: bool,
) -> None:
"""Reject files the ``python3`` runtime could not later ``open()``.

Raises ``ValidationError`` (code ``EFILE_TOO_LARGE_FOR_CPYTHON``) naming
every file above :data:`CPYTHON_READ_LIMIT`. Pass ``allow_oversized=True``
to ingest anyway — the file is stored and usable from bash/js-exec, but
``python3`` ``open()`` will fail on it.
"""
if allow_oversized:
return
too_big = [
(path, size)
for path, content in files.items()
if (size := _content_size(content)) > CPYTHON_READ_LIMIT
]
if too_big:
details = [
f"{path} ({size} bytes > {CPYTHON_READ_LIMIT} python3 open() limit)"
for path, size in too_big
]
raise ValidationError(
"file exceeds the 8 MiB limit that the python3 (CPython WASM) runtime "
"can read via open(): "
+ "; ".join(details)
+ ". The bytes ingest fine and stay usable from bash (cat/grep/awk) and "
"js-exec — only python3 open() will fail on these files. Pass "
"allow_oversized=True to ingest anyway, or split the file into <8 MiB "
"chunks if you need to read it from python3.",
code="EFILE_TOO_LARGE_FOR_CPYTHON",
details=details,
)


def _enforce_max_file_size(
files: Mapping[str, Union[str, bytes]],
max_file_size: int,
Expand Down Expand Up @@ -409,17 +454,25 @@ def ingest_files(
files: Mapping[str, Union[str, bytes]],
*,
base_path: str = "/home/user/project",
allow_oversized: bool = False,
) -> Dict[str, Any]:
"""`POST /ingest-files` — write a JSON manifest of files (auto base64).

Keys are paths relative to `base_path`. Values may be `str` (encoded
utf-8) or `bytes` (encoded as-is); the SDK base64-encodes them.

Each file is checked against the client's ``max_file_size`` first; an
oversized file raises ``ValidationError`` (code ``EFILE_TOO_LARGE``)
before anything is encoded or sent over the network.
Two client-side checks run before anything is encoded or sent:

1. Each file is checked against the client's ``max_file_size``; an
oversized file raises ``ValidationError`` (code ``EFILE_TOO_LARGE``).
2. Any file larger than 8 MiB (:data:`CPYTHON_READ_LIMIT`) raises
``ValidationError`` (code ``EFILE_TOO_LARGE_FOR_CPYTHON``) because the
``python3`` runtime cannot ``open()`` it. Pass ``allow_oversized=True``
to ingest anyway — the file stays usable from bash/js-exec, but
``python3`` ``open()`` will fail on it.
"""
_enforce_max_file_size(files, self._max_file_size)
_enforce_cpython_read_limit(files, allow_oversized=allow_oversized)
encoded: Dict[str, str] = {}
for path, content in files.items():
data = content.encode("utf-8") if isinstance(content, str) else content
Expand Down
29 changes: 29 additions & 0 deletions clients/python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,35 @@ def test_ingest_files_base64_encodes():
assert sent["files"]["b.bin"] == base64.b64encode(b"\x00\x01").decode()


@respx.mock
def test_ingest_files_blocks_oversized_for_cpython():
route = respx.post(f"{BASE_URL}/v1/sandboxes/sb/ingest-files").mock(
return_value=httpx.Response(200, json={"status": "ok", "fileCount": 1})
)
sb = make_client().sandboxes.attach("sb")
big = b"\x00" * (8 * 1024 * 1024 + 1)
with pytest.raises(ValidationError) as exc:
sb.ingest_files({"big.csv": big})
assert exc.value.code == "EFILE_TOO_LARGE_FOR_CPYTHON"
limit = 8 * 1024 * 1024
assert exc.value.details == [f"big.csv ({len(big)} bytes > {limit} python3 open() limit)"]
# Nothing sent over the network.
assert route.call_count == 0


@respx.mock
def test_ingest_files_allow_oversized_bypasses_cpython_check():
route = respx.post(f"{BASE_URL}/v1/sandboxes/sb/ingest-files").mock(
return_value=httpx.Response(200, json={"status": "ok", "fileCount": 1})
)
sb = make_client().sandboxes.attach("sb")
big = b"\x00" * (8 * 1024 * 1024 + 1)
sb.ingest_files({"big.csv": big}, allow_oversized=True)
assert route.call_count == 1
sent = json.loads(route.calls[0].request.content.decode())
assert list(sent["files"].keys()) == ["big.csv"]


# ── Error mapping ────────────────────────────────────────────────────────────
@respx.mock
def test_400_maps_to_validation_error():
Expand Down
37 changes: 36 additions & 1 deletion clients/typescript/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,11 @@ export class Sandbox {

async ingestFiles(
files: Record<string, FileContent>,
options: { basePath?: string } = {},
options: { basePath?: string; allowOversized?: boolean } = {},
): Promise<Record<string, unknown>> {
enforceMaxFileSize(files, this.maxFileSize);
// Files >8 MiB can't be read by python3's open() (CPython WASM IPC bridge cap).
enforceCpythonReadLimit(files, options.allowOversized ?? false);
const encoded: Record<string, string> = Object.create(null) as Record<string, string>;
for (const [path, content] of Object.entries(files)) {
encoded[path] = toBase64(content);
Expand Down Expand Up @@ -288,6 +290,39 @@ function enforceMaxFileSize(files: Record<string, FileContent>, maxFileSize: num
});
}

/**
* Largest file the `python3` runtime (CPython compiled to WASM) can read via
* `open()`. CPython reads sandbox files through an 8 MiB SharedArrayBuffer IPC
* bridge; `open()` on a larger file fails with an opaque error. The bytes still
* ingest fine and stay readable from bash and js-exec — only `python3` cannot
* read them.
*/
export const cpythonReadLimit = 8 * 1024 * 1024;

/**
* Reject files the `python3` runtime could not later `open()`. Throws
* ValidationError (code `EFILE_TOO_LARGE_FOR_CPYTHON`) unless `allowOversized`
* is set — in which case the file ingests but `python3` open() fails on it.
*/
function enforceCpythonReadLimit(files: Record<string, FileContent>, allowOversized: boolean): void {
if (allowOversized) {
return;
}
const tooBig = Object.entries(files)
.map(([path, content]) => [path, contentSize(content)] as const)
.filter(([, size]) => size > cpythonReadLimit);
if (tooBig.length === 0) {
return;
}
const details = tooBig.map(([path, size]) => `${path} (${size} bytes > ${cpythonReadLimit} python3 open() limit)`);
throw new ValidationError(
`file exceeds the 8 MiB limit that the python3 (CPython WASM) runtime can read via open(): ${details.join(
"; ",
)}. The bytes ingest fine and stay usable from bash (cat/grep/awk) and js-exec — only python3 open() will fail on these files. Pass allowOversized: true to ingest anyway, or split the file into <8 MiB chunks if you need to read it from python3.`,
{ code: "EFILE_TOO_LARGE_FOR_CPYTHON", details },
);
}

function toBytes(content: FileContent): Uint8Array<ArrayBuffer> {
if (typeof content === "string") {
return new TextEncoder().encode(content);
Expand Down
24 changes: 24 additions & 0 deletions clients/typescript/tests/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,28 @@ describe("TypeScript SQL-FS SDK files", () => {
);
expect(blocked.fetchMock).not.toHaveBeenCalled();
});

it("blocks ingest of files >8 MiB the python3 runtime can't open()", async () => {
const { fetchMock } = makeFetch([jsonResponse(200, {})]);
const sb = makeClient(fetchMock).sandboxes.attach("sb");
const big = new Uint8Array(8 * 1024 * 1024 + 1);

await expect(sb.ingestFiles({ "big.csv": big })).rejects.toMatchObject({
code: "EFILE_TOO_LARGE_FOR_CPYTHON",
details: [`big.csv (${big.byteLength} bytes > ${8 * 1024 * 1024} python3 open() limit)`],
});
// Nothing should have been sent over the network.
expect(fetchMock).not.toHaveBeenCalled();
});

it("ingests an oversized file when allowOversized is set", async () => {
const { fetchMock, captured } = makeFetch([jsonResponse(200, { count: 1 })]);
const sb = makeClient(fetchMock).sandboxes.attach("sb");
const big = new Uint8Array(8 * 1024 * 1024 + 1);

await expect(sb.ingestFiles({ "big.csv": big }, { allowOversized: true })).resolves.toEqual({ count: 1 });
expect(fetchMock).toHaveBeenCalledTimes(1);
const sentBody = captured[0]?.body as { files: Record<string, string> };
expect(Object.keys(sentBody.files)).toEqual(["big.csv"]);
});
});
2 changes: 1 addition & 1 deletion plugins/sql-fs/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sql-fs",
"version": "1.1.0",
"version": "1.1.1",
"description": "Operator skill for the SQL-FS API — persistent bash sandbox service with SQL-backed filesystem",
"author": {
"name": "Harry Nguyen",
Expand Down
24 changes: 12 additions & 12 deletions plugins/sql-fs/skills/api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ Invoke with optional sub-commands:

| Invocation | What happens |
|---|---|
| `/sqlfs:api` | General assistant — answer questions, generate commands |
| `/sqlfs:api setup` | Walk through auth bootstrap and first sandbox |
| `/sqlfs:api exec <script>` | Generate a ready-to-run exec-sync curl for `$ARGUMENTS` |
| `/sqlfs:api ingest <path>` | Generate an ingest-files payload for a local directory |
| `/sqlfs:api explore` | Load the active sandbox tree and start exploring |
| `/sql-fs:api` | General assistant — answer questions, generate commands |
| `/sql-fs:api setup` | Walk through auth bootstrap and first sandbox |
| `/sql-fs:api exec <script>` | Generate a ready-to-run exec-sync curl for `$ARGUMENTS` |
| `/sql-fs:api ingest <path>` | Generate an ingest-files payload for a local directory |
| `/sql-fs:api explore` | Load the active sandbox tree and start exploring |

Current arguments: **$ARGUMENTS**

Expand All @@ -43,21 +43,21 @@ Exception: `POST /v1/auth/bootstrap` is unauthenticated — it is how you obtain

## Supporting docs — read these when relevant

All reference material lives under `plugins/sqlfs/skills/api/` in this project:
All reference material lives under `plugins/sql-fs/skills/api/` in this project:

- **Setup & Auth** → `plugins/sqlfs/skills/api/SETUP.md`
- **Setup & Auth** → `plugins/sql-fs/skills/api/SETUP.md`
Read this when the user asks about tokens, first-time setup, or multi-tenant config.

- **Endpoint reference** → `plugins/sqlfs/skills/api/ref/endpoints.md`
- **Endpoint reference** → `plugins/sql-fs/skills/api/ref/endpoints.md`
Full schema for every route. Read this when generating curl commands.

- **Error codes** → `plugins/sqlfs/skills/api/ref/errors.md`
- **Error codes** → `plugins/sql-fs/skills/api/ref/errors.md`
HTTP → FS code mapping. Read this when debugging API responses.

- **Bash capabilities** → `plugins/sqlfs/skills/api/ref/bash.md`
- **Bash capabilities** → `plugins/sql-fs/skills/api/ref/bash.md`
What just-bash supports and what it doesn't. Read this before writing scripts.

- **Working examples** → `plugins/sqlfs/skills/api/examples/`
- **Working examples** → `plugins/sql-fs/skills/api/examples/`
- `quickstart.sh` — create sandbox, write file, exec, delete
- `ingest-files.sh` — upload a local folder via the `ingest-files` JSON manifest
- `ingest-explore.sh` — load a codebase and grep/cat via bash_exec
Expand All @@ -74,7 +74,7 @@ response shapes, and known gotchas from the live API.
2. Sandbox filesystem survives session eviction (Postgres is durable). Shell state (env vars,
cwd, functions) resets after 10 min of idle.
3. Never produce `curl` commands that omit `-s` — noisy progress meters obscure the output.
4. For any task that touches files: read `plugins/sqlfs/skills/api/ref/endpoints.md` first.
4. For any task that touches files: read `plugins/sql-fs/skills/api/ref/endpoints.md` first.

---

Expand Down
4 changes: 2 additions & 2 deletions plugins/sql-fs/skills/api/ref/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ curl -s -X POST "$BASE_URL/v1/sandboxes/$SB/exec-sync-batch" \
-d '{
"scripts": [
{"id": "tree", "script": "find /home/user -type f | head -20"},
{"id": "uname", "script": "uname -srm"}
{"id": "count", "script": "find /home/user -type f | wc -l"}
],
"timeoutMs": 30000,
"readOnly": true
Expand All @@ -365,7 +365,7 @@ Response `200`:
{
"results": [
{"id": "tree", "stdout": "...", "stderr": "", "exitCode": 0, "durationMs": 42},
{"id": "uname", "stdout": "Linux ...", "stderr": "", "exitCode": 0, "durationMs": 8}
{"id": "count", "stdout": "12\n", "stderr": "", "exitCode": 0, "durationMs": 8}
]
}
```
Expand Down
2 changes: 1 addition & 1 deletion plugins/sql-fs/skills/py-sdk/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ with Client(
) as fs:
sb = fs.sandboxes.create(name="smoke-test")
try:
result = sb.exec("echo hello && uname -srm")
result = sb.exec("echo hello && pwd")
assert result.ok, f"unexpected exit {result.exit_code}: {result.error}"
print(result.stdout)
finally:
Expand Down
38 changes: 21 additions & 17 deletions plugins/sql-fs/skills/py-sdk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ Invoke with optional sub-commands:

| Invocation | What happens |
|---|---|
| `/sqlfs:py-sdk` | General assistant — answer questions, generate snippets |
| `/sqlfs:py-sdk setup` | Walk through install, env vars, first sandbox |
| `/sqlfs:py-sdk exec <script>` | Generate a ready-to-run `sb.exec(...)` snippet for `$ARGUMENTS` |
| `/sqlfs:py-sdk ingest <path>` | Generate an `sb.ingest_files(...)` snippet for a local directory |
| `/sqlfs:py-sdk explore` | Build an `exec_batch` exploration script for the active sandbox |
| `/sql-fs:py-sdk` | General assistant — answer questions, generate snippets |
| `/sql-fs:py-sdk setup` | Walk through install, env vars, first sandbox |
| `/sql-fs:py-sdk exec <script>` | Generate a ready-to-run `sb.exec(...)` snippet for `$ARGUMENTS` |
| `/sql-fs:py-sdk ingest <path>` | Generate an `sb.ingest_files(...)` snippet for a local directory |
| `/sql-fs:py-sdk explore` | Build an `exec_batch` exploration script for the active sandbox |

Current arguments: **$ARGUMENTS**

Expand Down Expand Up @@ -65,24 +65,24 @@ either `auth_secret=...` (with `sub=...`) or a pre-minted `token=...`.

## Supporting docs — read these when relevant

All reference material lives under `plugins/sqlfs/skills/py-sdk/` in this project:
All reference material lives under `plugins/sql-fs/skills/py-sdk/` in this project:

- **Setup & auth** → `plugins/sqlfs/skills/py-sdk/SETUP.md`
- **Setup & auth** → `plugins/sql-fs/skills/py-sdk/SETUP.md`
Read this when the user asks about install, env vars, token vs auth_secret, or first-time setup.

- **Client reference** → `plugins/sqlfs/skills/py-sdk/ref/client.md`
- **Client reference** → `plugins/sql-fs/skills/py-sdk/ref/client.md`
`Client(...)` constructor + `client.sandboxes.{list,create,get,attach,delete}`. Read this for sandbox lifecycle.

- **Sandbox reference** → `plugins/sqlfs/skills/py-sdk/ref/sandbox.md`
- **Sandbox reference** → `plugins/sql-fs/skills/py-sdk/ref/sandbox.md`
`sb.exec / exec_batch / exec_stream`, `sb.ingest_files`, `sb.delete`. Read this for everything an agent does inside a sandbox.

- **Models reference** → `plugins/sqlfs/skills/py-sdk/ref/models.md`
- **Models reference** → `plugins/sql-fs/skills/py-sdk/ref/models.md`
Field-by-field shape of `ExecResult`, `BatchExecResult`, `StreamEvent`, `SandboxRecord`, etc. Read this when shaping return-value handling.

- **Error reference** → `plugins/sqlfs/skills/py-sdk/ref/errors.md`
- **Error reference** → `plugins/sql-fs/skills/py-sdk/ref/errors.md`
Exception hierarchy + HTTP-status mapping. Read this when writing `try/except` blocks.

- **Working examples** → `plugins/sqlfs/skills/py-sdk/examples/`
- **Working examples** → `plugins/sql-fs/skills/py-sdk/examples/`
- `quickstart.py` — create sandbox, ingest, exec, delete
- `ingest-explore.py` — load a codebase via `ingest_files` then explore via `exec_batch`
- `exec-stream.py` — SSE streaming with proper iteration
Expand All @@ -103,7 +103,7 @@ method names, parameter spellings, and known gotchas from the SDK.
the finally block — sandboxes survive process exit and accumulate.
4. **Sandbox filesystem is durable** (Postgres-backed). Bash session state (env vars,
cwd, shell functions) resets after 10 min idle — re-export anything you need.
5. **For any task that runs scripts**: read `plugins/sqlfs/skills/py-sdk/ref/sandbox.md`
5. **For any task that runs scripts**: read `plugins/sql-fs/skills/py-sdk/ref/sandbox.md`
first to use the right method and timeout shape.

---
Expand Down Expand Up @@ -143,11 +143,15 @@ methods are banned for agent use.
a local folder into a fresh sandbox (single HTTP round-trip, base64-safe for binary).
After ingest, all further interaction must be via `sb.exec / exec_batch / exec_stream`.

**Per-file size limit:** every write path (`ingest_files`, `fs.write`, `fs.write_files`)
enforces `Client(max_file_size=...)` — default **64 MiB** — **before** anything is
encoded or sent. An oversized file raises `ValidationError(code="EFILE_TOO_LARGE")`
**Per-file size limits (client-side):** every write path (`ingest_files`, `fs.write`,
`fs.write_files`) enforces `Client(max_file_size=...)` — default **64 MiB** — **before**
anything is encoded or sent. An oversized file raises `ValidationError(code="EFILE_TOO_LARGE")`
client-side (no network round-trip). Raise it via `Client(max_file_size=...)` or
disable with `max_file_size=0`. See `ref/client.md`.
disable with `max_file_size=0`. Additionally, `ingest_files` rejects any file **> 8 MiB**
with `ValidationError(code="EFILE_TOO_LARGE_FOR_CPYTHON")` because the `python3` runtime
(CPython WASM) can't `open()` it; pass `allow_oversized=True` to ingest anyway (the bytes
stay usable from bash/`js-exec`, only `python3 open()` fails), or split into <8 MiB chunks.
See `ref/client.md` and `ref/sandbox.md`.

When a user asks for an `sb.fs.*` snippet, **decline politely and produce the exec
equivalent instead**, citing this policy. If the workflow truly cannot be expressed
Expand Down
2 changes: 1 addition & 1 deletion plugins/sql-fs/skills/py-sdk/examples/quickstart.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def main() -> int:
print(f"created sandbox {sb.id}")
try:
# Single buffered exec — get back a flat ExecResult.
r = sb.exec("echo hello && uname -srm")
r = sb.exec("echo hello && pwd")
if not r.ok:
print(f"unexpected exit {r.exit_code}: {r.error}", file=sys.stderr)
return 1
Expand Down
Loading
Loading