Skip to content

feat(sdk): TypeScript SDK scaffold — @arcbox/sandbox hello-world loop (CORE-58) - #545

Merged
AprilNEA merged 16 commits into
masterfrom
feat/sdk-typescript
Aug 3, 2026
Merged

feat(sdk): TypeScript SDK scaffold — @arcbox/sandbox hello-world loop (CORE-58)#545
AprilNEA merged 16 commits into
masterfrom
feat/sdk-typescript

Conversation

@AprilNEA

@AprilNEA AprilNEA commented Aug 3, 2026

Copy link
Copy Markdown
Member

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.v1 proto on master; where the design doc and proto disagreed, the proto won (dual-knob lifecycle ttl + idle_timeout/on_idle, daemon-side auto-resume — the SDK ships no withResume).

Scope

  • Package: @arcbox/sandbox at sdk/typescript — standalone npm package (no workspace), Node ≥ 22, TS strict + NodeNext, eslint (typed) + prettier + vitest configured before any code.
  • Codegen: npm run generate → buf v2 (buf.gen.yaml) over ../../rpc/arcbox-protocol/proto, filtered to arcbox/sandbox/v1 (self-contained: only WKT imports), via protoc-gen-es v2 (connect-es v2 consumes its service descriptors — there is no separate connect plugin anymore). Output committed under src/gen/, never exported from the entry point; public shapes are hand-written DTOs mapped at the transport boundary.
  • Public surface: ArcBox client entry (one resolved connection) + Sandbox handle — statics create/connect/list (sugar over a throwaway ArcBox), instance info/kill/pause/Symbol.asyncDispose; commands.run() overloaded on background (foreground CommandResult with exit-as-data incl. .expect(); background CommandHandle with async-iterable output, long-poll waitForExit, process-group kill); files readBytes/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 as ttlMs / idleTimeoutMs + onIdle.
  • Errors: one transport→exception boundary (toArcBoxError) driven by the merged ErrorCode registry (ErrorInfo Connect detail → typed class + code/suggestion/context/operation), falling back to the coarse Connect code; ENOENT/ECONNREFUSED (which connect-node leaves as Unknown for the missing-socket shape) become ConnectionFailedError with a daemon-start suggestion.
  • Tests: unit tests for connection/env resolution, error mapping, and exit-status mapping (no daemon needed); test/e2e.test.ts runs the hello-world loop against a live socket, opt-in via ARCBOX_SDK_E2E=1, skipped cleanly otherwise.

Transport findings (the UDS hook)

No undici Agent is needed. @connectrpc/connect-node's own HTTP/1.1 transport carries the hook first-class:

createConnectTransport({
  httpVersion: '1.1',
  baseUrl: 'http://arcbox',            // Host header + request path only
  nodeOptions: { socketPath },          // the actual connection target
});

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 into http.request(url, { ...nodeOptions, headers, method }) in node-universal-client.js (createNodeHttp1Clienth1Request), and Node core's http.RequestOptions.socketPath dials 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. requestTimeoutMs is applied per unary call rather than as the transport defaultTimeoutMs, so Events/Attach streams and the WaitExecution long-poll are never killed by the unary deadline.

Socket default verified against arcbox-constants paths.rs: <data_dir>/run/arcbox.sock, data dir $ARCBOX_DATA_DIR else ~/.arcbox; overrides ARCBOX_SOCKET, remote-tier selection via ARCBOX_API_URL, bearer slot ARCBOX_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), Template statics, events() on the handle, setLifecycle, GetCapabilities handshake (ProtocolMismatchError class 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 typecheck is a follow-up (workflow edits deliberately not on this branch); the README carries the TODO.

No Rust or proto files are touched.

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].
@pullfrog

pullfrog Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown

CORE-58

@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown

Greptile Summary

Adds the initial standalone TypeScript SDK for ArcBox sandboxes.

  • Implements connection resolution, typed error mapping, lifecycle handles, command execution, and whole-file operations.
  • Generates and commits Connect/Protobuf service descriptors while keeping generated types out of the public entry point.
  • Adds unit, opt-in SDK end-to-end, and Rust harness coverage for the hello-world workflow.

Confidence Score: 4/5

The 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

Important Files Changed

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

Comment thread sdk/typescript/src/commands.ts Outdated
Comment thread sdk/typescript/src/sandbox.ts
Comment thread sdk/typescript/src/sandbox.ts
@pullfrog

pullfrog Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@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: 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".

Comment thread sdk/typescript/src/sandbox.ts Outdated
Comment thread sdk/typescript/src/errors.ts
Comment thread sdk/typescript/src/sandbox.ts
Comment thread sdk/typescript/src/connection.ts Outdated
Comment thread sdk/typescript/src/commands.ts Outdated
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.
@AprilNEA

AprilNEA commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Hardware validation: hello-world e2e against a real daemon (CORE-58 phase 1 acceptance)

Ran sdk/typescript/test/e2e.test.ts (ARCBOX_SDK_E2E=1) against a real, isolated daemon — fresh tempdir data dir, staged dev boot assets, freshly built musl arcbox-agent/vm-agent, readiness observed via WatchSetupStatus. Apple M5 Max, macOS 26.4, VZ backend (nested virt for the sandbox microVM).

Reproducible via the harness hook added in 6f20bd8:

SKIP_BUILD=1 cargo test -p arcbox-e2e --test sdk_ts -- --ignored --nocapture

Result: 1/1 passed, zero SDK changes needed.

Phase Timing
daemon READY (DownloadingAssets → AssetsReady → VmStarting → VmReady → NetworkReady → READY) 19 s (≈12 s of that was a one-time 161 MB runtime-bundle download)
guest VM boot (VmStarting → VmReady) ~2.4 s
vitest run (whole hello-world loop incl. sandbox microVM boot) 3.64 s

Assertions exercised, all green:

  • Sandbox.create('') on the built-in busybox template, resolved via the armed events subscription
  • files.writeTextfiles.readText byte-exact round-trip
  • foreground commands.run(['/bin/cat', ...]) + expect() sugar
  • exit 3 surfaced as data (exitCode === 3), not an exception
  • background run: async-iterable output streamed to completion, waitForExit → code 0
  • kill('SIGKILL')signal === 'SIGKILL', exitCode === 137
  • info() fresh state, sandbox.kill() teardown

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).

@pullfrog

pullfrog Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

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.
@pullfrog

pullfrog Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

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.
@pullfrog

pullfrog Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog | Using Claude Opus𝕏

@AprilNEA
AprilNEA requested review from PeronGH and SukkaW August 3, 2026 16:29
@AprilNEA
AprilNEA merged commit b0d05cf into master Aug 3, 2026
7 of 8 checks passed
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