@sandbar-sdk/cli 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +4 -4
  2. package/dist/main.js +460 -51
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -30,7 +30,7 @@ The adapter package must be installed where the CLI can find it: in `~/.config/s
30
30
  | `sh <sandbox> '<script>'` (or `-- '<script>'`) | Run a shell script; same flags as exec |
31
31
  | `run [create and exec flags] [--keep] -- cmd...` | Create, run one command, destroy |
32
32
  | `image build <oci-ref> [--name] [--refresh]` | Build an OCI image on a provider that needs a build first, and record it |
33
- | `image ls`, `image inspect <name>`, `image forget <name>` | List, show or drop image records; `forget` lists what the provider still holds |
33
+ | `image ls [--remote]`, `image inspect <name>`, `image rm <name> [--force]`, `image forget <name>` | List, show, delete or drop image records; `ls --remote` lists the provider's images, `rm` deletes a built image on the provider (only builds this CLI created unless `--force`), `forget` only drops the record |
34
34
  | `cp <src> <dst>` | Copy one file; one side is `<sandbox>:/path` |
35
35
  | `cat <sandbox>:/path` | Print a file |
36
36
  | `doctor` | Check config, credentials and the connection; creates nothing |
@@ -69,7 +69,7 @@ source <(sandbar completion bash) # add to ~/.bashrc
69
69
  sandbar completion fish > ~/.config/fish/completions/sandbar.fish
70
70
  ```
71
71
 
72
- Completion covers commands, flags, `--provider` ids, `--profile` names from `sandbar.json`, sandbox names for `exec`, `sh`, `inspect`, `rm`, `cp` and `cat`, and image record names for `image inspect` and `image forget`. Sandbox names come from the local store, so pressing tab never calls a provider.
72
+ Completion covers commands, flags, `--provider` ids, `--profile` names from `sandbar.json`, sandbox names for `exec`, `sh`, `inspect`, `rm`, `cp` and `cat`, and image record names for `image inspect`, `image rm` and `image forget`. Sandbox names come from the local store, so pressing tab never calls a provider.
73
73
 
74
74
  ## Agent skill
75
75
 
@@ -127,7 +127,7 @@ If the tool is not installed the command exits 4 (`SECRET_TOOL_MISSING`); if it
127
127
  Pass `--build` to build a missing image during `create` or `run`, or set `"images": { "autoBuild": true }` in `sandbar.json` to always do so. Each reference is built once per profile either way.
128
128
  - Vercel takes neither and rejects `--image`.
129
129
 
130
- `sandbar image build` records the reference as typed, the profile, the prepared image (bound to the provider account that built it) and the provider-side resources it left. Running it again for a recorded reference does nothing unless you pass `--refresh`, which builds again, for example after a mutable tag such as `python:3.12` moved; pin a digest for an exact image. `image forget` removes the record and prints the resources the provider still holds; the CLI cannot delete them yet, so remove them with the provider's own tools when you no longer need them. A build whose outcome is unknown (exit 6) keeps its record, and `image inspect` asks the provider again how it ended. Providers do not report a resolved digest, so records do not have one.
130
+ `sandbar image build` records the reference as typed, the profile, the prepared image (bound to the provider account that built it) and the provider-side resources it left. Running it again for a recorded reference does nothing unless you pass `--refresh`, which builds again, for example after a mutable tag such as `python:3.12` moved; pin a digest for an exact image. `image rm <name>` deletes a built image on the provider through `images.delete` and then drops the record; it works where the provider supports image deletion (E2B and Daytona) and exits 4 with `UNSUPPORTED` elsewhere. Without `--force` it only deletes builds this CLI verifiably created, so a build recovered from an older record or of unknown ownership needs `--force`. It refuses, with no effect, a record without a confirmed build, a record that shares the same prepared image, and an image the provider reports as public, shared or still in use. An image that is already gone counts as deleted. If the delete's outcome is unknown, `rm` exits 6, keeps the record in a `deleting` state with its recovery reference, and the next `image rm` asks the provider how the original request ended instead of sending it again. If the provider gives no operation to check, the record stays built and `rm` says so; check the provider before running it again, which sends a new delete. If that check itself fails (for example the profile now reaches another account), the record stays `deleting` with its recovery reference, so a later `image rm` from the original account can still check it. `image forget` removes the record without deleting anything and prints the resources the provider still holds. `image ls --remote` lists the images the provider reports for the profile (up to 1,000), marks the ones with a local record, and says how complete the provider's listing is; a listed image is not proof that you own it. A build whose outcome is unknown (exit 6) keeps its record, and `image inspect` asks the provider again how it ended. Providers do not report a resolved digest, so records do not have one.
131
131
 
132
132
  ## State store
133
133
 
@@ -135,7 +135,7 @@ Sandbox references are kept as one JSON file per sandbox in `~/.config/sandbar/s
135
135
 
136
136
  ## Not yet
137
137
 
138
- Planned for later steps: `--lifetime`/`--idle`/`--port` create flags, recursive `cp`, detached processes and logs, preview URLs, suspend/resume/renew, `gc`, deleting built images (`image rm`), snapshots and volumes, `sandbar adapter add`, an S3-compatible store, KeePassXC and system-keyring references, and MCP mode.
138
+ Planned for later steps: `--lifetime`/`--idle`/`--port` create flags, recursive `cp`, detached processes and logs, preview URLs, suspend/resume/renew, `gc`, snapshots and volumes, `sandbar adapter add`, an S3-compatible store, KeePassXC and system-keyring references, and MCP mode.
139
139
 
140
140
  ## Development
141
141
 
package/dist/main.js CHANGED
@@ -5,7 +5,7 @@ import { parseArgs } from "node:util";
5
5
  // package.json
6
6
  var package_default = {
7
7
  name: "@sandbar-sdk/cli",
8
- version: "0.5.0",
8
+ version: "0.6.0",
9
9
  private: false,
10
10
  type: "module",
11
11
  description: "Agent-first command line for creating and managing Sandbar sandboxes.",
@@ -60,9 +60,10 @@ import {
60
60
  Image as Image2,
61
61
  NonzeroExitError,
62
62
  ResourceReference as ResourceReference2,
63
- SandbarError as SandbarError3
63
+ SandbarError as SandbarError3,
64
+ assertResourceScope
64
65
  } from "sandbar-sdk";
65
- import { z as z4 } from "zod";
66
+ import { z as z5 } from "zod";
66
67
 
67
68
  // src/config.ts
68
69
  import { readFile } from "node:fs/promises";
@@ -317,6 +318,7 @@ function resolveEnvReferences(value, env, where) {
317
318
 
318
319
  // src/images.ts
319
320
  import { randomBytes as randomBytes2 } from "node:crypto";
321
+ import { z as z3 } from "zod";
320
322
  import {
321
323
  Image,
322
324
  SandbarError as SandbarError2
@@ -340,6 +342,7 @@ var SandboxRecord = z2.strictObject({
340
342
  labels: z2.record(z2.string(), z2.string()),
341
343
  image: z2.string().optional(),
342
344
  reference: ResourceReference.extend({ kind: z2.literal("sandbox") }).nullable(),
345
+ adopted: z2.literal(true).optional(),
343
346
  recovery: z2.custom((value) => z2.record(z2.string(), z2.unknown()).safeParse(value).success).optional()
344
347
  });
345
348
  var RetainedResource = z2.strictObject({
@@ -352,7 +355,7 @@ var ImageRecord = z2.strictObject({
352
355
  format: z2.literal(1),
353
356
  kind: z2.literal("image"),
354
357
  name: RecordName,
355
- status: z2.enum(["building", "built", "unknown"]),
358
+ status: z2.enum(["building", "built", "unknown", "deleting"]),
356
359
  profile: z2.string().min(1),
357
360
  provider: z2.string().min(1),
358
361
  createdAt: z2.iso.datetime({ offset: true }),
@@ -364,6 +367,7 @@ var ImageRecord = z2.strictObject({
364
367
  scope: ResourceReference.shape.scope
365
368
  }).nullable(),
366
369
  retainedResources: z2.array(RetainedResource),
370
+ reference: ResourceReference.extend({ kind: z2.literal("image") }).optional(),
367
371
  recovery: z2.custom((value) => z2.record(z2.string(), z2.unknown()).safeParse(value).success).optional()
368
372
  });
369
373
  var schemas = { sandbox: SandboxRecord, image: ImageRecord };
@@ -527,16 +531,21 @@ function generatedName() {
527
531
  }
528
532
 
529
533
  // src/images.ts
530
- var BuildResult = ImageRecord.pick({ prepared: true, retainedResources: true }).extend({
534
+ var BuildResult = ImageRecord.pick({
535
+ prepared: true,
536
+ retainedResources: true,
537
+ reference: true
538
+ }).extend({
531
539
  prepared: ImageRecord.shape.prepared.unwrap()
532
540
  });
541
+ var DeleteResult = z3.looseObject({ deleted: z3.literal(true) });
533
542
  function derivedName(source) {
534
543
  const base = (source.split("/").at(-1) ?? source).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 57).replace(/^-+|-+$/g, "");
535
544
  return RecordName.safeParse(base).success ? base : "image";
536
545
  }
537
546
  async function findBuild(store, profile, source) {
538
547
  const entries = await listAll(store, "image");
539
- return entries.find(({ record }) => record.profile === profile && record.source === source && record.prepared);
548
+ return entries.find(({ record }) => record.profile === profile && record.source === source && record.prepared && record.status !== "deleting");
540
549
  }
541
550
  function mergeRetained(previous, next) {
542
551
  const seen = new Set(previous.map((item) => `${item.kind}/${item.id}`));
@@ -610,7 +619,8 @@ async function buildImage(session, client, profile, source, options) {
610
619
  status: "built",
611
620
  createdAt: new Date().toISOString(),
612
621
  prepared: result.prepared,
613
- retainedResources: mergeRetained(claimed.record.retainedResources, result.retainedResources)
622
+ retainedResources: mergeRetained(claimed.record.retainedResources, result.retainedResources),
623
+ reference: result.reference
614
624
  };
615
625
  try {
616
626
  const { version } = await store.update("image", record.name, record, claimed.version);
@@ -716,7 +726,12 @@ async function inspectImage(session, args) {
716
726
  `built ${record.createdAt}`,
717
727
  ...record.retainedResources.length ? ["retained", ...retainedLines(record)] : []
718
728
  ];
719
- session.ctx.out.success({ ...summary(record), preparedImage: record.prepared, recovery: record.recovery ?? null }, lines.join(`
729
+ session.ctx.out.success({
730
+ ...summary(record),
731
+ preparedImage: record.prepared,
732
+ reference: record.reference ?? null,
733
+ recovery: record.recovery ?? null
734
+ }, lines.join(`
720
735
  `));
721
736
  return 0;
722
737
  }
@@ -728,25 +743,171 @@ async function forgetImage(session, args) {
728
743
  await store.remove("image", record.name, version);
729
744
  const lines = [`forgot ${record.name}`];
730
745
  if (record.retainedResources.length)
731
- lines.push(`${record.provider} still holds these; delete them with the provider's own tools if you no longer need them:`, ...retainedLines(record));
746
+ lines.push(`${record.provider} still holds these; forget never deletes them (\`sandbar image rm\` does, where the provider supports it):`, ...retainedLines(record));
732
747
  session.ctx.out.success({ name: record.name, result: "forgotten", retainedResources: record.retainedResources }, lines.join(`
733
748
  `));
734
749
  return 0;
735
750
  }
736
- var imageActions = ["build", "ls", "inspect", "forget"];
751
+ function recordReference(record) {
752
+ if (record.reference)
753
+ return record.reference;
754
+ if (!record.prepared)
755
+ return null;
756
+ return {
757
+ version: 1,
758
+ kind: "image",
759
+ provider: record.prepared.provider,
760
+ scope: structuredClone(record.prepared.scope),
761
+ nativeId: record.prepared.value,
762
+ ownership: "unknown"
763
+ };
764
+ }
765
+ async function forgetDeleted(store, entry) {
766
+ const current = await store.get("image", entry.record.name);
767
+ if (current?.version === entry.version)
768
+ await store.remove("image", entry.record.name, entry.version);
769
+ }
770
+ function deletedOutput(session, record, result) {
771
+ const prepared = record.prepared?.value ?? null;
772
+ const others = record.retainedResources.filter((item) => item.id !== prepared);
773
+ const lines = [result === "gone" ? `${record.name} was already gone` : `deleted ${record.name}`];
774
+ if (others.length)
775
+ lines.push(`${record.provider} still holds these from earlier builds:`, ...retainedLines({ ...record, retainedResources: others }));
776
+ session.ctx.out.success({ name: record.name, result, prepared, retainedResources: others }, lines.join(`
777
+ `));
778
+ return 0;
779
+ }
780
+ async function rmImage(session, args, force) {
781
+ if (args.length !== 1)
782
+ throw usage("Expected exactly one image name");
783
+ const store = await session.store();
784
+ const entry = await recoverBuild(session, store, await findImage(store, args[0]));
785
+ const { record } = entry;
786
+ const client = await session.connect(await session.profileNamed(record.profile));
787
+ if (record.status === "deleting") {
788
+ if (record.recovery?.kind !== "image_delete") {
789
+ const { recovery: _recovery, ...rest } = record;
790
+ await store.update("image", record.name, { ...rest, status: "built" }, entry.version);
791
+ throw new CliError("OUTCOME_UNKNOWN", `${record.name} has no saved delete to check`, `the store keeps ${record.name} as built; check the provider (\`sandbar image ls --remote\`) before running \`sandbar image rm ${record.name}\` again, which sends a new delete`, { image: record.name });
792
+ }
793
+ let value;
794
+ try {
795
+ value = await (await client.recover(record.recovery)).observe();
796
+ } catch (cause) {
797
+ if (!(cause instanceof SandbarError2))
798
+ throw cause;
799
+ if (cause.effect === "none")
800
+ throw new CliError(cause.code, cause.message, `the store keeps ${record.name} deleting; run \`sandbar image rm ${record.name}\` again with profile ${record.profile} connected to the account the delete was sent from`, { image: record.name, recovery: record.recovery });
801
+ }
802
+ if (!DeleteResult.safeParse(value).success)
803
+ throw new CliError("OUTCOME_UNKNOWN", `The delete of ${record.name} is still unconfirmed`, `the store keeps ${record.name}; run \`sandbar image rm ${record.name}\` again later`, { image: record.name, recovery: record.recovery });
804
+ await forgetDeleted(store, entry);
805
+ return deletedOutput(session, record, "deleted");
806
+ }
807
+ const reference = recordReference(record);
808
+ if (record.status !== "built" || !reference)
809
+ throw new CliError("CONFLICT", `${record.name} has no confirmed build to delete`, "`sandbar image inspect` checks an uncertain build; `sandbar image forget` drops the record");
810
+ if (reference.ownership !== "verified-created" && !force)
811
+ throw new CliError("FORBIDDEN", `${record.name} was not created by this CLI (ownership ${reference.ownership})`, "pass --force to delete it anyway", { image: record.name });
812
+ const capabilities = await client.capabilities();
813
+ if (capabilities.imageManagement.delete.status !== "supported")
814
+ throw new CliError("UNSUPPORTED", `${record.provider} cannot delete images through Sandbar`, "`sandbar image forget` drops the record; delete the image with the provider's own tools");
815
+ const shared = (await listAll(store, "image")).find((other) => other.record.name !== record.name && other.record.profile === record.profile && other.record.prepared?.value === reference.nativeId);
816
+ if (shared)
817
+ throw new CliError("CONFLICT", `${record.name} and ${shared.record.name} record the same provider image`, "forget one record first, so the other is not left pointing at a deleted image");
818
+ try {
819
+ await client.images.delete(reference, { signal: session.ctx.signal });
820
+ } catch (cause) {
821
+ const report = describeError(cause);
822
+ if (report.outcome === "rejected" && report.code === "NOT_FOUND") {
823
+ await forgetDeleted(store, entry);
824
+ return deletedOutput(session, record, "gone");
825
+ }
826
+ if (report.outcome === "rejected")
827
+ throw cause;
828
+ const recovery = report.known?.recovery;
829
+ if (!recovery)
830
+ throw new CliError("OUTCOME_UNKNOWN", report.message, `the store keeps ${record.name} as built and there is no saved delete to check; check the provider (\`sandbar image ls --remote\`) before running \`sandbar image rm ${record.name}\` again, which sends a new delete`, { image: record.name });
831
+ let kept = `the store keeps ${record.name}; run \`sandbar image rm ${record.name}\` again to check`;
832
+ try {
833
+ await store.update("image", record.name, { ...record, status: "deleting", recovery }, entry.version);
834
+ } catch (storeCause) {
835
+ kept = `the store could not record it (${describeError(storeCause).message}); keep the reference below`;
836
+ }
837
+ throw new CliError("OUTCOME_UNKNOWN", report.message, kept, {
838
+ image: record.name,
839
+ recovery
840
+ });
841
+ }
842
+ await forgetDeleted(store, entry);
843
+ return deletedOutput(session, record, "deleted");
844
+ }
845
+ function remoteRow(info, records) {
846
+ const recorded = records.find(({ record }) => record.prepared?.value === info.reference.nativeId)?.record.name;
847
+ return {
848
+ id: info.reference.nativeId,
849
+ state: info.state,
850
+ visibility: info.visibility,
851
+ source: info.source?.value ?? null,
852
+ createdAt: info.createdAt,
853
+ record: recorded ?? null,
854
+ reference: info.reference
855
+ };
856
+ }
857
+ async function lsRemote(session) {
858
+ const profile = await session.profile();
859
+ const client = await session.connect(profile);
860
+ const capabilities = await client.capabilities();
861
+ if (capabilities.imageManagement.list.status !== "supported")
862
+ throw new CliError("UNSUPPORTED", `${profile.provider} cannot list images through Sandbar`, "`sandbar image ls` shows the builds this store recorded");
863
+ const records = (await listAll(await session.store(), "image")).filter(({ record }) => record.profile === profile.name);
864
+ const rows = [];
865
+ let cursor;
866
+ let coverage = capabilities.imageManagement.list.value.coverage;
867
+ for (let page = 0;page < 10; page++) {
868
+ const result = await client.images.list({ limit: 100, cursor });
869
+ coverage = result.coverage;
870
+ rows.push(...result.items.map((info) => remoteRow(info, records)));
871
+ cursor = result.nextCursor;
872
+ if (!cursor)
873
+ break;
874
+ }
875
+ if (cursor)
876
+ session.ctx.out.note(`showing the first ${rows.length} images; more exist`);
877
+ const lines = rows.map((row) => [
878
+ row.id.padEnd(28),
879
+ row.state.padEnd(9),
880
+ row.visibility.padEnd(10),
881
+ (row.record ?? "-").padEnd(20),
882
+ row.source ?? "-"
883
+ ].join(" "));
884
+ session.ctx.out.success({ profile: profile.name, coverage, images: rows, truncated: !!cursor }, rows.length ? [
885
+ `${"ID".padEnd(28)} ${"STATE".padEnd(9)} ${"VISIBILITY".padEnd(10)} ${"RECORD".padEnd(20)} SOURCE`,
886
+ ...lines
887
+ ].join(`
888
+ `) : `no images on ${profile.provider} for profile ${profile.name}`);
889
+ return 0;
890
+ }
891
+ var imageActions = ["build", "ls", "inspect", "rm", "forget"];
737
892
  async function imageCommand(session, flags) {
738
893
  const [action, ...args] = flags.positionals;
739
894
  if ((flags.string("name") || flags.bool("refresh")) && action !== "build")
740
895
  throw usage("--name and --refresh only apply to `sandbar image build`");
896
+ if (flags.bool("remote") && action !== "ls")
897
+ throw usage("--remote only applies to `sandbar image ls`");
898
+ if (flags.bool("force") && action !== "rm")
899
+ throw usage("--force only applies to `sandbar image rm`");
741
900
  switch (action) {
742
901
  case "build":
743
902
  return buildCommand(session, flags, args);
744
903
  case "ls":
745
904
  if (args.length)
746
905
  throw usage("image ls takes no arguments");
747
- return lsImages(session);
906
+ return flags.bool("remote") ? lsRemote(session) : lsImages(session);
748
907
  case "inspect":
749
908
  return inspectImage(session, args);
909
+ case "rm":
910
+ return rmImage(session, args, flags.bool("force"));
750
911
  case "forget":
751
912
  return forgetImage(session, args);
752
913
  default:
@@ -762,7 +923,7 @@ import {
762
923
  bindAdapter
763
924
  } from "@sandbar-sdk/adapter";
764
925
  import { Sandbar } from "sandbar-sdk";
765
- import { z as z3 } from "zod";
926
+ import { z as z4 } from "zod";
766
927
  var providers = [
767
928
  {
768
929
  id: "daytona",
@@ -868,7 +1029,7 @@ function fromProfile(name, profile, loaded, env) {
868
1029
  }),
869
1030
  ...Object.entries(profile.credentials ?? {}).map(([key, value]) => [
870
1031
  key,
871
- z3.string().parse(resolveEnvReferences(value, env, `${where}.credentials.${key}`))
1032
+ z4.string().parse(resolveEnvReferences(value, env, `${where}.credentials.${key}`))
872
1033
  ])
873
1034
  ]);
874
1035
  const config = Object.fromEntries([
@@ -897,9 +1058,9 @@ function fromProfile(name, profile, loaded, env) {
897
1058
  credentials
898
1059
  };
899
1060
  }
900
- var PackageManifest = z3.object({
901
- main: z3.string().optional(),
902
- exports: z3.json().optional()
1061
+ var PackageManifest = z4.object({
1062
+ main: z4.string().optional(),
1063
+ exports: z4.json().optional()
903
1064
  });
904
1065
  async function exists(path) {
905
1066
  try {
@@ -911,10 +1072,10 @@ async function exists(path) {
911
1072
  }
912
1073
  var importConditions = new Set(["import", "node", "default"]);
913
1074
  function exportTarget(value) {
914
- const plain = z3.string().safeParse(value);
1075
+ const plain = z4.string().safeParse(value);
915
1076
  if (plain.success)
916
1077
  return plain.data;
917
- const list = z3.array(z3.json()).safeParse(value);
1078
+ const list = z4.array(z4.json()).safeParse(value);
918
1079
  if (list.success) {
919
1080
  for (const item of list.data) {
920
1081
  const target = exportTarget(item);
@@ -923,7 +1084,7 @@ function exportTarget(value) {
923
1084
  }
924
1085
  return null;
925
1086
  }
926
- const conditions = z3.record(z3.string(), z3.json()).safeParse(value);
1087
+ const conditions = z4.record(z4.string(), z4.json()).safeParse(value);
927
1088
  if (!conditions.success)
928
1089
  return null;
929
1090
  for (const [condition, nested] of Object.entries(conditions.data)) {
@@ -939,7 +1100,7 @@ function rootEntry(manifest) {
939
1100
  const exports = manifest.exports;
940
1101
  if (exports === undefined || exports === null)
941
1102
  return manifest.main || "index.js";
942
- const subpaths = z3.record(z3.string(), z3.json()).safeParse(exports);
1103
+ const subpaths = z4.record(z4.string(), z4.json()).safeParse(exports);
943
1104
  const root = subpaths.success && Object.keys(subpaths.data).some((key) => key.startsWith(".")) ? subpaths.data["."] : exports;
944
1105
  return (root === undefined ? null : exportTarget(root)) ?? manifest.main ?? "index.js";
945
1106
  }
@@ -980,7 +1141,7 @@ async function resolveAdapterModule(profile, cwd, env) {
980
1141
  function isAdapter(value) {
981
1142
  return value instanceof Object && "name" in value && "connect" in value && "config" in value;
982
1143
  }
983
- var AdapterExport = z3.custom(isAdapter);
1144
+ var AdapterExport = z4.custom(isAdapter);
984
1145
  async function adapterMissing(target, spec, message) {
985
1146
  if (target.startsWith("file:"))
986
1147
  return !await exists(fileURLToPath(target));
@@ -988,7 +1149,7 @@ async function adapterMissing(target, spec, message) {
988
1149
  }
989
1150
  async function loadAdapter(profile, cwd, env, secrets) {
990
1151
  const target = await resolveAdapterModule(profile, cwd, env);
991
- const schema = z3.object({ [profile.export]: AdapterExport });
1152
+ const schema = z4.object({ [profile.export]: AdapterExport });
992
1153
  let parsed;
993
1154
  try {
994
1155
  parsed = schema.safeParse(await import(target));
@@ -1008,7 +1169,7 @@ async function loadAdapter(profile, cwd, env, secrets) {
1008
1169
  return exported;
1009
1170
  const where = `profile ${profile.name}`;
1010
1171
  const config = await secrets.resolve(profile.config, `${where}.config`);
1011
- const credentials = z3.record(z3.string(), z3.string()).parse(await secrets.resolve(profile.credentials, `${where}.credentials`));
1172
+ const credentials = z4.record(z4.string(), z4.string()).parse(await secrets.resolve(profile.credentials, `${where}.credentials`));
1012
1173
  return bindAdapter(exported, config, credentials);
1013
1174
  }
1014
1175
  async function connect(profile, cwd, env, secrets) {
@@ -1064,16 +1225,16 @@ class Flags {
1064
1225
  this.rest = rest;
1065
1226
  }
1066
1227
  string(name) {
1067
- return z4.string().optional().parse(this.values[name]);
1228
+ return z5.string().optional().parse(this.values[name]);
1068
1229
  }
1069
1230
  strings(name) {
1070
- return z4.array(z4.string()).optional().parse(this.values[name]) ?? [];
1231
+ return z5.array(z5.string()).optional().parse(this.values[name]) ?? [];
1071
1232
  }
1072
1233
  bool(name) {
1073
- return z4.boolean().optional().parse(this.values[name]) ?? false;
1234
+ return z5.boolean().optional().parse(this.values[name]) ?? false;
1074
1235
  }
1075
1236
  }
1076
- function createRequest(flags) {
1237
+ function createRequest(flags, env = {}) {
1077
1238
  if (flags.string("image") && flags.string("prepared"))
1078
1239
  throw usage("Pass either --image or --prepared, not both");
1079
1240
  if (flags.bool("build") && !flags.string("image"))
@@ -1085,14 +1246,89 @@ function createRequest(flags) {
1085
1246
  image: flags.string("image"),
1086
1247
  prepared: flags.string("prepared"),
1087
1248
  network: flags.string("network"),
1088
- labels: parsePairs(flags.strings("label"), "label")
1249
+ labels: parsePairs(flags.strings("label"), "label"),
1250
+ env
1089
1251
  };
1090
1252
  }
1253
+ var EnvName = /^[A-Za-z_][A-Za-z0-9_]*$/;
1254
+ var escapes = new Map([
1255
+ ["n", `
1256
+ `],
1257
+ ['"', '"'],
1258
+ ["\\", "\\"]
1259
+ ]);
1260
+ function envFileValue(text) {
1261
+ const value = text.trimStart();
1262
+ const quote = value[0];
1263
+ if (quote !== '"' && quote !== "'")
1264
+ return { value: text.replace(/\s#.*$/s, "").trim() };
1265
+ let parsed = "";
1266
+ let end = -1;
1267
+ for (let at = 1;at < value.length; at++) {
1268
+ const char = value[at];
1269
+ if (char === quote) {
1270
+ end = at;
1271
+ break;
1272
+ }
1273
+ if (quote === '"' && char === "\\" && at + 1 < value.length) {
1274
+ const next = value[++at];
1275
+ parsed += escapes.get(next) ?? `\\${next}`;
1276
+ } else
1277
+ parsed += char;
1278
+ }
1279
+ if (end < 0)
1280
+ return { error: "has an unterminated quoted value; multi-line values are not supported" };
1281
+ if (!/^\s*(#.*)?$/s.test(value.slice(end + 1)))
1282
+ return { error: "has text after the closing quote" };
1283
+ return { value: parsed };
1284
+ }
1285
+ async function envFlags(session, flags) {
1286
+ const env = {};
1287
+ for (const file of flags.strings("env-file")) {
1288
+ const path = resolve3(session.ctx.cwd, file);
1289
+ let text;
1290
+ try {
1291
+ text = await readFile4(path, "utf8");
1292
+ } catch {
1293
+ throw usage(`--env-file ${file} could not be read`);
1294
+ }
1295
+ for (const [index, raw] of text.split(/\r?\n/).entries()) {
1296
+ const line = raw.trim();
1297
+ if (!line || line.startsWith("#"))
1298
+ continue;
1299
+ const assignment = line.startsWith("export ") ? line.slice(7).trimStart() : line;
1300
+ const split = assignment.indexOf("=");
1301
+ const name = split > 0 ? assignment.slice(0, split).trim() : "";
1302
+ if (!EnvName.test(name))
1303
+ throw usage(`--env-file ${file}:${index + 1} must be NAME=VALUE with a valid NAME`);
1304
+ const parsed = envFileValue(assignment.slice(split + 1));
1305
+ if ("error" in parsed)
1306
+ throw usage(`--env-file ${file}:${index + 1} ${parsed.error}`);
1307
+ env[name] = parsed.value;
1308
+ }
1309
+ }
1310
+ for (const entry of flags.strings("env")) {
1311
+ const split = entry.indexOf("=");
1312
+ const name = split < 0 ? entry : entry.slice(0, split);
1313
+ if (!EnvName.test(name))
1314
+ throw usage("--env expects NAME=VALUE or NAME, with NAME like [A-Za-z_][A-Za-z0-9_]*");
1315
+ if (split >= 0)
1316
+ env[name] = entry.slice(split + 1);
1317
+ else {
1318
+ const inherited = session.ctx.env[name];
1319
+ if (inherited === undefined)
1320
+ throw usage(`--env ${name} is not set in this environment`);
1321
+ env[name] = inherited;
1322
+ }
1323
+ }
1324
+ return env;
1325
+ }
1091
1326
  function createInput(request) {
1092
1327
  return {
1093
1328
  environment: request.prepared ? Image2.prepared(request.prepared) : undefined,
1094
1329
  network: request.network,
1095
- labels: Object.keys(request.labels).length ? request.labels : undefined
1330
+ labels: Object.keys(request.labels).length ? request.labels : undefined,
1331
+ env: Object.keys(request.env).length ? request.env : undefined
1096
1332
  };
1097
1333
  }
1098
1334
  async function claimName(store, record, requested, ifNotExists = false) {
@@ -1203,7 +1439,8 @@ function sandboxSummary(record) {
1203
1439
  status: record.status,
1204
1440
  createdAt: record.createdAt,
1205
1441
  labels: record.labels,
1206
- image: record.image ?? null
1442
+ image: record.image ?? null,
1443
+ adopted: record.adopted ?? false
1207
1444
  };
1208
1445
  }
1209
1446
  async function findTarget(session, target) {
@@ -1235,13 +1472,15 @@ async function findTarget(session, target) {
1235
1472
  if (entry)
1236
1473
  return entry;
1237
1474
  }
1238
- const byId = (await listAll(store, "sandbox")).find((entry) => entry.record.reference?.nativeId === target);
1239
- if (byId)
1240
- return byId;
1475
+ const byId = (await listAll(store, "sandbox")).filter((entry) => entry.record.reference?.nativeId === target);
1476
+ if (byId.length > 1)
1477
+ throw new CliError("CONFLICT", `${target} names more than one stored sandbox: ${byId.map((entry) => entry.record.name).join(", ")}`, "use the store name instead of the id");
1478
+ if (byId[0])
1479
+ return byId[0];
1241
1480
  throw new CliError("NOT_FOUND", `No sandbox named ${target} in the store`, "list known sandboxes with `sandbar ls`");
1242
1481
  }
1243
1482
  async function createCommand(session, flags) {
1244
- const created = await createSandbox(session, createRequest(flags));
1483
+ const created = await createSandbox(session, createRequest(flags, await envFlags(session, flags)));
1245
1484
  if (created.unrecorded)
1246
1485
  throw unrecordedError(created);
1247
1486
  session.ctx.out.success({
@@ -1265,7 +1504,108 @@ async function stateOf(session, record, client) {
1265
1504
  return "unreachable";
1266
1505
  }
1267
1506
  }
1507
+ var REMOTE_PAGES = 20;
1508
+ async function remoteSandboxes(client, until) {
1509
+ const items = [];
1510
+ let cursor;
1511
+ let coverage = "provider-scope";
1512
+ for (let page = 0;page < REMOTE_PAGES; page++) {
1513
+ const result = await client.sandboxes.list({ cursor, limit: 100 });
1514
+ coverage = result.coverage;
1515
+ items.push(...result.items);
1516
+ cursor = result.nextCursor;
1517
+ if (!cursor || until && result.items.some(until))
1518
+ return { items, coverage, complete: true };
1519
+ }
1520
+ return { items, coverage, complete: false };
1521
+ }
1522
+ var coverageNote = {
1523
+ "provider-scope": "every sandbox this profile's account and scope can see",
1524
+ "sandbar-managed": "only sandboxes created through Sandbar in this profile's scope"
1525
+ };
1526
+ function renderTable(rows) {
1527
+ const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
1528
+ return rows.map((row) => row.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd()).join(`
1529
+ `);
1530
+ }
1531
+ async function remoteLs(session) {
1532
+ const { out } = session.ctx;
1533
+ const profile = await session.profile();
1534
+ const entries = await listAll(await session.store(), "sandbox", (warning) => out.note(warning));
1535
+ const client = await session.connect(profile);
1536
+ const listing = await remoteSandboxes(client);
1537
+ const nameOf = (item) => entries.find(({ record }) => storesSandbox(record, profile, client, item))?.record.name ?? null;
1538
+ const rows = listing.items.map((item) => ({
1539
+ id: item.id,
1540
+ state: item.state,
1541
+ name: nameOf(item),
1542
+ reopenable: item.reference !== null,
1543
+ ownership: item.reference?.ownership ?? null,
1544
+ reference: item.reference
1545
+ }));
1546
+ out.note(`${profile.provider} lists ${coverageNote[listing.coverage]}`);
1547
+ if (!listing.complete)
1548
+ out.note(`warning: showing the first ${rows.length} sandboxes; the provider has more`);
1549
+ out.success({
1550
+ provider: profile.provider,
1551
+ profile: profile.name,
1552
+ coverage: listing.coverage,
1553
+ complete: listing.complete,
1554
+ sandboxes: rows
1555
+ }, rows.length ? renderTable([
1556
+ ["ID", "STATE", "NAME", "OWNERSHIP"],
1557
+ ...rows.map((row) => [
1558
+ row.id,
1559
+ row.state,
1560
+ row.name ?? "-",
1561
+ row.ownership ?? "not reopenable"
1562
+ ])
1563
+ ]) : "");
1564
+ return 0;
1565
+ }
1566
+ async function adoptCommand(session, flags) {
1567
+ const id = onePositional(flags, "sandbox id");
1568
+ const requested = flags.string("name");
1569
+ const store = await session.store();
1570
+ const profile = await session.profile();
1571
+ const client = await session.connect(profile);
1572
+ const listing = await remoteSandboxes(client, (item) => item.id === id);
1573
+ const listed = listing.items.find((item) => item.id === id);
1574
+ const known = listed && (await listAll(store, "sandbox")).find(({ record: record2 }) => storesSandbox(record2, profile, client, listed));
1575
+ if (known)
1576
+ throw new CliError("CONFLICT", `${id} is already in the store as ${known.record.name}`, `use \`sandbar inspect ${known.record.name}\``);
1577
+ if (!listed)
1578
+ throw new CliError("NOT_FOUND", `${profile.provider} does not list a sandbox ${id} for profile ${profile.name}`, listing.complete ? "list what it does show with `sandbar ls --remote`" : "the listing was cut short; check the provider's own tools");
1579
+ if (!listed.reference)
1580
+ throw new CliError("UNSUPPORTED", `${profile.provider} lists ${id} without a reopenable reference`, "this provider cannot verify that sandbox's identity, so the CLI cannot reach it");
1581
+ const box = await client.sandboxes.get(listed.reference, { signal: session.ctx.signal });
1582
+ const record = {
1583
+ format: 1,
1584
+ kind: "sandbox",
1585
+ status: "created",
1586
+ profile: profile.name,
1587
+ provider: profile.provider,
1588
+ createdAt: new Date().toISOString(),
1589
+ labels: {},
1590
+ reference: box.reference ?? listed.reference,
1591
+ adopted: true
1592
+ };
1593
+ let claimed;
1594
+ try {
1595
+ claimed = await claimName(store, record, requested);
1596
+ } catch (cause) {
1597
+ if (cause instanceof CliError && cause.code === "CONFLICT")
1598
+ throw new CliError("CONFLICT", cause.message, "choose another --name");
1599
+ throw cause;
1600
+ }
1601
+ session.ctx.out.success({ ...sandboxSummary(claimed.record), id: box.id, reference: claimed.record.reference }, claimed.record.name);
1602
+ return 0;
1603
+ }
1268
1604
  async function lsCommand(session, flags) {
1605
+ if (flags.bool("remote") && flags.bool("offline"))
1606
+ throw usage("Pass either --remote or --offline, not both");
1607
+ if (flags.bool("remote"))
1608
+ return remoteLs(session);
1269
1609
  const entries = await listAll(await session.store(), "sandbox", (warning) => session.ctx.out.note(warning));
1270
1610
  const offline = flags.bool("offline");
1271
1611
  const clients = new Map;
@@ -1325,10 +1665,30 @@ async function inspectCommand(session, flags) {
1325
1665
  function refusal(record) {
1326
1666
  if (!record.reference)
1327
1667
  return new CliError("UNSUPPORTED", `${record.name} has no reopenable reference (status ${record.status}); it cannot be destroyed from here`, "check the provider for it, then `sandbar rm --force` to drop the record", { sandbox: record.name });
1668
+ if (record.adopted)
1669
+ return new CliError("FORBIDDEN", `${record.name} was adopted from a provider listing, not created by this CLI`, "pass --force to destroy it anyway", { sandbox: record.name });
1328
1670
  if (record.reference.ownership !== "verified-created")
1329
1671
  return new CliError("FORBIDDEN", `${record.name} was not created by this CLI (ownership ${record.reference.ownership})`, "pass --force to destroy it anyway", { sandbox: record.name });
1330
1672
  return null;
1331
1673
  }
1674
+ function storesSandbox(record, profile, client, item) {
1675
+ const stored = record.reference;
1676
+ if (!stored || record.provider !== profile.provider || stored.nativeId !== item.id)
1677
+ return false;
1678
+ if (item.reference?.generation !== undefined && stored.generation !== undefined && stored.generation !== item.reference.generation)
1679
+ return false;
1680
+ try {
1681
+ assertResourceScope(stored, {
1682
+ provider: client.provider,
1683
+ scope: item.reference?.scope ?? client.scope
1684
+ });
1685
+ return true;
1686
+ } catch (error) {
1687
+ if (error instanceof Error && "code" in error && error.code === "CONFLICT")
1688
+ return false;
1689
+ throw error;
1690
+ }
1691
+ }
1332
1692
  function sameSandbox(a, b) {
1333
1693
  if (a.createdAt !== b.createdAt || a.provider !== b.provider || a.profile !== b.profile)
1334
1694
  return false;
@@ -1476,7 +1836,7 @@ async function execInput(session, flags, command) {
1476
1836
  const stdin = flags.bool("stdin") ? await session.ctx.readStdin() : stdinFile ? new Uint8Array(await readFile4(resolve3(session.ctx.cwd, stdinFile))) : undefined;
1477
1837
  const timeout = flags.string("timeout");
1478
1838
  const maxOutput = flags.string("max-output");
1479
- const env = parsePairs(flags.strings("env"), "env");
1839
+ const env = await envFlags(session, flags);
1480
1840
  return {
1481
1841
  command,
1482
1842
  cwd: flags.string("cwd"),
@@ -1744,8 +2104,8 @@ async function providersCommand(session) {
1744
2104
 
1745
2105
  // src/secrets.ts
1746
2106
  import { execFile } from "node:child_process";
1747
- import { z as z5 } from "zod";
1748
- var BwsSecret = z5.object({ value: z5.string() });
2107
+ import { z as z6 } from "zod";
2108
+ var BwsSecret = z6.object({ value: z6.string() });
1749
2109
  var backends = [
1750
2110
  {
1751
2111
  name: "1Password",
@@ -1818,14 +2178,14 @@ class SecretReader {
1818
2178
  }
1819
2179
  }
1820
2180
  async resolve(value, where) {
1821
- const text = z5.string().safeParse(value);
2181
+ const text = z6.string().safeParse(value);
1822
2182
  if (text.success) {
1823
2183
  const backend = backendFor(text.data);
1824
2184
  return backend ? this.read(backend, text.data, where) : value;
1825
2185
  }
1826
2186
  if (Array.isArray(value))
1827
2187
  return Promise.all(value.map((item, index) => this.resolve(item, `${where}[${index}]`)));
1828
- const record = z5.record(z5.string(), z5.json()).safeParse(value);
2188
+ const record = z6.record(z6.string(), z6.json()).safeParse(value);
1829
2189
  if (record.success)
1830
2190
  return Object.fromEntries(await Promise.all(Object.entries(record.data).map(async ([key, item]) => [key, await this.resolve(item, `${where}.${key}`)])));
1831
2191
  return value;
@@ -1886,7 +2246,7 @@ import { homedir as homedir2 } from "node:os";
1886
2246
  import { dirname as dirname3, join as join5 } from "node:path";
1887
2247
 
1888
2248
  // ../../skills/sandbar-cli/SKILL.md
1889
- var SKILL_default = "---\nname: sandbar-cli\ndescription: Create, use and clean up cloud sandboxes from the shell with the `sandbar` CLI (Daytona, E2B, boxd, Boat, Vercel, Modal). Use when you need an isolated machine to run untrusted or generated code, try a build or test in a clean environment, or manage sandboxes someone created with sandbar; prefer it over provider-specific CLIs.\n---\n\n# Use sandboxes with the sandbar CLI\n\n`sandbar` creates and manages sandboxes on any Sandbar provider. It is built for agents: it never prompts, every command has a stable `--json` form, and exit codes tell you what to do next.\n\n## Check the setup first\n\n```sh\nsandbar doctor --json # config, credentials, provider connection; creates nothing\nsandbar providers --json # which adapters are installed and which credentials are missing\n```\n\n- If `sandbar` is not on PATH, stop and tell the user; do not install anything yourself.\n- The provider comes from `--profile`, `SANDBAR_PROFILE`, `--provider`, `SANDBAR_PROVIDER`, then `default` in `~/.config/sandbar/sandbar.json`. Do not edit that file or credentials unless the user asks.\n- A missing adapter (exit 4, `ADAPTER_NOT_INSTALLED`), an installed adapter that fails to load (exit 4, `ADAPTER_LOAD_FAILED`) or a missing credential (exit 2) is the user's to fix. Report the `hint` from the error.\n\n**Sandboxes cost money.** Create only what the task needs and destroy it when done.\n\n## Always use JSON\n\nPass `--json` (or set `SANDBAR_JSON=1`). stdout then holds exactly one document:\n\n```json\n{\"ok\": true, \"data\": {...}}\n{\"ok\": false, \"error\": {\"code\": \"...\", \"message\": \"...\", \"retryable\": false, \"outcome\": \"rejected\", \"hint\": \"...\", \"known\": {...}}}\n```\n\nDiagnostics go to stderr. Read `error.hint` before deciding what to do.\n\n## One-shot work: `run`\n\n`run` creates a sandbox, runs one command and always destroys it. Prefer it whenever you do not need the sandbox afterwards.\n\n```sh\nsandbar run --json -- python3 -c 'print(2 + 2)'\nsandbar run --json --prepared base --timeout 5m -- sh -c 'pip install -q requests && python3 app.py'\n```\n\nPass the command after `--` as separate arguments. Its data is `{exitCode, stdout, stderr, truncated, sandbox}`, where `sandbox` names the sandbox it created and destroyed.\n\n## Longer work: create, use, remove\n\n```sh\nsandbar create --json --name job-1 --prepared base # prints the name; reuse it everywhere\nsandbar cp ./app.py job-1:/home/user/app.py\nsandbar exec --json job-1 --cwd /home/user -- python3 app.py\nsandbar sh --json job-1 'cd /home/user && ls -la | head'\nsandbar cat job-1:/home/user/out.txt\nsandbar rm --json job-1\n```\n\n- Give sandboxes a `--name` so you can find them again; names are lowercase letters, digits and dashes. `--if-not-exists` reuses a live sandbox with that name instead of failing.\n- `exec` takes an argv after `--`; no shell runs. Use `sh` only when you need pipes, redirects or `&&`. For a script that starts with `-`, put it after `--`: `sandbar sh job-1 -- '-n ...'`.\n- `--env K=V` (repeatable), `--cwd`, `--stdin` or `--stdin-file <path>`, `--timeout 30s|5m|1h`, `--max-output 1m`.\n- `cp` copies one file; one side is `<sandbox>:/absolute/path`. `--no-clobber` refuses to overwrite.\n- `--image <oci-ref>` boots an OCI image where the provider supports it; `--prepared <id>` uses a provider template or snapshot. On providers that need a build first (E2B, Daytona), `--image` fails with exit 4 and `UNSUPPORTED` before creating anything until `sandbar image build <oci-ref>` has run once for that profile; later creates reuse the build. Build once rather than passing `--build` in a loop: a build is slow, may cost money and leaves a provider-side resource. `sandbar image ls` shows the builds; `sandbar image forget <name>` drops a record and lists what the provider still holds.\n- `sandbar ls --json` lists known sandboxes with their live state (`running`, `gone`, `unreachable`, ...). `sandbar inspect --json <name>` shows state, expiry and the saved reference.\n\nAlways `rm` what you created before finishing, even after failures. `rm` only destroys sandboxes this CLI created; never pass `--force` or `rm --all` unless the user asked.\n\n## Exit codes\n\n`exec`, `sh` and `run` exit with the **remote command's** exit code. Their own failures always exit **125**, with the error in the JSON document. A command that ran but reported no exit status is 125 with `OUTCOME_UNKNOWN`; its output is in `error.known.exec`. It is never success.\n\nOther commands:\n\n| Code | Meaning | What to do |\n| ---- | ----------------------------------------------------------------- | ---------------------------------------------------- |\n| 0 | success | |\n| 1 | other failure, including a name already taken | read the message |\n| 2 | usage or local error | fix the command; `sandbar <command> --help` |\n| 3 | not found | `sandbar ls` |\n| 4 | unsupported, adapter or tool not installed, limit reached | tell the user; do not work around it |\n| 5 | authentication, permission, or unreadable password-manager secret | tell the user |\n| 6 | **outcome unknown**: the effect may have happened | **do not retry**; run `sandbar ls` / `inspect` first |\n| 7 | retryable (rate limit, capacity, unavailable) before any effect | wait, then retry once or twice |\n| 8 | partial: some steps happened | read `error.known` and `error.details` |\n\n## When things go wrong\n\n- **Never repeat a create after exit 6 or `outcome: \"unknown\"`.** The store keeps the name in status `unknown`; check `sandbar ls --json`. `sandbar rm <name>` first asks the provider how that create ended (never resending it) and destroys the sandbox if one exists. If it still exits 4 because the outcome is unknown, and the user agrees it is gone, `sandbar rm --force <name>` drops the record.\n- **`run` exits 125 with `CLEANUP_FAILED`**: the command result is in `error.known.exec`, but the sandbox is still running. Tell the user and run the `rm` from the hint.\n- **`rm` exits 8**: `error.details` lists what was `removed`, `skipped` and `failed`. Retry only the failed names once the cause is fixed.\n- **`STORE_WRITE_FAILED`**: the sandbox exists but was not recorded. The reference is in `error.known.reference`; save it to a file and destroy with `sandbar rm @file.json`.\n- Interrupting `run` (Ctrl-C, SIGTERM) still destroys its sandbox and exits 130 (143 for SIGTERM) with `INTERRUPTED`. A second signal exits immediately and may leave it running.\n\n## Discover more\n\n`sandbar schema` prints every command, flag and exit code as JSON. `sandbar <command> --help` shows usage and examples.\n";
2249
+ var SKILL_default = "---\nname: sandbar-cli\ndescription: Create, use and clean up cloud sandboxes from the shell with the `sandbar` CLI (Daytona, E2B, boxd, Boat, Vercel, Modal). Use when you need an isolated machine to run untrusted or generated code, try a build or test in a clean environment, or manage sandboxes someone created with sandbar; prefer it over provider-specific CLIs.\n---\n\n# Use sandboxes with the sandbar CLI\n\n`sandbar` creates and manages sandboxes on any Sandbar provider. It is built for agents: it never prompts, every command has a stable `--json` form, and exit codes tell you what to do next.\n\n## Check the setup first\n\n```sh\nsandbar doctor --json # config, credentials, provider connection; creates nothing\nsandbar providers --json # which adapters are installed and which credentials are missing\n```\n\n- If `sandbar` is not on PATH, stop and tell the user; do not install anything yourself.\n- The provider comes from `--profile`, `SANDBAR_PROFILE`, `--provider`, `SANDBAR_PROVIDER`, then `default` in `~/.config/sandbar/sandbar.json`. Do not edit that file or credentials unless the user asks.\n- A missing adapter (exit 4, `ADAPTER_NOT_INSTALLED`), an installed adapter that fails to load (exit 4, `ADAPTER_LOAD_FAILED`) or a missing credential (exit 2) is the user's to fix. Report the `hint` from the error.\n\n**Sandboxes cost money.** Create only what the task needs and destroy it when done.\n\n## Always use JSON\n\nPass `--json` (or set `SANDBAR_JSON=1`). stdout then holds exactly one document:\n\n```json\n{\"ok\": true, \"data\": {...}}\n{\"ok\": false, \"error\": {\"code\": \"...\", \"message\": \"...\", \"retryable\": false, \"outcome\": \"rejected\", \"hint\": \"...\", \"known\": {...}}}\n```\n\nDiagnostics go to stderr. Read `error.hint` before deciding what to do.\n\n## One-shot work: `run`\n\n`run` creates a sandbox, runs one command and always destroys it. Prefer it whenever you do not need the sandbox afterwards.\n\n```sh\nsandbar run --json -- python3 -c 'print(2 + 2)'\nsandbar run --json --prepared base --timeout 5m -- sh -c 'pip install -q requests && python3 app.py'\n```\n\nPass the command after `--` as separate arguments. Its data is `{exitCode, stdout, stderr, truncated, sandbox}`, where `sandbox` names the sandbox it created and destroyed.\n\n## Longer work: create, use, remove\n\n```sh\nsandbar create --json --name job-1 --prepared base # prints the name; reuse it everywhere\nsandbar cp ./app.py job-1:/home/user/app.py\nsandbar exec --json job-1 --cwd /home/user -- python3 app.py\nsandbar sh --json job-1 'cd /home/user && ls -la | head'\nsandbar cat job-1:/home/user/out.txt\nsandbar rm --json job-1\n```\n\n- Give sandboxes a `--name` so you can find them again; names are lowercase letters, digits and dashes. `--if-not-exists` reuses a live sandbox with that name instead of failing.\n- `exec` takes an argv after `--`; no shell runs. Use `sh` only when you need pipes, redirects or `&&`. For a script that starts with `-`, put it after `--`: `sandbar sh job-1 -- '-n ...'`.\n- `--env K=V` (repeatable), `--env-file <path>`, `--cwd`, `--stdin` or `--stdin-file <path>`, `--timeout 30s|5m|1h`, `--max-output 1m`.\n- On `create`, `--env K=V` and `--env-file <path>` set variables for every later `exec`/`sh` in that sandbox; a command's own `--env` wins. For a secret, pass `--env NAME` (no value) to copy it from your environment instead of typing it. Values are never stored or printed. Providers that cannot keep sandbox variables (boxd) fail with exit 4 and `UNSUPPORTED` before creating anything; pass `--env` on each `exec` there. On `run`, `--env` applies to its one command and works everywhere.\n- `cp` copies one file; one side is `<sandbox>:/absolute/path`. `--no-clobber` refuses to overwrite.\n- `--image <oci-ref>` boots an OCI image where the provider supports it; `--prepared <id>` uses a provider template or snapshot. On providers that need a build first (E2B, Daytona), `--image` fails with exit 4 and `UNSUPPORTED` before creating anything until `sandbar image build <oci-ref>` has run once for that profile; later creates reuse the build. Build once rather than passing `--build` in a loop: a build is slow, may cost money and leaves a provider-side resource. `sandbar image ls` shows the builds (`--remote` lists the provider's images instead); `sandbar image rm <name>` deletes a build on the provider and drops its record; `sandbar image forget <name>` only drops the record. Only `image rm` images you built for this task; it refuses builds this CLI did not verifiably create, and never pass `--force` unless the user asked. Exit 6 from `image rm` means the outcome is unknown: run `sandbar image rm <name>` again later to check, which never resends the delete.\n- `sandbar ls --json` lists known sandboxes with their live state (`running`, `gone`, `unreachable`, ...). `sandbar inspect --json <name>` shows state, expiry and the saved reference.\n- `sandbar ls --remote --json` asks the provider instead of the store. `data.coverage` says whether it shows every sandbox in the account scope (`provider-scope`) or only Sandbar-created ones (`sandbar-managed`); `name` is the store name, or null for sandboxes this store does not know. `sandbar adopt <id> [--name <name>]` records a listed sandbox so `exec`, `cp` and `inspect` can reach it. Adopted sandboxes are not the CLI's own: `rm --all` skips them and `rm <name>` needs `--force`. Do not adopt or remove sandboxes the user did not ask about.\n\nAlways `rm` what you created before finishing, even after failures. `rm` only destroys sandboxes this CLI created; never pass `--force` or `rm --all` unless the user asked.\n\n## Exit codes\n\n`exec`, `sh` and `run` exit with the **remote command's** exit code. Their own failures always exit **125**, with the error in the JSON document. A command that ran but reported no exit status is 125 with `OUTCOME_UNKNOWN`; its output is in `error.known.exec`. It is never success.\n\nOther commands:\n\n| Code | Meaning | What to do |\n| ---- | ----------------------------------------------------------------- | ---------------------------------------------------- |\n| 0 | success | |\n| 1 | other failure, including a name already taken | read the message |\n| 2 | usage or local error | fix the command; `sandbar <command> --help` |\n| 3 | not found | `sandbar ls` |\n| 4 | unsupported, adapter or tool not installed, limit reached | tell the user; do not work around it |\n| 5 | authentication, permission, or unreadable password-manager secret | tell the user |\n| 6 | **outcome unknown**: the effect may have happened | **do not retry**; run `sandbar ls` / `inspect` first |\n| 7 | retryable (rate limit, capacity, unavailable) before any effect | wait, then retry once or twice |\n| 8 | partial: some steps happened | read `error.known` and `error.details` |\n\n## When things go wrong\n\n- **Never repeat a create after exit 6 or `outcome: \"unknown\"`.** The store keeps the name in status `unknown`; check `sandbar ls --json`. `sandbar rm <name>` first asks the provider how that create ended (never resending it) and destroys the sandbox if one exists. If it still exits 4 because the outcome is unknown, and the user agrees it is gone, `sandbar rm --force <name>` drops the record.\n- **`run` exits 125 with `CLEANUP_FAILED`**: the command result is in `error.known.exec`, but the sandbox is still running. Tell the user and run the `rm` from the hint.\n- **`rm` exits 8**: `error.details` lists what was `removed`, `skipped` and `failed`. Retry only the failed names once the cause is fixed.\n- **`STORE_WRITE_FAILED`**: the sandbox exists but was not recorded. The reference is in `error.known.reference`; save it to a file and destroy with `sandbar rm @file.json`.\n- Interrupting `run` (Ctrl-C, SIGTERM) still destroys its sandbox and exits 130 (143 for SIGTERM) with `INTERRUPTED`. A second signal exits immediately and may leave it running.\n\n## Discover more\n\n`sandbar schema` prints every command, flag and exit code as JSON. `sandbar <command> --help` shows usage and examples.\n";
1890
2250
 
1891
2251
  // src/skill.ts
1892
2252
  var skillName = "sandbar-cli";
@@ -1959,7 +2319,7 @@ ${marker}
1959
2319
  // src/completion.ts
1960
2320
  var NoSpace = 1;
1961
2321
  var Files = 2;
1962
- var fileFlags = new Set(["config", "state-dir", "stdin-file"]);
2322
+ var fileFlags = new Set(["config", "state-dir", "stdin-file", "env-file"]);
1963
2323
  var sandboxArguments = new Map([
1964
2324
  ["inspect", 1],
1965
2325
  ["exec", 1],
@@ -2046,7 +2406,7 @@ async function complete(words, commands, globals, session) {
2046
2406
  if (command.name === "image") {
2047
2407
  if (positionals.length === 0)
2048
2408
  return { candidates: imageActions.map((action) => ({ value: action })), directive: 0 };
2049
- if (positionals.length === 1 && (positionals[0] === "inspect" || positionals[0] === "forget")) {
2409
+ if (positionals.length === 1 && ["inspect", "forget", "rm"].includes(positionals[0])) {
2050
2410
  const entries = await listAll(await session.store(), "image");
2051
2411
  return {
2052
2412
  candidates: entries.map(({ record }) => ({
@@ -2214,6 +2574,18 @@ var createFlags = {
2214
2574
  description: "blocked or internet; default is the profile's policy"
2215
2575
  },
2216
2576
  label: { type: "string", multiple: true, value: "k=v", description: "Label to set (repeatable)" },
2577
+ env: {
2578
+ type: "string",
2579
+ multiple: true,
2580
+ value: "K=V",
2581
+ description: "Environment variable for every command in the sandbox (repeatable; K alone copies it from here)"
2582
+ },
2583
+ "env-file": {
2584
+ type: "string",
2585
+ multiple: true,
2586
+ value: "path",
2587
+ description: "Read K=V lines into the sandbox environment (repeatable; --env wins)"
2588
+ },
2217
2589
  build: {
2218
2590
  type: "boolean",
2219
2591
  description: "Build --image now if this provider needs a build and none is recorded"
@@ -2225,7 +2597,13 @@ var execFlags = {
2225
2597
  type: "string",
2226
2598
  multiple: true,
2227
2599
  value: "K=V",
2228
- description: "Environment variable (repeatable)"
2600
+ description: "Environment variable for this command (repeatable; K alone copies it from here)"
2601
+ },
2602
+ "env-file": {
2603
+ type: "string",
2604
+ multiple: true,
2605
+ value: "path",
2606
+ description: "Read K=V lines into this command's environment (repeatable; --env wins)"
2229
2607
  },
2230
2608
  stdin: { type: "boolean", description: "Send this process's stdin to the command" },
2231
2609
  "stdin-file": {
@@ -2244,7 +2622,7 @@ var commands = [
2244
2622
  {
2245
2623
  name: "create",
2246
2624
  summary: "Create a sandbox and print its name",
2247
- usage: "sandbar create [--name <name>] [--image <oci-ref> | --prepared <id>] [--network <policy>]",
2625
+ usage: "sandbar create [--name <name>] [--image <oci-ref> | --prepared <id>] [--network <policy>] [--env K=V]...",
2248
2626
  flags: {
2249
2627
  ...createFlags,
2250
2628
  "if-not-exists": {
@@ -2256,22 +2634,43 @@ var commands = [
2256
2634
  remoteExit: false,
2257
2635
  examples: [
2258
2636
  "sandbar create --name web --network internet",
2259
- "sandbar create --name job-42 --if-not-exists --json"
2637
+ "sandbar create --name job-42 --if-not-exists --json",
2638
+ "sandbar create --name ci --env-file .env --env API_TOKEN"
2260
2639
  ],
2261
2640
  run: createCommand
2262
2641
  },
2263
2642
  {
2264
2643
  name: "ls",
2265
2644
  summary: "List sandboxes in the store with their current state",
2266
- usage: "sandbar ls [--offline]",
2645
+ usage: "sandbar ls [--offline | --remote]",
2267
2646
  flags: {
2268
- offline: { type: "boolean", description: "Show stored records without asking providers" }
2647
+ offline: { type: "boolean", description: "Show stored records without asking providers" },
2648
+ remote: {
2649
+ type: "boolean",
2650
+ description: "List the profile's sandboxes from the provider instead of the store"
2651
+ }
2269
2652
  },
2270
2653
  passthrough: false,
2271
2654
  remoteExit: false,
2272
- examples: ["sandbar ls", "sandbar ls --json"],
2655
+ examples: ["sandbar ls", "sandbar ls --json", "sandbar ls --remote --provider daytona"],
2273
2656
  run: lsCommand
2274
2657
  },
2658
+ {
2659
+ name: "adopt",
2660
+ summary: "Add a sandbox from the provider's listing to the store so other commands reach it",
2661
+ usage: "sandbar adopt <id> [--name <name>]",
2662
+ flags: {
2663
+ name: {
2664
+ type: "string",
2665
+ value: "name",
2666
+ description: "Store name (default: generated, like sb-1a2b3)"
2667
+ }
2668
+ },
2669
+ passthrough: false,
2670
+ remoteExit: false,
2671
+ examples: ["sandbar ls --remote", "sandbar adopt 4f1c2a --name worker"],
2672
+ run: adoptCommand
2673
+ },
2275
2674
  {
2276
2675
  name: "inspect",
2277
2676
  summary: "Show a sandbox's state, expiry and reference",
@@ -2340,15 +2739,23 @@ var commands = [
2340
2739
  },
2341
2740
  {
2342
2741
  name: "image",
2343
- summary: "Build OCI images for providers that need it, and list or forget the builds",
2344
- usage: "sandbar image build <oci-ref> [--name <name>] [--refresh] | ls | inspect <name> | forget <name>",
2742
+ summary: "Build OCI images for providers that need it, and list, delete or forget the builds",
2743
+ usage: "sandbar image build <oci-ref> [--name <name>] [--refresh] | ls [--remote] | inspect <name> | rm <name> [--force] | forget <name>",
2345
2744
  flags: {
2346
2745
  name: {
2347
2746
  type: "string",
2348
2747
  value: "name",
2349
2748
  description: "Record name for a build (default: from the reference, like python-3-12)"
2350
2749
  },
2351
- refresh: { type: "boolean", description: "Build again even if this reference is recorded" }
2750
+ refresh: { type: "boolean", description: "Build again even if this reference is recorded" },
2751
+ remote: {
2752
+ type: "boolean",
2753
+ description: "List the images on the provider for this profile, not the local records"
2754
+ },
2755
+ force: {
2756
+ type: "boolean",
2757
+ description: "Delete a recorded image this CLI did not verifiably create"
2758
+ }
2352
2759
  },
2353
2760
  passthrough: false,
2354
2761
  remoteExit: false,
@@ -2356,6 +2763,8 @@ var commands = [
2356
2763
  "sandbar image build python:3.12 --provider e2b",
2357
2764
  "sandbar create --image python:3.12 --provider e2b",
2358
2765
  "sandbar image ls",
2766
+ "sandbar image ls --remote --provider e2b",
2767
+ "sandbar image rm python-3-12",
2359
2768
  "sandbar image forget python-3-12"
2360
2769
  ],
2361
2770
  run: imageCommand
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandbar-sdk/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Agent-first command line for creating and managing Sandbar sandboxes.",
@@ -20,17 +20,17 @@
20
20
  "build": "rm -rf dist && bun build src/main.ts --target node --format esm --outdir dist --external sandbar-sdk --external @sandbar-sdk/adapter --external zod"
21
21
  },
22
22
  "dependencies": {
23
- "@sandbar-sdk/adapter": "0.5.0",
24
- "sandbar-sdk": "0.5.0",
23
+ "@sandbar-sdk/adapter": "0.6.0",
24
+ "sandbar-sdk": "0.6.0",
25
25
  "zod": "4.6.5"
26
26
  },
27
27
  "devDependencies": {
28
- "@sandbar-sdk/boat": "0.5.0",
29
- "@sandbar-sdk/boxd": "0.5.0",
30
- "@sandbar-sdk/daytona": "0.5.0",
31
- "@sandbar-sdk/e2b": "0.5.0",
28
+ "@sandbar-sdk/boat": "0.6.0",
29
+ "@sandbar-sdk/boxd": "0.6.0",
30
+ "@sandbar-sdk/daytona": "0.6.0",
31
+ "@sandbar-sdk/e2b": "0.6.0",
32
32
  "@sandbar-sdk/modal": "0.0.0",
33
- "@sandbar-sdk/vercel": "0.5.0",
33
+ "@sandbar-sdk/vercel": "0.6.0",
34
34
  "@sandbar/provider-fake": "0.0.0"
35
35
  },
36
36
  "license": "Apache-2.0",