From 22c9abc0e6112e07e6bbdd801ca0c3ec0e2b3dca Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Sun, 7 Jun 2026 19:09:05 +0930 Subject: [PATCH 1/8] feat(sdk): guard ingest against >8MB files python3 can't open() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The python3 runtime (CPython WASM) reads sandbox files through an 8 MiB IPC bridge, so open() fails on larger files. Both SDKs now reject files over 8 MiB on ingest_files/ingestFiles with EFILE_TOO_LARGE_FOR_CPYTHON before sending. Pass allow_oversized/allowOversized to ingest anyway — the bytes stay usable from bash and js-exec, only python3 open() can't read them. Split into <8 MiB chunks to read large files from python3. --- .changeset/sdk-cpython-8mb-ingest-guard.md | 5 ++ clients/python/CHANGELOG.md | 12 +++++ clients/python/src/sqlfs/sandbox.py | 59 +++++++++++++++++++-- clients/python/tests/test_client.py | 29 ++++++++++ clients/typescript/CHANGELOG.md | 12 +++++ clients/typescript/src/sandbox.ts | 37 ++++++++++++- clients/typescript/tests/files.test.ts | 24 +++++++++ plugins/sql-fs/skills/py-sdk/ref/sandbox.md | 22 +++++++- 8 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 .changeset/sdk-cpython-8mb-ingest-guard.md diff --git a/.changeset/sdk-cpython-8mb-ingest-guard.md b/.changeset/sdk-cpython-8mb-ingest-guard.md new file mode 100644 index 0000000..91f38ff --- /dev/null +++ b/.changeset/sdk-cpython-8mb-ingest-guard.md @@ -0,0 +1,5 @@ +--- +"sql-fs-api": patch +--- + +SDK: guard `ingest_files`/`ingestFiles` against files larger than 8 MiB that the `python3` runtime (CPython WASM) cannot `open()`. Oversized files now raise `ValidationError` (code `EFILE_TOO_LARGE_FOR_CPYTHON`) before anything is sent; pass `allow_oversized=True` (Python) / `allowOversized: true` (TS) to ingest anyway — the bytes stay usable from bash and `js-exec`, only `python3 open()` can't read them. Split into <8 MiB chunks to read large files from `python3`. diff --git a/clients/python/CHANGELOG.md b/clients/python/CHANGELOG.md index 0879ccf..687aee6 100644 --- a/clients/python/CHANGELOG.md +++ b/clients/python/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to the SQL-FS Python SDK are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `ingest_files(..., allow_oversized=False)` — a client-side guard that rejects + files larger than 8 MiB with `ValidationError(code="EFILE_TOO_LARGE_FOR_CPYTHON")` + before anything is sent. The `python3` runtime (CPython WASM) reads files + through an 8 MiB IPC bridge, so `open()` fails on larger files. Pass + `allow_oversized=True` to ingest anyway (the bytes stay usable from bash and + `js-exec`; only `python3 open()` can't read them), or split the file into + <8 MiB chunks. + ## [0.3.0] - 2026-06-05 ### Added diff --git a/clients/python/src/sqlfs/sandbox.py b/clients/python/src/sqlfs/sandbox.py index cb4acef..0bea987 100644 --- a/clients/python/src/sqlfs/sandbox.py +++ b/clients/python/src/sqlfs/sandbox.py @@ -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, @@ -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 diff --git a/clients/python/tests/test_client.py b/clients/python/tests/test_client.py index cea6142..980e531 100644 --- a/clients/python/tests/test_client.py +++ b/clients/python/tests/test_client.py @@ -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(): diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 533c5ac..e2f19eb 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to the TypeScript SDK are documented here. +## [Unreleased] + +### Added + +- `ingestFiles(..., { allowOversized })` — a client-side guard that rejects + files larger than 8 MiB with `ValidationError` (code + `EFILE_TOO_LARGE_FOR_CPYTHON`) before anything is sent. The `python3` runtime + (CPython WASM) reads files through an 8 MiB IPC bridge, so `open()` fails on + larger files. Pass `allowOversized: true` to ingest anyway (the bytes stay + usable from bash and `js-exec`; only `python3 open()` can't read them), or + split the file into <8 MiB chunks. + ## [0.3.0] - 2026-06-05 ### Added diff --git a/clients/typescript/src/sandbox.ts b/clients/typescript/src/sandbox.ts index 2efd1f3..88f11f0 100644 --- a/clients/typescript/src/sandbox.ts +++ b/clients/typescript/src/sandbox.ts @@ -216,9 +216,11 @@ export class Sandbox { async ingestFiles( files: Record, - options: { basePath?: string } = {}, + options: { basePath?: string; allowOversized?: boolean } = {}, ): Promise> { 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 = Object.create(null) as Record; for (const [path, content] of Object.entries(files)) { encoded[path] = toBase64(content); @@ -288,6 +290,39 @@ function enforceMaxFileSize(files: Record, 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, 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 { if (typeof content === "string") { return new TextEncoder().encode(content); diff --git a/clients/typescript/tests/files.test.ts b/clients/typescript/tests/files.test.ts index 257aa2d..517718a 100644 --- a/clients/typescript/tests/files.test.ts +++ b/clients/typescript/tests/files.test.ts @@ -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 }; + expect(Object.keys(sentBody.files)).toEqual(["big.csv"]); + }); }); diff --git a/plugins/sql-fs/skills/py-sdk/ref/sandbox.md b/plugins/sql-fs/skills/py-sdk/ref/sandbox.md index 2b9d40a..8103960 100644 --- a/plugins/sql-fs/skills/py-sdk/ref/sandbox.md +++ b/plugins/sql-fs/skills/py-sdk/ref/sandbox.md @@ -139,7 +139,7 @@ overhead per chunk. ## Ingest (bootstrap only) -### `sb.ingest_files(files, *, base_path="/home/user/project") -> dict` +### `sb.ingest_files(files, *, base_path="/home/user/project", allow_oversized=False) -> dict` Maps to `POST /v1/sandboxes/{id}/ingest-files`. **Allowed for one-time bootstrap of a fresh sandbox** — for any further file mutation, switch to @@ -171,6 +171,26 @@ checked against the client's `max_file_size` (default 64 MiB — see `ValidationError(code="EFILE_TOO_LARGE")` and **nothing is sent**. Raise or disable it with `Client(max_file_size=...)` / `max_file_size=0`. +**8 MiB `python3` read limit (client-side).** Any file larger than 8 MiB raises +`ValidationError(code="EFILE_TOO_LARGE_FOR_CPYTHON")` and **nothing is sent**. +The `python3` runtime (CPython WASM) reads sandbox files through an 8 MiB IPC +bridge — `open()` on a larger file fails with an opaque error. The bytes +themselves ingest fine and stay usable from bash (`cat`/`grep`/`awk`) and +`js-exec`; only `python3 open()` can't read them. Pass `allow_oversized=True` to +ingest anyway (accepting that `python3` can't read the file), or split the file +into <8 MiB chunks and recombine them in your script: + +```python +# Blocked — a 17 MB CSV the python3 runtime can't open(): +sb.ingest_files({"data.csv": big_csv_bytes}) # raises EFILE_TOO_LARGE_FOR_CPYTHON + +# Option A — store it anyway for bash/js-exec (grep/awk/cut), not python3: +sb.ingest_files({"data.csv": big_csv_bytes}, allow_oversized=True) + +# Option B — split into <8 MiB chunks, recombine in python3 (see SKILL.md): +sb.ingest_files({"chunk1.csv": part1, "chunk2.csv": part2, "chunk3.csv": part3}) +``` + **Hard limits to keep in mind:** - All file bytes are buffered into one HTTP request body. The server caps the whole body (default 256 MB); after ~33% base64 inflation that's ~190 MB of raw From 9aef98be8d9ea78f423b49e4a0eb25f077ff8f80 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Sun, 7 Jun 2026 19:18:44 +0930 Subject: [PATCH 2/8] fix(sdk): drop manual Unreleased changelog sections check:version requires the top CHANGELOG heading to match package.json (0.3.0); the changeset file is the canonical record of unreleased changes, so version-locked dated sections are the repo convention. --- clients/python/CHANGELOG.md | 12 ------------ clients/typescript/CHANGELOG.md | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/clients/python/CHANGELOG.md b/clients/python/CHANGELOG.md index 687aee6..0879ccf 100644 --- a/clients/python/CHANGELOG.md +++ b/clients/python/CHANGELOG.md @@ -5,18 +5,6 @@ All notable changes to the SQL-FS Python SDK are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -### Added - -- `ingest_files(..., allow_oversized=False)` — a client-side guard that rejects - files larger than 8 MiB with `ValidationError(code="EFILE_TOO_LARGE_FOR_CPYTHON")` - before anything is sent. The `python3` runtime (CPython WASM) reads files - through an 8 MiB IPC bridge, so `open()` fails on larger files. Pass - `allow_oversized=True` to ingest anyway (the bytes stay usable from bash and - `js-exec`; only `python3 open()` can't read them), or split the file into - <8 MiB chunks. - ## [0.3.0] - 2026-06-05 ### Added diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index e2f19eb..533c5ac 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -2,18 +2,6 @@ All notable changes to the TypeScript SDK are documented here. -## [Unreleased] - -### Added - -- `ingestFiles(..., { allowOversized })` — a client-side guard that rejects - files larger than 8 MiB with `ValidationError` (code - `EFILE_TOO_LARGE_FOR_CPYTHON`) before anything is sent. The `python3` runtime - (CPython WASM) reads files through an 8 MiB IPC bridge, so `open()` fails on - larger files. Pass `allowOversized: true` to ingest anyway (the bytes stay - usable from bash and `js-exec`; only `python3 open()` can't read them), or - split the file into <8 MiB chunks. - ## [0.3.0] - 2026-06-05 ### Added From 94d881c5816dce1952a14e459362b2516883eed7 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Mon, 8 Jun 2026 11:40:20 +0930 Subject: [PATCH 3/8] chore: drop changeset; change is SDK-only, not sql-fs-api The 8MB ingest guard lives entirely in clients/{python,typescript}, which are versioned independently of the sql-fs-api package the changeset system tracks. A sql-fs-api bump would be inaccurate. --- .changeset/sdk-cpython-8mb-ingest-guard.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/sdk-cpython-8mb-ingest-guard.md diff --git a/.changeset/sdk-cpython-8mb-ingest-guard.md b/.changeset/sdk-cpython-8mb-ingest-guard.md deleted file mode 100644 index 91f38ff..0000000 --- a/.changeset/sdk-cpython-8mb-ingest-guard.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"sql-fs-api": patch ---- - -SDK: guard `ingest_files`/`ingestFiles` against files larger than 8 MiB that the `python3` runtime (CPython WASM) cannot `open()`. Oversized files now raise `ValidationError` (code `EFILE_TOO_LARGE_FOR_CPYTHON`) before anything is sent; pass `allow_oversized=True` (Python) / `allowOversized: true` (TS) to ingest anyway — the bytes stay usable from bash and `js-exec`, only `python3 open()` can't read them. Split into <8 MiB chunks to read large files from `python3`. From 7eda2ad6fc518446554c9becf225f7f0f628cd9f Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Mon, 8 Jun 2026 11:56:49 +0930 Subject: [PATCH 4/8] docs(plugin): add ts-sdk skill mirroring py-sdk; bump plugin 1.1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New skill plugins/sql-fs/skills/ts-sdk/ documents the TypeScript SDK (sql-fs-sdk) with the same structure as py-sdk: SKILL.md, SETUP.md, ref/{client,sandbox,models,errors}.md, and four runnable examples. Adapted to the TS API surface — camelCase methods, options objects, async SSE via for-await, try/finally + client.close() (no context manager) — and documents the new ingestFiles allowOversized / 8 MiB EFILE_TOO_LARGE_FOR_CPYTHON guard. Bumps the plugin to 1.1.1. --- plugins/sql-fs/.claude-plugin/plugin.json | 2 +- plugins/sql-fs/skills/ts-sdk/SETUP.md | 193 ++++++++++++ plugins/sql-fs/skills/ts-sdk/SKILL.md | 228 ++++++++++++++ .../skills/ts-sdk/examples/batch-explore.ts | 67 ++++ .../skills/ts-sdk/examples/exec-stream.ts | 70 +++++ .../skills/ts-sdk/examples/ingest-explore.ts | 96 ++++++ .../skills/ts-sdk/examples/quickstart.ts | 51 +++ plugins/sql-fs/skills/ts-sdk/ref/client.md | 243 +++++++++++++++ plugins/sql-fs/skills/ts-sdk/ref/errors.md | 205 ++++++++++++ plugins/sql-fs/skills/ts-sdk/ref/models.md | 159 ++++++++++ plugins/sql-fs/skills/ts-sdk/ref/sandbox.md | 292 ++++++++++++++++++ 11 files changed, 1605 insertions(+), 1 deletion(-) create mode 100644 plugins/sql-fs/skills/ts-sdk/SETUP.md create mode 100644 plugins/sql-fs/skills/ts-sdk/SKILL.md create mode 100644 plugins/sql-fs/skills/ts-sdk/examples/batch-explore.ts create mode 100644 plugins/sql-fs/skills/ts-sdk/examples/exec-stream.ts create mode 100644 plugins/sql-fs/skills/ts-sdk/examples/ingest-explore.ts create mode 100644 plugins/sql-fs/skills/ts-sdk/examples/quickstart.ts create mode 100644 plugins/sql-fs/skills/ts-sdk/ref/client.md create mode 100644 plugins/sql-fs/skills/ts-sdk/ref/errors.md create mode 100644 plugins/sql-fs/skills/ts-sdk/ref/models.md create mode 100644 plugins/sql-fs/skills/ts-sdk/ref/sandbox.md diff --git a/plugins/sql-fs/.claude-plugin/plugin.json b/plugins/sql-fs/.claude-plugin/plugin.json index 5b302f3..c2cf5cd 100644 --- a/plugins/sql-fs/.claude-plugin/plugin.json +++ b/plugins/sql-fs/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/sql-fs/skills/ts-sdk/SETUP.md b/plugins/sql-fs/skills/ts-sdk/SETUP.md new file mode 100644 index 0000000..f0ddd8f --- /dev/null +++ b/plugins/sql-fs/skills/ts-sdk/SETUP.md @@ -0,0 +1,193 @@ +# SQL-FS TypeScript SDK — Setup & Auth + +## Install + +The SDK lives at `clients/typescript/` in the sqlfs monorepo and is published as +the `sql-fs-sdk` package on npm (see `package.json` for the canonical version). + +```bash +# From npm (when released) +npm install sql-fs-sdk + +# From this repo (workspace / file install — picks up local changes) +pnpm add ./clients/typescript +``` + +ESM-only. Requires **Node ≥ 22** (the SDK uses the global `fetch`). No runtime +dependencies. + +--- + +## Two ways to authenticate + +The SDK accepts **either** a pre-minted `token` **or** the server's `authSecret` +(plus a `sub`). The latter is recommended for short-lived agents — the SDK +exchanges the secret for a JWT lazily on the first request. + +### Option A — `authSecret` (recommended for agents) + +```typescript +import { Client } from "sql-fs-sdk"; + +const client = new Client({ + baseUrl: process.env.BASE_URL!, + authSecret: process.env.AUTH_SECRET!, + sub: "my-agent", // token subject = sandbox owner identity + expiresIn: "30d", // one of: "24h", "30d", "1y", "never" +}); + +console.log((await client.getToken()).slice(0, 20), "..."); // forces bootstrap +``` + +The bootstrap call hits `POST /v1/auth/bootstrap` with `X-Auth-Secret`. Subsequent +requests reuse the cached JWT. `client.token` is the sync getter (may be +`undefined` before bootstrap); `await client.getToken()` forces it. + +### Option B — pre-minted `token` + +If you've already minted a token (e.g. via `pnpm token:create` on the server +host, or via `POST /v1/auth/admin`), pass it directly: + +```typescript +const client = new Client({ baseUrl: process.env.BASE_URL!, token: process.env.TOKEN! }); +const sandboxes = await client.sandboxes.list(); +``` + +You don't need `sub` when passing `token` — it's already encoded in the JWT. + +### Option C — admin secret (for token-minting tools, not agents) + +```typescript +const client = new Client({ + baseUrl: process.env.BASE_URL!, + adminSecret: process.env.ADMIN_SECRET!, + sub: "agent-001", +}); +console.log(await client.getToken()); // minted via POST /v1/auth/admin +``` + +This is what you'd use to **build** a tool that mints scoped tokens for other +agents. For day-to-day agent work, prefer Option A. + +--- + +## Required env vars + +```bash +export BASE_URL="" # e.g. https://your-app.azurecontainerapps.io +export AUTH_SECRET="" # never commit; ask the deployer +# OR +export TOKEN="" +``` + +The SDK reads these via `process.env[...]` in your code — it does NOT read them +automatically. Always pass them explicitly to `new Client({...})`. + +--- + +## First sandbox — end-to-end smoke test + +```typescript +import { Client } from "sql-fs-sdk"; + +const client = new Client({ + baseUrl: process.env.BASE_URL!, + authSecret: process.env.AUTH_SECRET!, + sub: "smoke", +}); + +const sb = await client.sandboxes.create({ name: "smoke-test" }); +try { + const result = await sb.exec("echo hello && uname -srm"); + if (!result.ok) throw new Error(`unexpected exit ${result.exitCode}: ${result.error}`); + console.log(result.stdout); +} finally { + await client.sandboxes.delete(sb.id); + client.close(); +} +``` + +--- + +## Patterns to bake in + +### Always `client.close()` in a `finally` + +`Client.close()` releases the transport. There's no context-manager equivalent +in TS — wrap the whole flow in `try/finally`: + +```typescript +const client = new Client({ ... }); +try { + // ... work ... +} finally { + client.close(); +} +``` + +### Always use `try/finally` around sandbox lifetime + +Sandboxes are durable — they survive process exit and accumulate Postgres rows +(and storage) until explicitly deleted. + +```typescript +const sb = await client.sandboxes.create({ name: "..." }); +try { + // ... do work ... +} finally { + await client.sandboxes.delete(sb.id); // or: await sb.delete() +} +``` + +### Re-using an existing sandbox by id + +```typescript +const sb = client.sandboxes.attach("550e8400-e29b-41d4-a716-446655440000"); +// .attach() does NOT hit the network — call .get(id) first if you need to verify. +const info = await client.sandboxes.get(sb.id); +console.log(info.lastUsedAt); +``` + +--- + +## Multi-tenant deployments + +Pass `tenant` alongside `authSecret` to mint a tenant-scoped JWT: + +```typescript +const client = new Client({ + baseUrl: process.env.BASE_URL!, + authSecret: process.env.AUTH_SECRET!, + sub: "agent-1", + tenant: "tenant-a", +}); +``` + +For single-tenant deployments (most common), omit `tenant` entirely — the +server falls back to the default tenant. + +--- + +## Common setup mistakes + +| Symptom | Cause | Fix | +|---|---|---| +| `Error: Provide one of: token, authSecret, or adminSecret` | Constructor called with no credentials | Pass exactly one of the three | +| `Error: 'sub' is required when bootstrapping a token from a secret` | `authSecret` without `sub` | Add `sub: "..."` | +| `AuthError` with `status === 401`, `code === "AUTH_INVALID"` | JWT expired, or `AUTH_SECRET` mismatch | Re-mint with correct secret | +| `AuthError` with `status === 403`, `code === "FORBIDDEN"` | Accessing a sandbox owned by a different `sub` | Use the `sub` that created the sandbox, or attach via that token | +| `RateLimitError` with `retryAfter` on bootstrap | `/v1/auth/bootstrap` rate limit (default 5 / 60s per IP) | Wait `retryAfter` seconds; or pass a pre-minted `token` instead of bootstrapping each run | +| `TransportError: network error after N attempts` | Wrong `baseUrl`, DNS failure, or server down | Check `curl -fsS $BASE_URL/healthz` first | + +--- + +## Verifying the deployment + +```typescript +const r = await fetch(`${process.env.BASE_URL}/healthz`); +if (!r.ok) throw new Error(`health check failed: ${r.status}`); +console.log(await r.json()); // → { status: "ok" } +``` + +The SDK doesn't expose `/healthz` (it's not under `/v1`). Use global `fetch` +directly when you just need to check liveness without auth. diff --git a/plugins/sql-fs/skills/ts-sdk/SKILL.md b/plugins/sql-fs/skills/ts-sdk/SKILL.md new file mode 100644 index 0000000..7a1e32e --- /dev/null +++ b/plugins/sql-fs/skills/ts-sdk/SKILL.md @@ -0,0 +1,228 @@ +--- +name: ts-sdk +description: "Expert operator of the SQL-FS TypeScript SDK (`sql-fs-sdk` package). Use when interacting with a live SQL-FS deployment from TypeScript/JavaScript: auth bootstrap, creating sandboxes, ingesting code, executing bash, streaming output, batching exploration. Triggers on: sqlfs typescript, sql-fs-sdk, ts sandbox, ingestFiles, sqlfs Client ts, sb.exec ts, execBatch." +user-invocable: true +--- + +You are an expert operator of the **SQL-FS TypeScript SDK** — the `sql-fs-sdk` +package that wraps the SQL-FS HTTP API in idiomatic TypeScript (`Client`, +`Sandbox`, `ExecResult`, typed errors, async SSE streaming). +Your job is to help the user write working, copy-pasteable TypeScript that talks +to a live SQL-FS deployment. + +## How to use this skill + +Invoke with optional sub-commands: + +| Invocation | What happens | +|---|---| +| `/sql-fs:ts-sdk` | General assistant — answer questions, generate snippets | +| `/sql-fs:ts-sdk setup` | Walk through install, env vars, first sandbox | +| `/sql-fs:ts-sdk exec