@pirol/nido 0.2.1 → 0.3.1

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
@@ -75,8 +75,24 @@ Instead of an agent, run any command. It needs no key:
75
75
  | `files.exclude` | Patterns like in `.gitignore`; `.git` and `.nido` are always excluded |
76
76
  | `secrets` | Names of stored secrets, never values |
77
77
  | `network` | Hosts the sandbox may reach on port 443; everything else is blocked |
78
+ | `storage` | Storage volumes, mounted at `/data/<name>` |
78
79
  | `output` | Folder that comes back after the run; default `output/` |
79
80
 
81
+ ## Storage
82
+
83
+ Keep large data in storage, independently of sandboxes:
84
+
85
+ ```bash
86
+ nido storage put ./data my-data # upload straight to storage in Frankfurt
87
+ nido storage ls # volumes with file count and size
88
+ nido storage ls my-data # files in one volume
89
+ nido storage rm my-data # only once no sandbox uses it
90
+ ```
91
+
92
+ Add `"storage": ["my-data"]` to `nido.json` (or `nido create shop --storage my-data`), and the
93
+ sandbox reads and writes the data at `/data/my-data`, without copying. Volumes are attached when a
94
+ sandbox is created; after changing `storage`, run `nido run --fresh`.
95
+
80
96
  ## Secrets
81
97
 
82
98
  ```bash
@@ -109,11 +125,11 @@ Paths inside a sandbox must be below `/sandbox`.
109
125
 
110
126
  ## Limits in the beta
111
127
 
112
- - Up to 5 sandboxes per account.
128
+ - Up to 5 sandboxes per account, and 10 storage volumes with 100 GB in total.
113
129
  - No interactive shell yet; use `nido exec`.
114
130
  - The agent runs in the foreground. If the connection drops, `nido pull` fetches what is already
115
131
  in `output/`.
116
- - No persistent workspaces or backups yet. Keep anything important in `output/`.
132
+ - No backups or templates of a sandbox yet. Keep anything important in `output/` or in storage.
117
133
 
118
134
  ## Development
119
135
 
package/dist/api.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Client for pirol-agents-api. Files travel as tar.gz streams, command
2
2
  // output comes back as NDJSON while the command runs.
3
3
  import { spawn } from "node:child_process";
4
+ import { createReadStream } from "node:fs";
5
+ import { request } from "node:https";
4
6
  import { Readable } from "node:stream";
5
7
  import { loadConfig } from "./config.js";
6
8
  export class ApiError extends Error {
@@ -152,4 +154,37 @@ export class Api {
152
154
  removeSecret(name) {
153
155
  return this.json("DELETE", `/v1/secrets/${encodeURIComponent(name)}`);
154
156
  }
157
+ storageList() {
158
+ return this.json("GET", "/v1/storage");
159
+ }
160
+ storageFiles(name) {
161
+ return this.json("GET", `/v1/storage/${encodeURIComponent(name)}`);
162
+ }
163
+ async storageCreate(name) {
164
+ return (await this.json("PUT", `/v1/storage/${encodeURIComponent(name)}`)).result;
165
+ }
166
+ /** One upload URL per file; each takes exactly the given number of bytes. */
167
+ async storageUploads(name, files) {
168
+ return (await this.json("POST", `/v1/storage/${encodeURIComponent(name)}/uploads`, { files })).uploads;
169
+ }
170
+ storageRemove(name) {
171
+ return this.json("DELETE", `/v1/storage/${encodeURIComponent(name)}`);
172
+ }
173
+ }
174
+ /** Sends a local file to an upload URL from storageUploads (straight to the storage). */
175
+ export function putFile(url, file, size) {
176
+ return new Promise((resolve, reject) => {
177
+ const req = request(url, { method: "PUT", headers: { "content-length": size } }, (res) => {
178
+ let body = "";
179
+ res.on("data", (d) => (body += d.toString("utf8")));
180
+ res.on("end", () => res.statusCode === 200 || res.statusCode === 201
181
+ ? resolve()
182
+ : reject(new Error(`upload failed: HTTP ${res.statusCode} ${body.slice(0, 200)}`)));
183
+ });
184
+ req.on("error", reject);
185
+ if (size === 0)
186
+ req.end();
187
+ else
188
+ createReadStream(file).on("error", reject).pipe(req);
189
+ });
155
190
  }
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { copy, createSandbox, exec, init, list, login, logout, pull, runFolder, secretList, secretRemove, secretSet, simple, whoami, } from "./commands.js";
5
+ import { storageLs, storagePut, storageRm } from "./storage.js";
5
6
  const HELP = `nido — run agents in isolated sandboxes (closed beta)
6
7
 
7
8
  Start
@@ -14,13 +15,18 @@ Folder workflow
14
15
  nido pull bring output/ back again
15
16
 
16
17
  Sandboxes
17
- nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]…
18
+ nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]… [--storage <name>]…
18
19
  nido ls list your sandboxes
19
20
  nido exec <name> -- <cmd>… run a command
20
21
  nido cp <src> <dst> copy files; write the sandbox side as <name>:<path>
21
22
  nido logs <name> sandbox logs
22
23
  nido stop|start|rm <name>
23
24
 
25
+ Storage (lives on without sandboxes, mounted at /data/<name>)
26
+ nido storage put <folder|file> <name> upload, straight to storage
27
+ nido storage ls [name]
28
+ nido storage rm <name>
29
+
24
30
  Secrets (stored once, never readable again)
25
31
  nido secret set <name> [--from-gh] anthropic, openai, github
26
32
  nido secret ls
@@ -69,6 +75,7 @@ async function main(argv) {
69
75
  image: flags.get("image")?.[0],
70
76
  secrets: flags.get("secret") ?? [],
71
77
  network: flags.get("network") ?? [],
78
+ storage: flags.get("storage") ?? [],
72
79
  });
73
80
  return 0;
74
81
  case "ls":
@@ -93,6 +100,8 @@ async function main(argv) {
93
100
  return 0;
94
101
  case "secret":
95
102
  return secret(args, flags);
103
+ case "storage":
104
+ return storage(args);
96
105
  default:
97
106
  throw new Error(`Unknown command "${cmd}". See: nido help`);
98
107
  }
@@ -109,7 +118,19 @@ async function secret(args, flags) {
109
118
  throw new Error("Usage: nido secret set <name> | nido secret ls | nido secret rm <name>");
110
119
  return 0;
111
120
  }
112
- const VALUE_FLAGS = new Set(["image", "secret", "network"]);
121
+ async function storage(args) {
122
+ const [sub, a, b] = args;
123
+ if (sub === "put" && a && b)
124
+ await storagePut(a, b);
125
+ else if (sub === "ls")
126
+ await storageLs(a);
127
+ else if (sub === "rm" && a)
128
+ await storageRm(a);
129
+ else
130
+ throw new Error("Usage: nido storage put <folder|file> <name> | nido storage ls [name] | nido storage rm <name>");
131
+ return 0;
132
+ }
133
+ const VALUE_FLAGS = new Set(["image", "secret", "network", "storage"]);
113
134
  export function parse(argv) {
114
135
  const flags = new Map();
115
136
  const args = [];
package/dist/commands.js CHANGED
@@ -164,7 +164,13 @@ async function ensureSandbox(api, m, fresh) {
164
164
  }
165
165
  if (!info) {
166
166
  say(`Creating sandbox ${m.name}…`);
167
- const created = await api.create({ name: m.name, image: m.imageSet ? m.image : undefined, secrets: m.secrets, network: m.network });
167
+ const created = await api.create({
168
+ name: m.name,
169
+ image: m.imageSet ? m.image : undefined,
170
+ secrets: m.secrets,
171
+ network: m.network,
172
+ storage: m.storage,
173
+ });
168
174
  say(`Sandbox ${m.name} ready in ${(created.ready_ms / 1000).toFixed(1)} s`);
169
175
  return true;
170
176
  }
package/dist/manifest.js CHANGED
@@ -25,7 +25,7 @@ export function parseManifest(raw, folderName) {
25
25
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
26
26
  throw new Error(`${MANIFEST} must be an object`);
27
27
  const m = raw;
28
- const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "output"];
28
+ const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "storage", "output"];
29
29
  const unknown = Object.keys(m).filter((key) => !known.includes(key) && !key.startsWith("$"));
30
30
  if (unknown.length)
31
31
  throw new Error(`${MANIFEST}: unknown field ${unknown.map((k) => `"${k}"`).join(", ")}`);
@@ -65,6 +65,11 @@ export function parseManifest(raw, folderName) {
65
65
  exclude,
66
66
  secrets: stringList(m, "secrets"),
67
67
  network: stringList(m, "network"),
68
+ storage: stringList(m, "storage").map((name) => {
69
+ if (!isVolumeName(name))
70
+ throw new Error(`${MANIFEST}: "${name}" in "storage" is not a valid name`);
71
+ return name;
72
+ }),
68
73
  output,
69
74
  };
70
75
  }
@@ -78,6 +83,10 @@ export function sandboxName(input) {
78
83
  .replace(/-+$/, "");
79
84
  return name || "nest";
80
85
  }
86
+ /** Storage names, like sandbox names: lowercase letters, digits and dashes, at most 40. */
87
+ export function isVolumeName(name) {
88
+ return /^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$/.test(name);
89
+ }
81
90
  function optionalString(m, key) {
82
91
  const value = m[key];
83
92
  if (value === undefined)
@@ -0,0 +1,115 @@
1
+ // nido storage: volumes in object storage that live independently of
2
+ // sandboxes. Files go straight to the storage; the API only hands out an
3
+ // upload URL per file.
4
+ import { existsSync, statSync } from "node:fs";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { Api, putFile } from "./api.js";
7
+ import { excludeMatcher, listFiles } from "./files.js";
8
+ import { isVolumeName } from "./manifest.js";
9
+ const say = (text) => void process.stderr.write(text + "\n");
10
+ const BATCH = 500;
11
+ const PARALLEL = 8;
12
+ export function checkVolumeName(name) {
13
+ if (!isVolumeName(name))
14
+ throw new Error(`"${name}" is not a valid storage name: lowercase letters, digits and dashes, at most 40 characters`);
15
+ return name;
16
+ }
17
+ /** Local files to upload: a folder's contents, or one file. Paths use "/". */
18
+ export function localFiles(source) {
19
+ const full = resolve(source);
20
+ if (!existsSync(full))
21
+ throw new Error(`${source} does not exist`);
22
+ if (statSync(full).isFile())
23
+ return { root: dirname(full), files: [{ path: basename(full), size: statSync(full).size }] };
24
+ const paths = listFiles(full, excludeMatcher([]));
25
+ return { root: full, files: paths.map((path) => ({ path, size: statSync(join(full, path)).size })) };
26
+ }
27
+ /** nido storage put <folder|file> <name> */
28
+ export async function storagePut(source, name) {
29
+ checkVolumeName(name);
30
+ const { root, files } = localFiles(source);
31
+ if (!files.length)
32
+ throw new Error(`${source} has no files`);
33
+ const api = Api.fromConfig();
34
+ if ((await api.storageCreate(name)) === "created")
35
+ say(`Created storage ${name}`);
36
+ const total = files.reduce((sum, f) => sum + f.size, 0);
37
+ say(`Uploading ${files.length} file${files.length === 1 ? "" : "s"} (${size(total)}) to ${name}…`);
38
+ let done = 0;
39
+ let sent = 0;
40
+ const started = Date.now();
41
+ const progress = () => {
42
+ if (process.stderr.isTTY)
43
+ process.stderr.write(`\r ${done}/${files.length} files, ${size(sent)} of ${size(total)} `);
44
+ };
45
+ for (let i = 0; i < files.length; i += BATCH) {
46
+ const batch = files.slice(i, i + BATCH);
47
+ const queue = await api.storageUploads(name, batch);
48
+ const bySize = new Map(batch.map((f) => [f.path, f.size]));
49
+ const worker = async () => {
50
+ for (let item = queue.shift(); item; item = queue.shift()) {
51
+ const bytes = bySize.get(item.path);
52
+ await uploadWithRetry(api, name, item, join(root, item.path), bytes);
53
+ done += 1;
54
+ sent += bytes;
55
+ progress();
56
+ }
57
+ };
58
+ await Promise.all(Array.from({ length: PARALLEL }, worker));
59
+ }
60
+ if (process.stderr.isTTY)
61
+ process.stderr.write("\n");
62
+ const seconds = (Date.now() - started) / 1000;
63
+ say(`Done in ${seconds.toFixed(1)} s. Sandboxes see it at /data/${name} with "storage": ["${name}"] in nido.json`);
64
+ }
65
+ async function uploadWithRetry(api, name, item, file, bytes) {
66
+ let url = item.url;
67
+ for (let attempt = 1;; attempt++) {
68
+ try {
69
+ return await putFile(url, file, bytes);
70
+ }
71
+ catch (err) {
72
+ if (attempt === 3)
73
+ throw new Error(`${item.path}: ${err.message}`);
74
+ // A failed session cannot take the whole file again; ask for a new one.
75
+ url = (await api.storageUploads(name, [{ path: item.path, size: bytes }]))[0].url;
76
+ }
77
+ }
78
+ }
79
+ /** nido storage ls [name] */
80
+ export async function storageLs(name) {
81
+ const api = Api.fromConfig();
82
+ if (name) {
83
+ const volume = await api.storageFiles(checkVolumeName(name));
84
+ for (const f of volume.files)
85
+ process.stdout.write(`${size(f.size).padStart(10)} ${f.path}\n`);
86
+ if (volume.total_files > volume.files.length)
87
+ say(`… ${volume.total_files - volume.files.length} more files`);
88
+ say(`${volume.total_files} files, ${size(volume.bytes)}`);
89
+ return;
90
+ }
91
+ const { volumes, limit_bytes } = await api.storageList();
92
+ if (!volumes.length)
93
+ return say("No storage yet. Upload a folder with: nido storage put ./data my-data");
94
+ const width = Math.max(4, ...volumes.map((v) => v.name.length));
95
+ process.stdout.write(`${"NAME".padEnd(width)} ${"FILES".padStart(7)} ${"SIZE".padStart(10)}\n`);
96
+ for (const v of volumes)
97
+ process.stdout.write(`${v.name.padEnd(width)} ${String(v.files).padStart(7)} ${size(v.bytes).padStart(10)}\n`);
98
+ const used = volumes.reduce((sum, v) => sum + v.bytes, 0);
99
+ say(`${size(used)} of ${size(limit_bytes)} used`);
100
+ }
101
+ /** nido storage rm <name> */
102
+ export async function storageRm(name) {
103
+ await Api.fromConfig().storageRemove(checkVolumeName(name));
104
+ say(`Removed storage ${name}`);
105
+ }
106
+ export function size(bytes) {
107
+ const units = ["B", "KB", "MB", "GB", "TB"];
108
+ let value = bytes;
109
+ let unit = 0;
110
+ while (value >= 1024 && unit < units.length - 1) {
111
+ value /= 1024;
112
+ unit++;
113
+ }
114
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pirol/nido",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Run agents in isolated sandboxes. Give it a folder, get results back.",
5
5
  "type": "module",
6
6
  "bin": {