Skip to content

Add Sandbox API support - #67

Open
mmilutinovic371 wants to merge 4 commits into
mainfrom
add-sandboxes-support
Open

Add Sandbox API support#67
mmilutinovic371 wants to merge 4 commits into
mainfrom
add-sandboxes-support

Conversation

@mmilutinovic371

Copy link
Copy Markdown

Summary

  • Adds isolated microVM sandboxes to the JS SDK: create/exec/fs read-write/list/catalog/lifecycle (start/stop/terminate), ported from deepinfra-python's Sandbox API and adapted to idiomatic TypeScript/Promises (no sync/async duality, snake_case DTOs matching the wire format, camelCase accessors elsewhere).
  • New unified DeepInfraClient (GET/POST/PUT/DELETE, retry/backoff, NDJSON exec streaming, binary-safe fs reads) and a typed error hierarchy (AuthenticationError, NotFoundError, ConflictError, RateLimitError/TooManySandboxesError, SandboxTimeoutError, CommandFailedError, etc.), all newly and publicly exported.
  • The pre-existing per-model inference client is untouched behaviorally — only renamed internally (DeepInfraClientLegacyModelClient) to free up the name; it was never part of the public API (src/index.ts never re-exported it), so this isn't a breaking change.
  • New README "Sandboxes" section and a runnable examples/sandbox-quickstart.ts.
  • File layout follows the existing repo convention (one class/interface per file, domain-nested folders, explicit-named-export barrels) rather than mirroring Python's module-per-concern layout.

Test plan

  • npm run build (tsc + tsc-alias) — passes
  • npx jest — 58/58 tests pass across 16 suites, no regressions to existing inference-wrapper tests
  • prettier --check — clean
  • 52 scenarios run against the real production API across 3 rounds (happy path, adversarial edge cases, and a follow-up round), 0 SDK bugs found — covers full lifecycle, exec (incl. timeouts, unicode, 200KB+ output, shell-metacharacter safety), fs (incl. 20MB binary byte-for-byte round-trip, nonexistent files, directories), concurrent exec/create, tag filtering (single- and multi-key), disk persistence across stop/start, and every documented error type reachable client-side (401/404/409/429 rate-limit cap/timeout errors) — verified via throwaway scripts, not committed
  • Account swept clean after every round (no leftover billed sandboxes)
  • Found and fixed one real docs bug along the way: /work/... paths (copied from Python's README) are rejected by the live API, which requires /workspace/... — fixed in this repo's README/example

🤖 Generated with Claude Code

mmilutinovic371 and others added 2 commits August 27, 2026 13:03
Adds isolated microVM sandboxes (create/exec/fs/list/catalog/lifecycle),
ported from deepinfra-python's Sandbox API: a new unified DeepInfraClient
(GET/POST/PUT/DELETE, retries, NDJSON streaming) backing a typed Sandbox
class, plus a matching error hierarchy. Includes unit tests, a runnable
example, and a new README section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Missed these when fixing the same issue in README/example — the doc
comment on Sandbox.exec() and the fs test fixtures still referenced
the invalid /work path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread src/lib/sandbox/streaming.ts Outdated
): AsyncGenerator<NdjsonEvent> {
let buffer = "";
for await (const chunk of stream) {
buffer += chunk.toString("utf8");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

chunk.toString('utf8') decodes every chunk on its own, so a multi-byte char split across two chunks comes out as replacement chars. reproduced by splitting {"stdout":"héllo"} inside the é, stdout is "h��llo". the prod unicode runs passed only because the chunk boundary never landed inside a char. use string_decoder: const decoder = new StringDecoder('utf8'), buffer += decoder.write(chunk) in the loop and buffer += decoder.end() after it.

const response = await this._client.stream(
this.execSpec(command, options.timeout),
);
return foldExecEvents(iterNdjson(response.data));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

stream() maps transport errors only up to the response headers, the body iteration here runs outside any mapping. a connection that stalls mid-stream does get aborted by the axios idle timeout, but the caller sees a bare Error('aborted') instead of APITimeoutError (reproduced with a local server that writes one line and hangs). wrap this in try/catch and map like mapTransportError does, 'aborted' / ECONNABORTED to APITimeoutError, everything else to APIConnectionError.

Comment thread src/lib/sandbox/sandbox.ts Outdated
timeout: Duration | undefined,
): RequestSpec {
const timeoutSeconds = timeout === undefined ? 0 : parseDuration(timeout);
// Read timeout outlives the server-side command timeout so the server's

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this reads as a total read timeout, but the axios timeout on a stream response is an idle timeout on the socket, it resets on every chunk. so it is not a cap on exec duration, a command that keeps printing stays alive until the server kills it. the value is fine, just say idle timeout in the comment so nobody relies on it as a hard limit.

Comment thread src/lib/sandbox/sandbox-options.ts Outdated
}

export interface CreateOptions extends WaitOptions {
image?: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm guessing you used the 0.2.0 python SDK version as an example for Claude, but you missed that in 0.3.0 we removed the user supplied image id :)

Comment thread src/lib/http/deepinfra-client.ts Outdated
headers,
params: spec.params,
data: spec.json === undefined ? spec.content : spec.json,
timeout: spec.timeout === undefined ? this.timeout : spec.timeout * 1000,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

units are mixed in the same class: the constructor timeout is ms, RequestSpec.timeout is seconds, Duration is seconds. the python client is seconds everywhere. make the constructor seconds too (DEFAULT_TIMEOUT = 60) and convert once here.

Comment thread src/lib/sandbox/sandbox-info.ts Outdated
sandbox_id: string;
plan: string;
image: string;
state: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

type this as 'running' | 'stopped' | 'failed' | 'deleted' | (string & {}) and reuse it for TERMINAL_STATES and the wait targets in sandbox.ts, a typo in a state string then fails to compile instead of waiting 300s for a state that never comes.

}
}

async terminate(): Promise<void> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

consider adding Symbol.asyncDispose that calls terminate() and swallows NotFoundError, same as the python exit. gives ts 5.2+ users await using sb = await Sandbox.create(...) in place of the try/finally from the readme.

Comment thread README.MD
// Large scripts: upload, then run
await sb.fs.write(
"/workspace/script.py",
await fs.promises.readFile("script.py", "utf8"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

fs is not imported in this snippet. please copy and paste every readme snippet into a file and run it, same as we did for the docs.

Comment thread README.MD
const out = await sb.runPython("print(21 * 2)");
out.check(); // throws CommandFailedError on a non-zero exit code

await sb.fs.write("/workspace/in.csv", "a,b\n1,2\n");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

port the paragraph from the python readme here: /workspace is the only path fs accepts and the only one that survives stop/start, everything else comes back from the base image so runtime pip installs are gone after a restart, and the idle timeout stops the sandbox with the same effect. that is exactly the /work bug you hit, worth stating.

Comment thread src/lib/http/deepinfra-client.ts Outdated
* and eventually the inference wrappers) funnels through this client.
*/
export class DeepInfraClient {
private apiKeyValue?: string;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is an ordinary enumerable property at runtime, ts private is compile time only, and Sandbox holds the client as an enumerable _client field. so console.log(sandbox) prints the api key in full (util.inspect reaches _client.apiKeyValue at depth 2), same for console.log(client) and JSON.stringify(client). reproduced against a local server with a fake key. users log the sandbox object all the time and pino/winston/sentry object serializers capture it the same way, the python sdk is safe only because repr is explicit.

keep the key in a real #apiKey private field (or a closure), add util.inspect.custom { return this.toString(); } on both DeepInfraClient and Sandbox with a toString like DeepInfraClient(baseUrl=...), and consider Object.defineProperty for _client so it is non-enumerable. then a regression test that util.inspect(sb) and JSON.stringify(client) do not contain the key.

the error objects are fine, console.log(err), inspect with depth null, JSON.stringify(err) and err.response.config.headers do not contain the key.

@ats3v

ats3v commented Aug 27, 2026

Copy link
Copy Markdown
Member

please bump the runtime deps in this PR, it is a package.json only change and it is the difference between npm install deepinfra printing a critical advisory on day one or not.

 "dependencies": {
-  "@swc/core": "^1.4.6",
-  "@swc/wasm": "^1.4.6",
-  "axios": "^1.6.7",
-  "form-data": "^4.0.0"
+  "axios": "^1.20.0",
+  "form-data": "^4.0.6"
 },

@swc/core and @swc/wasm are imported nowhere, they only pull a native binary onto every consumer.

I tried it on this branch: zero source changes, npm audit --omit=dev goes from 3 vulnerabilities (1 critical, 1 high, 1 moderate) to 0, build passes, jest is unchanged at 57/58 (the one failure is the pre-existing token-classification test, it does not clear DEEPINFRA_API_KEY and fails for anyone with the key exported). The repo uses a small stable slice of axios (create, get, isAxiosError, responseType stream/arraybuffer, validateStatus and the types), the only breaking change between 1.6 and 1.20 is the allowAbsoluteUrls default in 1.8 and it only matters with baseURL set on the instance, which the new client never does.

Also ran the sandbox flow against prod on axios 1.20.0, 15/15: catalog, create, list by tags, exec via the ndjson stream (560KB unicode output, server side timeout, rc 3 + check()), 1MB binary fs round-trip, 400/401/404/409 mapping including the stream error path, stop/start/terminate.

pnpm-lock.yaml is also committed and would go stale, delete it or regenerate it in the same commit. The rest of the cleanup (node 20/22/24 in ci, eslint vs prettier, build.config.js) can stay a follow-up, it does not affect what ships to users.

- Security: apiKeyValue is now a true #private field (was TS-private,
  i.e. enumerable at runtime), and DeepInfraClient/Sandbox get
  [util.inspect.custom]() so console.log/util.inspect/JSON.stringify
  never expose the API key. Also fixes a related circular reference
  (sandbox.fs.sandbox === sandbox) that broke JSON.stringify(sandbox)
  unconditionally, found while adding the regression test for this.
- Correctness: decode NDJSON chunks with StringDecoder instead of
  per-chunk toString('utf8'), so a multi-byte UTF-8 character split
  across two chunks decodes correctly instead of producing replacement
  characters; map stream-body errors (e.g. a stalled connection) to
  typed APITimeoutError/APIConnectionError instead of leaking a raw
  Error out of exec().
- API: DeepInfraClient's constructor timeout is now seconds everywhere
  (was silently milliseconds, inconsistent with RequestSpec/Duration);
  removed the create-time `image` parameter to match the current
  upstream Sandbox API (server already ignored it); exec()'s variadic
  signature is now properly overloaded so a misplaced options object no
  longer type-checks; SandboxInfo.state is a typed SandboxState instead
  of a bare string; added [Symbol.asyncDispose] for `await using`
  parity with Python's __exit__.
- API surface: src/index.ts no longer re-exports internal helpers
  (parseJsonBody, defaultClient, extractErrorMessage,
  exceptionFromResponse) that Python keeps private — only the classes
  Python's __all__ exposes are public now.
- Cleanup: removed the AuthenticationError constructor cast by giving
  it the same (message, opts) shape as the other error classes.
- Deps: bumped axios to ^1.20.0 and form-data to ^4.0.6 (fixes all
  `npm audit --omit=dev` findings, including a critical form-data
  advisory), dropped @swc/core and @swc/wasm (unused, native-binary
  weight for every consumer), removed the stale pnpm-lock.yaml (CI only
  ever ran npm; a second lockfile just goes stale silently).

All changes verified against the real API (52+ scenarios across prior
rounds, plus targeted re-verification of every fix here and every
README Sandboxes snippet run verbatim) in addition to the unit suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread src/lib/sandbox/index.ts Outdated
@@ -0,0 +1,11 @@
export { Sandbox } from "@/lib/sandbox/sandbox";
export { SandboxFS } from "@/lib/sandbox/sandbox-fs";
export type { SandboxInfo } from "@/lib/sandbox/sandbox-info";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

SandboxState is not exported. sandbox.state is typed as SandboxState in the shipped d.ts, but const s: SandboxState = sb.state in a consumer project fails with TS2305, no exported member. tried it against the packed tarball in a fresh project with strict tsc. add it here:

export type { SandboxInfo, SandboxState } from "@/lib/sandbox/sandbox-info";

It was used as Sandbox.state's return type in the shipped d.ts but
never re-exported, so a consumer writing `const s: SandboxState = ...`
hit TS2305. Verified by packing the tarball and type-checking against
it from a fresh project with strict tsc, same repro ats3v used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

2 participants