@coder/ai-sdk-sandbox 0.1.0 → 0.3.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,14 +10,13 @@ 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
22
  On the host you also need:
@@ -128,13 +127,51 @@ createCoderWorkspace({
128
127
  });
129
128
  ```
130
129
 
130
+ ## Provisioning a workspace without a session
131
+
132
+ `ensureCoderWorkspace(settings)` runs the same get-or-create → start-if-stopped
133
+ → wait-until-ready pipeline as create mode, but without creating a harness
134
+ sandbox session — use it to provision a workspace for **other tools** to bind
135
+ to. It takes an explicit `workspace` name (`[owner/]workspace`; there is no
136
+ `sessionId` to derive one from), an optional `create` block (same shape as
137
+ above; `namePrefix`/`owner` are unused), plus `readyTimeoutMs`, `transport`, and
138
+ `abortSignal`. A stopped workspace is always started. Without `create`, the
139
+ workspace must already exist.
140
+
141
+ It returns an `EnsuredCoderWorkspace`: the workspace's final (ready) status
142
+ snapshot plus `created` (whether this call created it) and — when the transport
143
+ reports one — `id`, the workspace UUID. That id is the handle other Coder
144
+ packages bind to. `@coder/ai-sdk-agent` is intentionally **not** a dependency of
145
+ this package; the two compose by a plain string handoff:
146
+
147
+ ```ts
148
+ import { ensureCoderWorkspace } from "@coder/ai-sdk-sandbox";
149
+ import { CoderAgent } from "@coder/ai-sdk-agent";
150
+
151
+ const ws = await ensureCoderWorkspace({
152
+ workspace: "agent-ws",
153
+ create: { template: "docker" },
154
+ });
155
+
156
+ const agent = new CoderAgent({
157
+ baseUrl: process.env.CODER_URL!,
158
+ token: process.env.CODER_SESSION_TOKEN!,
159
+ organizationId: "<org-uuid>",
160
+ workspaceId: ws.id!, // binds the chat's workspace-scoped tools
161
+ });
162
+ ```
163
+
164
+ The non-null assertion is safe on `coder` CLIs that emit `id` in
165
+ `coder list -o json`; old CLIs omit it, so guard
166
+ (`if (ws.id === undefined) throw …`) when you can't pin the CLI version.
167
+
131
168
  ## Terminal UI
132
169
 
133
170
  For an interactive chat in your terminal instead of one-shot `generate()` calls,
134
171
  wrap the same agent with the AI SDK terminal UI ([`@ai-sdk/tui`](https://ai-sdk.dev/v7/docs/ai-sdk-harnesses/terminal-ui)):
135
172
 
136
173
  ```bash
137
- npm add @ai-sdk/tui@canary
174
+ npm add @ai-sdk/tui
138
175
  ```
139
176
 
140
177
  The TUI drives a session-less agent, so adapt the `HarnessAgent` (whose
@@ -299,7 +336,7 @@ and a full Claude Code turn with tool use (`scripts/e2e-claude.ts`).
299
336
 
300
337
  ```bash
301
338
  npm install
302
- npm run typecheck # tsc against the real canary harness types
339
+ npm run typecheck # tsc against the real harness types
303
340
  npm test # vitest: unit + local integration (fake `coder` + `ssh`)
304
341
  npm run build # tsup → dist/ (ESM + d.ts)
305
342
 
package/dist/index.d.ts CHANGED
@@ -64,6 +64,11 @@ interface WorkspaceAgentInfo {
64
64
  lifecycleState: WorkspaceAgentLifecycle;
65
65
  }
66
66
  interface WorkspaceStatus {
67
+ /**
68
+ * Workspace UUID (top-level `id` in the Coder API). Optional: old `coder`
69
+ * CLIs or non-CLI transports may not report it, so never require it.
70
+ */
71
+ id?: string;
67
72
  /** Workspace name (without owner/agent qualifiers). */
68
73
  name: string;
69
74
  /** Workspace-level build status (`latest_build.status`). */
@@ -370,6 +375,65 @@ type CoderWorkspaceSettings = CoderWorkspaceBaseSettings & ({
370
375
  * ```
371
376
  */
372
377
  declare function createCoderWorkspace(settings: CoderWorkspaceSettings): HarnessV1SandboxProvider;
378
+ /**
379
+ * Settings for {@link ensureCoderWorkspace} — the provisioning subset of
380
+ * {@link createCoderWorkspace}'s settings. There is no harness session here, so
381
+ * the workspace name must be explicit (no `sessionId` to derive one from) and a
382
+ * stopped workspace is always started.
383
+ */
384
+ interface EnsureCoderWorkspaceSettings extends Pick<CoderWorkspaceBaseSettings, "readyTimeoutMs" | "transport"> {
385
+ /** The workspace to ensure, as `[owner/]workspace`. Required. */
386
+ workspace: string;
387
+ /**
388
+ * Create the workspace from a template when it doesn't exist; without it an
389
+ * existing workspace is required. `create.namePrefix` and `create.owner` are
390
+ * unused here (they only shape provider-derived names).
391
+ */
392
+ create?: CoderCreateSettings;
393
+ /** Aborts pending create/start/status calls and the readiness polling. */
394
+ abortSignal?: AbortSignal;
395
+ }
396
+ /**
397
+ * Result of {@link ensureCoderWorkspace}: the workspace's final (ready) status
398
+ * snapshot plus whether this call created it.
399
+ */
400
+ interface EnsuredCoderWorkspace extends WorkspaceStatus {
401
+ /** `true` when this call created the workspace (vs. reusing an existing one). */
402
+ created: boolean;
403
+ }
404
+ /**
405
+ * Ensure a Coder workspace exists, is started, and has a ready agent — without
406
+ * creating a harness sandbox session. Get-or-creates (when `create` is set),
407
+ * runs `coder start` if stopped, then polls until an agent is connected and its
408
+ * startup script has finished. Shares its internals with
409
+ * {@link createCoderWorkspace}'s create mode.
410
+ *
411
+ * The result includes the workspace UUID (`id`) when the transport reports one
412
+ * (a new-enough `coder` CLI; older CLIs may omit it) — the handle that other
413
+ * Coder packages bind to. `@coder/ai-sdk-agent` is intentionally *not* a
414
+ * dependency of this package; compose the two by passing the id:
415
+ *
416
+ * @example Provision a workspace, then bind a `CoderAgent` chat to it
417
+ * ```ts
418
+ * import { ensureCoderWorkspace } from '@coder/ai-sdk-sandbox';
419
+ * import { CoderAgent } from '@coder/ai-sdk-agent';
420
+ *
421
+ * const ws = await ensureCoderWorkspace({
422
+ * workspace: 'agent-ws',
423
+ * create: { template: 'docker' },
424
+ * });
425
+ * if (ws.id === undefined) {
426
+ * throw new Error('this coder CLI does not report workspace ids; upgrade it');
427
+ * }
428
+ * const agent = new CoderAgent({
429
+ * baseUrl: 'https://coder.example.com',
430
+ * token: process.env.CODER_SESSION_TOKEN!,
431
+ * organizationId: '<org-uuid>',
432
+ * workspaceId: ws.id, // binds the chat's workspace-scoped tools
433
+ * });
434
+ * ```
435
+ */
436
+ declare function ensureCoderWorkspace(settings: EnsureCoderWorkspaceSettings): Promise<EnsuredCoderWorkspace>;
373
437
 
374
438
  interface ReadFileOptions {
375
439
  path: string;
@@ -444,4 +508,4 @@ declare class CoderWorkspaceSession implements HarnessV1NetworkSandboxSession {
444
508
  readonly restricted: () => Experimental_SandboxSession;
445
509
  }
446
510
 
447
- export { CODER_WORKSPACE_PROVIDER_ID, CoderCliTransport, type CoderCliTransportOptions, type CoderCreateSettings, type CoderTransport, type CoderWorkspaceBaseSettings, type CoderWorkspaceRef, CoderWorkspaceSession, type CoderWorkspaceSessionConfig, type CoderWorkspaceSettings, type CreateWorkspaceOptions, 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 };
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 };
package/dist/index.js CHANGED
@@ -125,6 +125,8 @@ function parseWorkspaceStatus(workspace) {
125
125
  }
126
126
  }
127
127
  return {
128
+ // The workspace UUID; omitted (not required) so old CLI output stays accepted.
129
+ ...typeof ws.id === "string" && ws.id !== "" ? { id: ws.id } : {},
128
130
  name: typeof ws.name === "string" ? ws.name : "",
129
131
  buildStatus: typeof build.status === "string" ? build.status : "pending",
130
132
  transition: typeof build.transition === "string" ? build.transition : "start",
@@ -833,34 +835,61 @@ function createCoderWorkspace(settings) {
833
835
  }
834
836
  };
835
837
  }
838
+ async function ensureCoderWorkspace(settings) {
839
+ const transport = settings.transport ?? new CoderCliTransport();
840
+ const { created, status } = await ensureWorkspaceReady(
841
+ transport,
842
+ settings.workspace,
843
+ settings.create,
844
+ settings.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS,
845
+ "ensureCoderWorkspace",
846
+ settings.abortSignal
847
+ );
848
+ return { ...status, created };
849
+ }
836
850
  async function ensureWorkspace(transport, workspace, settings, readyTimeoutMs, abortSignal) {
837
- const create = settings.create;
838
- if (create === void 0) {
851
+ if (settings.create === void 0) {
839
852
  if (settings.ensureStarted) {
840
853
  await transport.start(workspace, { abortSignal });
841
854
  }
842
855
  return { createdByProvider: false };
843
856
  }
857
+ const { created } = await ensureWorkspaceReady(
858
+ transport,
859
+ workspace,
860
+ settings.create,
861
+ readyTimeoutMs,
862
+ "createCoderWorkspace",
863
+ abortSignal
864
+ );
865
+ return { createdByProvider: created };
866
+ }
867
+ async function ensureWorkspaceReady(transport, workspace, create, readyTimeoutMs, caller, abortSignal) {
844
868
  const existing = await transport.status(workspace, { abortSignal });
845
- let createdByProvider = false;
869
+ let created = false;
846
870
  if (existing === null) {
871
+ if (create === void 0) {
872
+ throw new Error(
873
+ `${caller}: workspace "${workspace}" does not exist; set \`create\` to create it from a template.`
874
+ );
875
+ }
847
876
  if (create.validate ?? true) {
848
- await validatePreset(transport, create, abortSignal);
877
+ await validatePreset(transport, create, caller, abortSignal);
849
878
  }
850
879
  await transport.create(toCreateOptions(workspace, create, abortSignal));
851
- createdByProvider = true;
880
+ created = true;
852
881
  } else {
853
- if (create.ifExists === "error") {
882
+ if (create?.ifExists === "error") {
854
883
  throw new Error(
855
- `createCoderWorkspace: workspace "${workspace}" already exists (create.ifExists: 'error').`
884
+ `${caller}: workspace "${workspace}" already exists (create.ifExists: 'error').`
856
885
  );
857
886
  }
858
887
  if (isStopped(existing)) {
859
888
  await transport.start(workspace, { abortSignal });
860
889
  }
861
890
  }
862
- await waitForReady(transport, workspace, readyTimeoutMs, abortSignal);
863
- return { createdByProvider };
891
+ const status = await waitForReady(transport, workspace, readyTimeoutMs, caller, abortSignal);
892
+ return { created, status };
864
893
  }
865
894
  function isStopped(status) {
866
895
  return status.buildStatus === "stopped" || status.buildStatus === "stopping" || status.transition === "stop";
@@ -889,7 +918,7 @@ function stringifyParams(params) {
889
918
  }
890
919
  return out;
891
920
  }
892
- async function validatePreset(transport, create, abortSignal) {
921
+ async function validatePreset(transport, create, caller, abortSignal) {
893
922
  if (create.preset === void 0 || create.preset.toLowerCase() === "none") return;
894
923
  let presets;
895
924
  try {
@@ -906,11 +935,11 @@ async function validatePreset(transport, create, abortSignal) {
906
935
  if (!presets.some((preset) => preset.name === create.preset)) {
907
936
  const available = presets.map((preset) => `"${preset.name}"`).join(", ");
908
937
  throw new Error(
909
- `createCoderWorkspace: preset "${create.preset}" not found for template "${create.template}". Available presets: ${available || "(none)"}.`
938
+ `${caller}: preset "${create.preset}" not found for template "${create.template}". Available presets: ${available || "(none)"}.`
910
939
  );
911
940
  }
912
941
  }
913
- async function waitForReady(transport, workspace, timeoutMs, abortSignal) {
942
+ async function waitForReady(transport, workspace, timeoutMs, caller, abortSignal) {
914
943
  const deadline = Date.now() + timeoutMs;
915
944
  let last = "unknown";
916
945
  for (; ; ) {
@@ -919,28 +948,26 @@ async function waitForReady(transport, workspace, timeoutMs, abortSignal) {
919
948
  if (status !== null) {
920
949
  last = `build=${status.buildStatus} agents=[` + status.agents.map((a) => `${a.name || "?"}:${a.status}/${a.lifecycleState}`).join(", ") + "]";
921
950
  if (status.buildStatus === "failed") {
922
- throw new Error(`createCoderWorkspace: workspace "${workspace}" build failed (${last}).`);
951
+ throw new Error(`${caller}: workspace "${workspace}" build failed (${last}).`);
923
952
  }
924
953
  if (status.buildStatus === "canceled" || status.buildStatus === "deleted") {
925
- throw new Error(
926
- `createCoderWorkspace: workspace "${workspace}" is ${status.buildStatus} (${last}).`
927
- );
954
+ throw new Error(`${caller}: workspace "${workspace}" is ${status.buildStatus} (${last}).`);
928
955
  }
929
956
  const errored = status.agents.find(
930
957
  (a) => a.lifecycleState === "start_error" || a.lifecycleState === "start_timeout"
931
958
  );
932
959
  if (errored) {
933
960
  throw new Error(
934
- `createCoderWorkspace: workspace "${workspace}" agent "${errored.name || "?"}" failed to start (lifecycle: ${errored.lifecycleState}).`
961
+ `${caller}: workspace "${workspace}" agent "${errored.name || "?"}" failed to start (lifecycle: ${errored.lifecycleState}).`
935
962
  );
936
963
  }
937
964
  if (status.buildStatus === "running" && status.agents.some((a) => a.status === "connected" && a.lifecycleState === "ready")) {
938
- return;
965
+ return status;
939
966
  }
940
967
  }
941
968
  if (Date.now() >= deadline) {
942
969
  throw new Error(
943
- `createCoderWorkspace: timed out after ${timeoutMs}ms waiting for workspace "${workspace}" to become ready (last status: ${last}).`
970
+ `${caller}: timed out after ${timeoutMs}ms waiting for workspace "${workspace}" to become ready (last status: ${last}).`
944
971
  );
945
972
  }
946
973
  await delay(READY_POLL_INTERVAL_MS, void 0, { signal: abortSignal });
@@ -978,5 +1005,6 @@ export {
978
1005
  CODER_WORKSPACE_PROVIDER_ID,
979
1006
  CoderCliTransport,
980
1007
  CoderWorkspaceSession,
981
- createCoderWorkspace
1008
+ createCoderWorkspace,
1009
+ ensureCoderWorkspace
982
1010
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coder/ai-sdk-sandbox",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Coder workspace sandbox provider for the Vercel AI SDK v7 HarnessAgent",
5
5
  "keywords": [
6
6
  "ai-sdk",
@@ -41,10 +41,10 @@
41
41
  "provenance": true
42
42
  },
43
43
  "devDependencies": {
44
- "@ai-sdk/harness": "1.0.0-canary.11",
45
- "@ai-sdk/harness-claude-code": "1.0.0-canary.7",
46
- "@ai-sdk/provider-utils": "5.0.0-canary.48",
47
- "@ai-sdk/tui": "1.0.0-canary.11",
44
+ "@ai-sdk/harness": "^1.0.23",
45
+ "@ai-sdk/harness-claude-code": "^1.0.23",
46
+ "@ai-sdk/provider-utils": "^5.0.7",
47
+ "@ai-sdk/tui": "^1.0.19",
48
48
  "@arethetypeswrong/cli": "^0.18.0",
49
49
  "@types/node": "^22",
50
50
  "@vitest/coverage-v8": "^4.1.9",
@@ -56,8 +56,8 @@
56
56
  "zod": "4.4.3"
57
57
  },
58
58
  "peerDependencies": {
59
- "@ai-sdk/harness": "^1.0.0-canary.11",
60
- "@ai-sdk/provider-utils": "^5.0.0-canary.48"
59
+ "@ai-sdk/harness": "^1.0.0",
60
+ "@ai-sdk/provider-utils": "^5.0.0"
61
61
  },
62
62
  "engines": {
63
63
  "node": ">=22"