@pirol/nido 0.2.1 → 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 +18 -2
- package/dist/api.js +35 -0
- package/dist/cli.js +23 -2
- package/dist/commands.js +7 -1
- package/dist/manifest.js +10 -1
- package/dist/storage.js +115 -0
- package/package.json +1 -1
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 Cloud 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
|
|
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 Cloud 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 Cloud 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
|
-
|
|
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({
|
|
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)
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// nido storage: volumes in Cloud Storage that live independently of
|
|
2
|
+
// sandboxes. Files go straight to Cloud 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
|
+
}
|