feat(sdk): TypeScript SDK scaffold — @arcbox/sandbox hello-world loop (CORE-58) - #545
Conversation
TypeScript SDK package at sdk/typescript (CORE-58 phase 1): standalone npm package, NodeNext + strict tsconfig, eslint (typed) + prettier + vitest gates, and the first hand-written module — connection resolution (explicit > env > default; ARCBOX_SOCKET / ARCBOX_API_URL / ARCBOX_API_KEY / ARCBOX_DATA_DIR) with unit tests.
Codegen pipeline (`npm run generate`): buf v2 config over the repo proto tree, filtered to the public arcbox.sandbox.v1 package (self-contained — only well-known-type imports). Output is committed under src/gen/ and is never exported from the package entry point; public shapes stay hand-written and are mapped at the transport boundary.
Transport: connect-es v2's own node transport reaches the daemon socket
first-class — createConnectTransport({ httpVersion: '1.1', nodeOptions:
{ socketPath } }) flows into Node's http.request(url, options), which
dials the Unix socket while the URL supplies only path + Host header
(placeholder baseUrl). No undici Agent needed; mechanism cited at the
call site. requestTimeoutMs is applied per unary call, never as the
transport default, so long-lived streams stay open.
Errors: the single transport-to-exception boundary (toArcBoxError) maps
the merged ErrorCode registry (ErrorInfo Connect detail) to a typed
hierarchy with code/suggestion/context/operation, falls back to the
coarse Connect code, and turns ENOENT/ECONNREFUSED — which connect-node
leaves as Unknown for the missing-socket shape — into
ConnectionFailedError with a daemon-start suggestion.
commands.run(): one method overloaded on background — foreground resolves a CommandResult with exit-as-data (signal death = 128+n + signal name; .expect() is the opt-in throw), background returns a CommandHandle with async-iterable output (replay-then-live, ends deterministically on exit), long-poll waitForExit (exempted from the unary deadline), and process-group kill. Execution ids are minted client-side so retries stay idempotent and lost responses stay addressable. argv is primary; a string is /bin/sh -lc sugar. files: bytes-first readBytes/readText and chunked streaming writeBytes/writeText over the WriteFile client stream, with the 256 MiB cap pre-checked as a typed FileTooLargeError.
ArcBox holds one resolved connection; Sandbox statics (create/connect/list) are sugar over a throwaway instance resolved from options/env. create() mints the sandbox id client-side and arms the Events subscription BEFORE Create (subscribe-then-act, the CORE-67 rule), with a post-create Inspect and keepalive re-inspects covering the subscription-registration window; waitUntilReady: false opts out. connect() resumes a PAUSED sandbox, waits out STARTING, and surfaces terminal states as typed SandboxStateError. The handle carries only the id plus commands/files namespaces — info() always fetches fresh, kill() is Remove(force), pause() checkpoints under the same id, and Symbol.asyncDispose kills so a leaked handle never leaks a VM. Dual lifecycle knobs (ttlMs hard cap; idleTimeoutMs + onIdle) surface in create options. Public DTOs are hand-written; src/gen never leaks through the entry point.
The closed loop against a live daemon socket, opt-in via ARCBOX_SDK_E2E=1 and skipped cleanly otherwise: create (built-in template) -> files write/read-back -> foreground run with expect() -> exit-as-data -> background run with streamed output, waitForExit, and process-group kill -> fresh info() -> kill. README covers purpose, install, the hello world, connection resolution, regen instructions, and flags the CI wiring as a follow-up (workflow edits are deliberately not on this branch).
tsconfig.build.json (src-only) compiled without the NodeJS globals the root config picked up transitively through vitest; pin types: [node].
|
Run failed. View the logs →
|
Greptile SummaryAdds the initial standalone TypeScript SDK for ArcBox sandboxes.
Confidence Score: 4/5The PR is not yet safe to merge because its public pause and paused-reconnect paths still invoke daemon endpoints that always reject as unimplemented. The current SDK exposes pause() and promises automatic resume when connecting to a paused sandbox, but both paths call daemon handlers that remain contract-only stubs, so callers cannot complete either lifecycle operation. Files Needing Attention: sdk/typescript/src/sandbox.ts; app/arcbox-api/src/connect/control.rs
|
| Filename | Overview |
|---|---|
| sdk/typescript/src/sandbox.ts | Implements sandbox creation, readiness, connection, cleanup, and lifecycle operations; Pause/Resume remain unavailable because their daemon handlers are still stubs. |
| sdk/typescript/src/commands.ts | Implements foreground and background execution, output streaming, replay-gap reporting, waiting, and process-group signaling. |
| sdk/typescript/src/transport.ts | Configures local Unix-socket and remote Connect transports with authentication and unary request deadlines. |
| sdk/typescript/src/errors.ts | Centralizes Connect and structured daemon error conversion into the SDK’s typed exception hierarchy. |
| sdk/typescript/src/files.ts | Implements bounded whole-file byte and text reads and writes. |
| tests/e2e/src/sdk_ts.rs | Adds the Rust-side harness for running the opt-in TypeScript SDK end-to-end test. |
Reviews (4): Last reviewed commit: "fix(sdk): report retained-output truncat..." | Re-trigger Greptile
|
Run failed. View the logs →
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: de9712aea4
ℹ️ 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".
Boots an isolated daemon (VZ, staged dev boot assets, readiness via WatchSetupStatus) and runs the TypeScript SDK's gated hello-world e2e against its socket: SKIP_BUILD=1 cargo test -p arcbox-e2e --test sdk_ts -- --ignored --nocapture.
Hardware validation: hello-world e2e against a real daemon (CORE-58 phase 1 acceptance)Ran Reproducible via the harness hook added in 6f20bd8: SKIP_BUILD=1 cargo test -p arcbox-e2e --test sdk_ts -- --ignored --nocaptureResult: 1/1 passed, zero SDK changes needed.
Assertions exercised, all green:
Daemon side: clean run — exit status 0 on shutdown, temp dir auto-removed. Only benign warnings observed (K8s proxy port 16443 already taken by the production daemon, tolerated by design; pre-VM sandbox-cleanup watch reconnects). |
|
Run failed. View the logs →
|
Bundles src (including src/gen) into a single ESM dist/index.js + index.d.ts; the exports map keeps one default condition since bunchee emits no dual format here. tsconfig.build.json is no longer needed — typecheck still runs off tsconfig.json.
Formatter is biome 2.x, formatter-only (linter disabled, organizeImports assist on), modeled on the house convention; linting is eslint-config-sukka with stylistic: false so biome owns style, plus the typescript import resolver and the sukka formatter. src/gen stays lint- and format-exempt. Two scoped rule adjustments, each with its rationale inline: npm files-array ordering is semantic, and the ArcBox <-> Sandbox pair is mutually recursive by design.
Mechanical output of biome format --write: double quotes, 80-column wrapping, no semantic changes.
- errors: every class stamps its literal name (minification-safe, replacing new.target.name); options forwarded to super() so cause rides natively; registry hoisted above its use; Ctor capitalized. - commands: CommandHandle precedes its user; CommandResult uses parameter properties; output assembly via Buffer.concat. - sandbox: statics take this: void; event-stream iterators built off a local instead of a newline-hazard computed access; promises marked handled with foxts noop (bundled, not a published dependency). - Deliberately sequential awaits (pagination, long-poll slices, in-order event consumption) carry reasoned no-await-in-loop disables.
The SDK sent mode: 0 when opts.mode was unset while documenting a 0o644 default — correct only because the guest handler treats 0 as 'use 0o644' (the filesystem.proto sentinel). Send the documented default explicitly and document the sentinel: a literal mode of 0 is not expressible on this wire.
|
Run failed. View the logs →
|
The daemon and CLI resolve the development profile's layout to ~/.arcbox-dev (HostLayout::resolve_for_profile_from_env); the SDK always dialed ~/.arcbox, so zero-config calls in the development profile reported the daemon unreachable. Mirror the daemon exactly: non-empty ARCBOX_DATA_DIR wins, then the trimmed case-insensitive profile (development|dev), unknown values fall back to production.
- create(): a failure after the daemon accepted Create (readiness failed, response lost) leaked the sandbox — ttlMs is optional, so potentially forever, and retries mint a fresh id. Best-effort force-remove of the client-minted id before rethrowing. - asyncDispose: suppress the whole NotFoundError family, not just SandboxNotFoundError — the daemon attaches no ErrorInfo detail yet (app/arcbox-api/src/error.rs maps to coarse Connect codes), so 'already gone' arrives as the generic class and disposal threw. - pause(): document that the daemon serves Pause/Resume as contract-only stubs until CORE-21; README Status matches.
- The daemon retains 8 MiB per output channel (CHANNEL_RETENTION, virt/arcbox-vm sandbox/execution.rs) and attach replays from the earliest retained byte, exposing the jump via chunk offsets. The collector ignored them, so a foreground command that outgrew retention reported a silently truncated stdout/stderr as complete. CommandResult now carries truncated, set when a chunk lands past the expected per-channel offset; docs state the retention window. - CommandHandle.kill() was the one unary RPC without unaryOptions, so requestTimeoutMs never bounded signal delivery; the handle now carries the client context and applies the deadline. Both paths regression-tested through the real waitForExit collector with a stubbed client.
|
Run failed. View the logs →
|

Phase 1 of CORE-58: the TypeScript SDK scaffold with the hello-world closed loop working end to end against the daemon's Connect surface on the Unix socket. Contract source is the merged
arcbox.sandbox.v1proto on master; where the design doc and proto disagreed, the proto won (dual-knob lifecyclettl+idle_timeout/on_idle, daemon-side auto-resume — the SDK ships nowithResume).Scope
@arcbox/sandboxatsdk/typescript— standalone npm package (no workspace), Node ≥ 22, TS strict + NodeNext, eslint (typed) + prettier + vitest configured before any code.npm run generate→ buf v2 (buf.gen.yaml) over../../rpc/arcbox-protocol/proto, filtered toarcbox/sandbox/v1(self-contained: only WKT imports), viaprotoc-gen-esv2 (connect-es v2 consumes its service descriptors — there is no separate connect plugin anymore). Output committed undersrc/gen/, never exported from the entry point; public shapes are hand-written DTOs mapped at the transport boundary.ArcBoxclient entry (one resolved connection) +Sandboxhandle — staticscreate/connect/list(sugar over a throwawayArcBox), instanceinfo/kill/pause/Symbol.asyncDispose;commands.run()overloaded onbackground(foregroundCommandResultwith exit-as-data incl..expect(); backgroundCommandHandlewith async-iterableoutput, long-pollwaitForExit, process-groupkill);filesreadBytes/readText/writeBytes/writeText (256 MiB cap pre-checked). Create arms the Events subscription before Create (client-minted sandbox id; CORE-67 subscribe-then-act), with post-create Inspect + keepalive re-inspects covering the subscription-registration window. TTL/idle knobs surface asttlMs/idleTimeoutMs+onIdle.toArcBoxError) driven by the mergedErrorCoderegistry (ErrorInfoConnect detail → typed class +code/suggestion/context/operation), falling back to the coarse Connect code; ENOENT/ECONNREFUSED (which connect-node leaves asUnknownfor the missing-socket shape) becomeConnectionFailedErrorwith a daemon-start suggestion.test/e2e.test.tsruns the hello-world loop against a live socket, opt-in viaARCBOX_SDK_E2E=1, skipped cleanly otherwise.Transport findings (the UDS hook)
No undici
Agentis needed.@connectrpc/connect-node's own HTTP/1.1 transport carries the hook first-class:NodeHttp1TransportOptions.nodeOptions("Options passed to the request() call of the Node.js built-in http or https module",node-transport-options.d.ts) is spread verbatim intohttp.request(url, { ...nodeOptions, headers, method })innode-universal-client.js(createNodeHttp1Client→h1Request), and Node core'shttp.RequestOptions.socketPathdials the UDS while the URL supplies only path + Host — verified empirically (server on a socket, request with URL +socketPath). HTTP/1.1 covers every RPC shape the surface uses: unary, server-streaming, and client-streaming (Node's http client streams request bodies, unlike fetch over h1); no bidi exists in the surface.requestTimeoutMsis applied per unary call rather than as the transportdefaultTimeoutMs, so Events/Attach streams and the WaitExecution long-poll are never killed by the unary deadline.Socket default verified against
arcbox-constantspaths.rs:<data_dir>/run/arcbox.sock, data dir$ARCBOX_DATA_DIRelse~/.arcbox; overridesARCBOX_SOCKET, remote-tier selection viaARCBOX_API_URL, bearer slotARCBOX_API_KEY(attached when set; unused locally).Deferred (phase 2+)
PTY,
ports.expose/unexpose,waitForPort/waitForLog, filesystem path verbs (Stat/ListDir/MakeDir/Remove/Move/WatchDir),Templatestatics,events()on the handle,setLifecycle,GetCapabilitieshandshake (ProtocolMismatchErrorclass exists, unwired), stdin (WriteStdin/StreamStdin), re-attach (commands.connect/list), transparent re-attach at offsets after stream death. The e2e file notes it is the design-doc hello world minus ports/waitForPort for that reason.CI wiring for
npm run lint/npm test/npm run typecheckis a follow-up (workflow edits deliberately not on this branch); the README carries the TODO.No Rust or proto files are touched.