@coder/ai-sdk-sandbox 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,22 +10,23 @@ It implements the `HarnessV1SandboxProvider` contract from `@ai-sdk/harness`, so
10
10
  you pass it as the `sandbox` to a `HarnessAgent` exactly like
11
11
  `@ai-sdk/sandbox-vercel`.
12
12
 
13
- > **Status:** experimental. The AI SDK harness packages are published under the
14
- > `@canary` tag and their APIs can change between releases. This provider tracks
15
- > `@ai-sdk/harness@1.0.0-canary.11`.
13
+ > **Status:** experimental. This provider tracks the stable AI SDK v7 harness
14
+ > packages (`@ai-sdk/harness@^1.0.23`).
16
15
 
17
16
  ## Install
18
17
 
19
18
  ```bash
20
- npm add @coder/ai-sdk-sandbox @ai-sdk/harness@canary @ai-sdk/harness-claude-code@canary @ai-sdk/provider-utils@canary
19
+ npm add @coder/ai-sdk-sandbox @ai-sdk/harness @ai-sdk/harness-claude-code @ai-sdk/provider-utils
21
20
  ```
22
21
 
23
- On the host you also need:
22
+ Choose one host transport:
24
23
 
25
- - the [`coder` CLI](https://coder.com/docs/install) on PATH and authenticated
26
- (`coder login`); for non-ambient auth, configure the transport explicitly with
27
- `new CoderCliTransport({ url, token })` (see [Settings](#settings));
28
- - an **OpenSSH client** (`ssh`) on PATH — exec runs through it (see "How it works").
24
+ - `CoderNativeTransport` connects directly to Coderd and requires no `coder` or
25
+ `ssh` binary on the host. Pass a deployment URL and token (see
26
+ [Native transport](#native-transport)).
27
+ - The default `CoderCliTransport` requires the
28
+ [`coder` CLI](https://coder.com/docs/install), an authenticated `coder login`,
29
+ and an OpenSSH client (`ssh`) on PATH.
29
30
 
30
31
  ## Quick start
31
32
 
@@ -56,6 +57,33 @@ try {
56
57
 
57
58
  See [`examples/claude-code.ts`](./examples/claude-code.ts) for a runnable version.
58
59
 
60
+ ### Native transport
61
+
62
+ Use `CoderNativeTransport` when the host should not depend on the Coder CLI or
63
+ OpenSSH:
64
+
65
+ ```ts
66
+ import { CoderNativeTransport, createCoderWorkspace } from "@coder/ai-sdk-sandbox";
67
+
68
+ const transport = new CoderNativeTransport({
69
+ url: process.env.CODER_URL!,
70
+ token: process.env.CODER_SESSION_TOKEN!,
71
+ });
72
+
73
+ const sandbox = createCoderWorkspace({
74
+ workspace: "my-dev-workspace",
75
+ transport,
76
+ });
77
+
78
+ // When the application shuts down, close cached relay WebSockets:
79
+ await transport.close();
80
+ ```
81
+
82
+ The constructor falls back to `CODER_URL` and `CODER_SESSION_TOKEN`, so
83
+ `new CoderNativeTransport()` is sufficient when both are set. The token is sent
84
+ only to Coderd in the `Coder-Session-Token` header; it is never copied into the
85
+ workspace.
86
+
59
87
  ## Creating workspaces on demand
60
88
 
61
89
  Instead of pointing at an existing workspace, you can have the provider **create
@@ -97,12 +125,13 @@ createCoderWorkspace({
97
125
 
98
126
  **Parameters vs. presets.** A preset's parameter values take precedence over an
99
127
  overlapping `parameters` entry of the same name (this is Coder's behavior), so
100
- set a given value via the preset _or_ `parameters`, not both. Required
101
- parameters (those without a template default) must be supplied via `parameters`,
102
- `parameterFile`, a `preset`, or `useParameterDefaults` otherwise creation
103
- fails (it can't prompt non-interactively). If you set a `preset`, the provider
104
- preflight-validates the name against the template's presets and fails fast with
105
- the available names (set `validate: false` to skip).
128
+ set a given value via the preset _or_ `parameters`, not both. Every unset
129
+ non-ephemeral parameter must be supplied via `parameters`, `parameterFile`, or a
130
+ `preset`, unless `useParameterDefaults` accepts its template default. Parameters
131
+ marked required have no usable default and must always be supplied; otherwise
132
+ creation fails because it cannot prompt non-interactively. If you set a `preset`,
133
+ the provider preflight-validates the name against the template's presets and
134
+ fails fast with the available names (set `validate: false` to skip).
106
135
 
107
136
  ### Create settings
108
137
 
@@ -172,7 +201,7 @@ For an interactive chat in your terminal instead of one-shot `generate()` calls,
172
201
  wrap the same agent with the AI SDK terminal UI ([`@ai-sdk/tui`](https://ai-sdk.dev/v7/docs/ai-sdk-harnesses/terminal-ui)):
173
202
 
174
203
  ```bash
175
- npm add @ai-sdk/tui@canary
204
+ npm add @ai-sdk/tui
176
205
  ```
177
206
 
178
207
  The TUI drives a session-less agent, so adapt the `HarnessAgent` (whose
@@ -234,7 +263,11 @@ Because the bridge runs inside the workspace, the workspace image must have:
234
263
  [Creating workspaces on demand](#creating-workspaces-on-demand)).
235
264
 
236
265
  ```ts
237
- import { createCoderWorkspace, CoderCliTransport } from "@coder/ai-sdk-sandbox";
266
+ import {
267
+ createCoderWorkspace,
268
+ CoderCliTransport,
269
+ CoderNativeTransport,
270
+ } from "@coder/ai-sdk-sandbox";
238
271
 
239
272
  createCoderWorkspace({
240
273
  // One of these is required (TypeScript enforces it):
@@ -255,6 +288,12 @@ createCoderWorkspace({
255
288
  // url: process.env.CODER_URL, token: process.env.CODER_SESSION_TOKEN,
256
289
  // env: {}, loginShell: true, waitMode: 'no',
257
290
  }),
291
+
292
+ // Or connect directly to Coderd with no host CLI/OpenSSH dependency:
293
+ // transport: new CoderNativeTransport({
294
+ // url: process.env.CODER_URL,
295
+ // token: process.env.CODER_SESSION_TOKEN,
296
+ // }),
258
297
  });
259
298
  ```
260
299
 
@@ -277,10 +316,11 @@ createCoderWorkspace({
277
316
 
278
317
  The adapter binds its bridge to a port and resolves it from
279
318
  `createClaudeCode({ port })` or, by default, `sandbox.ports[0]`. Expose that port
280
- via `ports` (default `[4000]`); `getPortUrl` opens an OpenSSH `-L` local forward
281
- (over the same `coder ssh --stdio` ProxyCommand) to it on demand and returns a
282
- loopback `ws://` URL. The forward is plaintext on loopback, so `https`/`wss`
283
- requests resolve to their `http`/`ws` loopback equivalent.
319
+ via `ports` (default `[4000]`); `getPortUrl` asks the configured transport for a
320
+ local TCP forward and returns a loopback `ws://` URL. The CLI transport uses
321
+ OpenSSH `-L`; the native transport multiplexes TCP over its Coderd WebSocket.
322
+ The forward is plaintext on loopback, so `https`/`wss` requests resolve to their
323
+ `http`/`ws` loopback equivalent.
284
324
 
285
325
  ## How it works
286
326
 
@@ -291,14 +331,13 @@ bridge runs the vendor SDK in-workspace and streams events back to the host.
291
331
 
292
332
  This provider maps that contract onto Coder primitives:
293
333
 
294
- | Harness contract | Coder implementation |
295
- | ------------------------------------------- | ------------------------------------------------------------------------------------------- |
296
- | `run` / `spawn` | OpenSSH `bash -lc '…'` over a `coder ssh --stdio` ProxyCommand |
297
- | `readFile` / `writeFile` / `read*`/`write*` | base64 piped over the SSH connection (binary-safe) |
298
- | `getPortUrl({ port, protocol })` | OpenSSH `-L <local>:127.0.0.1:<port>` over the same ProxyCommand → `ws://127.0.0.1:<local>` |
299
- | `ports` / `setPorts` | the workspace's exposed port set |
300
- | `createSession` / `resumeSession` / `id` | attach to a workspace by name |
301
- | `stop` / `destroy` | `coder stop` / `coder delete` (only when it owns the lifecycle) |
334
+ | Harness contract | CLI transport | Native transport |
335
+ | ------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------- |
336
+ | `run` / `spawn` | OpenSSH over `coder ssh --stdio` | versioned process relay over Coderd's agent PTY WebSocket |
337
+ | `readFile` / `writeFile` / `read*`/`write*` | base64 over SSH | base64 over the native process relay |
338
+ | `getPortUrl({ port, protocol })` | OpenSSH `-L` | multiplexed TCP channels over the relay |
339
+ | `createSession` / `resumeSession` / `id` | CLI workspace lookup | Coderd v2 REST API |
340
+ | `stop` / `destroy` | `coder stop` / `coder delete` when lifecycle-owned | Coderd workspace-build transitions |
302
341
 
303
342
  **Why OpenSSH and not `coder ssh <ws> -- cmd`?** `coder ssh` allocates a PTY for
304
343
  the command, which rewrites newlines to CRLF, merges stdout and stderr onto one
@@ -309,6 +348,15 @@ provider does the programmatic equivalent, running real OpenSSH over a
309
348
  `coder ssh --stdio` ProxyCommand. That yields clean, separated streams and
310
349
  correct exit codes (verified against a live workspace).
311
350
 
351
+ **How the native relay stays byte-clean.** Coderd's browser-terminal endpoint
352
+ is a PTY, which by itself merges stdout/stderr and has no process exit-code
353
+ channel. The native transport uses it only as a carrier: it bootstraps a small,
354
+ dependency-free Node relay, switches the PTY to raw/no-echo mode, and exchanges
355
+ versioned newline-delimited frames with base64 byte payloads. The relay launches
356
+ commands with separate pipes and also opens TCP sockets for `getPortUrl`. It
357
+ does not bind a workspace port or persist credentials/files; one relay is cached
358
+ per selected workspace agent and `transport.close()` tears it down.
359
+
312
360
  The WebSocket the harness opens against `getPortUrl(...)` is the critical path,
313
361
  and it needs no wildcard access URLs — the host running `HarnessAgent` is already
314
362
  a Coder client. We forward via OpenSSH `-L` rather than `coder port-forward`:
@@ -327,6 +375,11 @@ and a full Claude Code turn with tool use (`scripts/e2e-claude.ts`).
327
375
  workspace per session rather than leasing ports from a shared sandbox.
328
376
  - File reads buffer the whole file (binary content moves as base64). Fine for
329
377
  bootstrap-sized files; not intended for streaming very large files.
378
+ - `CoderNativeTransport` currently targets POSIX workspaces with `bash`, `stty`,
379
+ `base64`, and Node.js. Its default relay executable is `node`; override
380
+ `relayNodeCommand` when Node lives at a fixed nonstandard path.
381
+ - A workspace with multiple agents must be selected as `workspace.agent`; the
382
+ native transport refuses to guess.
330
383
  - `@ai-sdk/sandbox-just-bash` cannot expose ports and is rejected by bridge-backed
331
384
  adapters — this provider exists precisely to provide that port.
332
385
  - To run Claude Code / Codex, the **workspace** image needs Node.js (the adapter
@@ -337,7 +390,7 @@ and a full Claude Code turn with tool use (`scripts/e2e-claude.ts`).
337
390
 
338
391
  ```bash
339
392
  npm install
340
- npm run typecheck # tsc against the real canary harness types
393
+ npm run typecheck # tsc against the real harness types
341
394
  npm test # vitest: unit + local integration (fake `coder` + `ssh`)
342
395
  npm run build # tsup → dist/ (ESM + d.ts)
343
396
 
@@ -349,6 +402,12 @@ npm run check # biome check . (format + lint, read-only; for CI
349
402
  # End-to-end against a real workspace (needs the coder CLI + a running workspace):
350
403
  npm run verify:real -- my-ws
351
404
 
405
+ # The same contract through Coderd directly. The CLI is used only to retrieve
406
+ # the already-authenticated token for this shell; CoderNativeTransport never invokes it:
407
+ CODER_URL=https://coder.example.com \
408
+ CODER_SESSION_TOKEN="$(coder login token)" \
409
+ npm run verify:native -- my-ws
410
+
352
411
  # End-to-end of create mode (creates a throwaway workspace, then deletes it):
353
412
  npm run verify:create -- docker
354
413
  ```
package/dist/index.d.ts CHANGED
@@ -1,12 +1,11 @@
1
- import { HarnessV1SandboxProvider, HarnessV1NetworkSandboxSession } from '@ai-sdk/harness';
1
+ import { HarnessV1SandboxProvider, HarnessV1NetworkSandboxSession, HarnessV1PortEndpoint } from '@ai-sdk/harness';
2
2
  import { Experimental_SandboxSession } from '@ai-sdk/provider-utils';
3
3
 
4
4
  /**
5
5
  * Transport abstraction over a Coder workspace. The sandbox session talks to a
6
6
  * workspace exclusively through this interface, which keeps the harness-facing
7
7
  * session decoupled from *how* we reach Coder (the default is the `coder` CLI;
8
- * tests inject a mock, and a future implementation could use the Coder REST API
9
- * or a persistent SSH/SFTP connection).
8
+ * {@link CoderNativeTransport} uses Coderd directly, and tests inject mocks).
10
9
  */
11
10
  interface CoderTransport {
12
11
  /** Run a command to completion, buffering stdout/stderr into strings. */
@@ -227,6 +226,62 @@ declare class CoderCliTransport implements CoderTransport {
227
226
  listPresets(options: ListPresetsOptions): Promise<PresetInfo[]>;
228
227
  }
229
228
 
229
+ interface CoderNativeTransportOptions {
230
+ /** Coder deployment URL. Defaults to `CODER_URL`. */
231
+ url?: string;
232
+ /** Coder session/API token. Defaults to `CODER_SESSION_TOKEN`. */
233
+ token?: string;
234
+ /** Custom fetch implementation, primarily for tests or custom HTTP agents. */
235
+ fetch?: typeof globalThis.fetch;
236
+ /** Additional headers sent to Coderd over both HTTP and WebSocket. */
237
+ headers?: Record<string, string>;
238
+ /** Poll interval while waiting for provisioner builds. Default: 1000ms. */
239
+ buildPollIntervalMs?: number;
240
+ /** Maximum wait for one provisioner build. Default: 30 minutes. */
241
+ buildTimeoutMs?: number;
242
+ /** Maximum wait for the workspace relay handshake. Default: 30000ms. */
243
+ relayConnectTimeoutMs?: number;
244
+ /** Node executable used inside the workspace for the relay. Default: `node`. */
245
+ relayNodeCommand?: string;
246
+ /** Run commands through `bash -lc` instead of `bash -c`. Default: true. */
247
+ loginShell?: boolean;
248
+ }
249
+ /**
250
+ * Native Coder transport: Coderd's REST API supplies the control plane and an
251
+ * authenticated workspace-agent PTY carries a small multiplexed process/TCP
252
+ * relay. No local `coder` or `ssh` binary is launched.
253
+ */
254
+ declare class CoderNativeTransport implements CoderTransport {
255
+ #private;
256
+ constructor(options?: CoderNativeTransportOptions);
257
+ exec(options: TransportExecOptions): Promise<ExecResult>;
258
+ spawn(options: TransportExecOptions): SpawnedProcess;
259
+ forwardPort(options: ForwardPortOptions): Promise<PortForward>;
260
+ start(workspace: string, options?: LifecycleOptions): Promise<void>;
261
+ stop(workspace: string, options?: LifecycleOptions): Promise<void>;
262
+ destroy(workspace: string, options?: LifecycleOptions): Promise<void>;
263
+ status(workspace: string, options?: LifecycleOptions): Promise<WorkspaceStatus | null>;
264
+ create(options: CreateWorkspaceOptions): Promise<void>;
265
+ listPresets(options: ListPresetsOptions): Promise<PresetInfo[]>;
266
+ /** Close every cached workspace relay. Existing local port-forwards close too. */
267
+ close(): Promise<void>;
268
+ }
269
+
270
+ /** Error returned for a non-success response from Coderd's v2 API. */
271
+ declare class CoderNativeApiError extends Error {
272
+ readonly status: number;
273
+ readonly method: string;
274
+ readonly path: string;
275
+ readonly detail?: string;
276
+ constructor(options: {
277
+ status: number;
278
+ method: string;
279
+ path: string;
280
+ message: string;
281
+ detail?: string;
282
+ });
283
+ }
284
+
230
285
  /** Stable provider id reported on the {@link HarnessV1SandboxProvider}. */
231
286
  declare const CODER_WORKSPACE_PROVIDER_ID = "coder-workspace";
232
287
  /**
@@ -474,11 +529,10 @@ interface CoderWorkspaceSessionConfig {
474
529
  /**
475
530
  * A {@link HarnessV1NetworkSandboxSession} backed by a Coder workspace.
476
531
  *
477
- * Exec maps to `coder ssh`, file I/O to base64-over-`coder ssh`, and
478
- * `getPortUrl` to an OpenSSH `-L` local forward over a `coder ssh --stdio`
479
- * ProxyCommand, exposed as a local `ws://127.0.0.1:<port>` URL which is what
480
- * bridge-backed harness adapters (Claude Code, Codex) open their WebSocket
481
- * against.
532
+ * The configured {@link CoderTransport} supplies process execution, lifecycle,
533
+ * and TCP forwarding. `getPortEndpoint` exposes a forwarded port as a local
534
+ * `ws://127.0.0.1:<port>` URL, which is what bridge-backed harness adapters
535
+ * (Claude Code, Codex) open their WebSocket against.
482
536
  */
483
537
  declare class CoderWorkspaceSession implements HarnessV1NetworkSandboxSession {
484
538
  #private;
@@ -495,6 +549,11 @@ declare class CoderWorkspaceSession implements HarnessV1NetworkSandboxSession {
495
549
  readonly writeFile: (options: WriteFileOptions<ReadableStream<Uint8Array>>) => Promise<void>;
496
550
  readonly writeBinaryFile: (options: WriteFileOptions<Uint8Array>) => Promise<void>;
497
551
  readonly writeTextFile: (options: WriteTextFileOptions) => Promise<void>;
552
+ readonly getPortEndpoint: (options: {
553
+ port: number;
554
+ protocol?: "http" | "https" | "ws";
555
+ }) => Promise<HarnessV1PortEndpoint>;
556
+ /** @deprecated Kept for the `HarnessV1NetworkSandboxSession` contract; use `getPortEndpoint`. */
498
557
  readonly getPortUrl: (options: {
499
558
  port: number;
500
559
  protocol?: "http" | "https" | "ws";
@@ -508,4 +567,4 @@ declare class CoderWorkspaceSession implements HarnessV1NetworkSandboxSession {
508
567
  readonly restricted: () => Experimental_SandboxSession;
509
568
  }
510
569
 
511
- export { CODER_WORKSPACE_PROVIDER_ID, CoderCliTransport, type CoderCliTransportOptions, type CoderCreateSettings, type CoderTransport, type CoderWorkspaceBaseSettings, type CoderWorkspaceRef, CoderWorkspaceSession, type CoderWorkspaceSessionConfig, type CoderWorkspaceSettings, type CreateWorkspaceOptions, type EnsureCoderWorkspaceSettings, type EnsuredCoderWorkspace, type ExecResult, type ForwardPortOptions, type LifecycleOptions, type ListPresetsOptions, type PortForward, type PresetInfo, type SpawnedProcess, type TransportExecOptions, type WorkspaceAgentInfo, type WorkspaceAgentLifecycle, type WorkspaceAgentStatus, type WorkspaceBuildStatus, type WorkspaceStatus, createCoderWorkspace, ensureCoderWorkspace };
570
+ export { CODER_WORKSPACE_PROVIDER_ID, CoderCliTransport, type CoderCliTransportOptions, type CoderCreateSettings, CoderNativeApiError, CoderNativeTransport, type CoderNativeTransportOptions, type CoderTransport, type CoderWorkspaceBaseSettings, type CoderWorkspaceRef, CoderWorkspaceSession, type CoderWorkspaceSessionConfig, type CoderWorkspaceSettings, type CreateWorkspaceOptions, type EnsureCoderWorkspaceSettings, type EnsuredCoderWorkspace, type ExecResult, type ForwardPortOptions, type LifecycleOptions, type ListPresetsOptions, type PortForward, type PresetInfo, type SpawnedProcess, type TransportExecOptions, type WorkspaceAgentInfo, type WorkspaceAgentLifecycle, type WorkspaceAgentStatus, type WorkspaceBuildStatus, type WorkspaceStatus, createCoderWorkspace, ensureCoderWorkspace };