@pirol/nido 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # nido
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ CLI für Agenten-Sandboxes, wie im Konzept
4
+ [Agenten-Sandboxes auf OpenShell](../docs/concepts/agent-sandboxes.md)
5
+ beschrieben. Man gibt nido einen Ordner, nido lässt darin einen Agenten
6
+ arbeiten und holt die Ergebnisse zurück. Jede Sandbox läuft in einer eigenen
7
+ Kata-VM.
8
+
9
+ Stand: geschlossene Beta (#686–#688). nido spricht mit `pirol-agents-api`
10
+ unter `https://nido.pirol.ai`. Die API prüft den Pirol-Schlüssel, trennt die
11
+ Kunden und reicht die Aufrufe an das OpenShell-Gateway weiter. Die Doku für
12
+ Kunden steht unter [pirol.ai/docs/sandboxes](https://pirol.ai/docs/sandboxes).
13
+
14
+ ## Einrichten
15
+
16
+ Voraussetzung: Node 20 oder neuer und ein Pirol-Konto, das für die Beta
17
+ freigeschaltet ist (`BETA_ALLOWLIST` in
18
+ [deploy-agents-api.sh](../infra/agent-sandbox/deploy-agents-api.sh)).
19
+
20
+ ```bash
21
+ npm install -g @pirol/nido
22
+ nido login
23
+ ```
24
+
25
+ `nido login` läuft wie `kyte login`: Der Browser öffnet pirol.ai, man
26
+ bestätigt, und nido legt den Schlüssel in `~/.nido/config.json` ab (nur für
27
+ den Nutzer lesbar). Ohne Browser hilft `nido login --manual`. In CI reicht
28
+ die Umgebungsvariable `PIROL_API_KEY`. `NIDO_API_URL` zeigt nido auf eine
29
+ andere API, etwa eine lokale.
30
+
31
+ Aus dem Repo statt von npm:
32
+
33
+ ```bash
34
+ cd nido && npm install && npm run build && npm link
35
+ ```
36
+
37
+ ## Ordner mit `nido.json`
38
+
39
+ ```bash
40
+ mkdir survey && cd survey
41
+ cp ~/Downloads/results.csv .
42
+ nido init # writes nido.json for Claude Code
43
+ nido secret set anthropic # once, asks for the key without echo
44
+ nido run
45
+ ```
46
+
47
+ `nido.json`:
48
+
49
+ ```json
50
+ {
51
+ "agent": "claude",
52
+ "task": "Analyze results.csv and write a report to output/report.md",
53
+ "files": { "exclude": ["node_modules", ".venv", "*.log"] },
54
+ "secrets": ["anthropic"],
55
+ "network": ["pypi.org", "files.pythonhosted.org"],
56
+ "output": "output/"
57
+ }
58
+ ```
59
+
60
+ Was `nido run` macht:
61
+
62
+ 1. Sandbox anlegen, falls es sie noch nicht gibt. Der Name kommt aus
63
+ `name` oder dem Ordnernamen. Ein neuer Start dauert etwa 20 Sekunden.
64
+ 2. Geänderte Dateien hochladen. nido merkt sich die Hashes in
65
+ `.nido/state.json`. Unveränderte Dateien gehen nicht noch einmal hoch,
66
+ lokal gelöschte verschwinden auch in der Sandbox.
67
+ 3. Den Agenten starten. Bei Claude zeigt nido jeden Schritt in einer Zeile.
68
+ 4. `output/` zurückholen. Neue Dateien landen im Ordner. Eine Datei, die
69
+ man lokal geändert hat, überschreibt nido nicht. Die Fassung aus der
70
+ Sandbox heißt dann zum Beispiel `report.remote.md`.
71
+
72
+ Die Sandbox bleibt danach stehen. Der nächste `nido run` nutzt sie weiter,
73
+ `nido run --fresh` fängt neu an.
74
+
75
+ Statt eines Agenten geht auch ein Befehl, das braucht keinen Schlüssel:
76
+
77
+ ```json
78
+ { "command": "python analyze.py", "output": "output/" }
79
+ ```
80
+
81
+ Felder:
82
+
83
+ | Feld | Bedeutung |
84
+ |---|---|
85
+ | `agent` | `claude`, weitere folgen |
86
+ | `task` | Auftrag an den Agenten |
87
+ | `command` | statt `agent`: Befehl als Text (läuft in bash) oder Liste |
88
+ | `name` | Name der Sandbox, sonst der Ordnername |
89
+ | `image` | eigenes Image, sonst das OpenShell-Community-Image mit Claude Code, Codex und OpenCode |
90
+ | `files.exclude` | Muster wie in `.gitignore`. `.git` und `.nido` bleiben immer draußen |
91
+ | `secrets` | Namen von Secrets, nie die Werte |
92
+ | `network` | Hosts, die die Sandbox auf Port 443 erreichen darf. Sonst ist das Netz zu |
93
+ | `output` | Ordner, den nido zurückholt, Standard `output/` |
94
+
95
+ ## Secrets
96
+
97
+ ```bash
98
+ nido secret set anthropic # Anthropic API key
99
+ nido secret set github --from-gh # token from the GitHub CLI
100
+ nido secret set openai
101
+ nido secret ls
102
+ nido secret rm github
103
+ ```
104
+
105
+ Der Wert geht per HTTPS an die API und von dort über eine
106
+ Umgebungsvariable an das Gateway, nie als Argument. Lesen kann ihn danach
107
+ niemand mehr, auch der Kunde nicht.
108
+ In der Sandbox steht nur ein Platzhalter. Der Proxy von OpenShell setzt den
109
+ echten Wert erst beim Aufruf ein, und nur für die Hosts des Dienstes, etwa
110
+ `api.anthropic.com`. Andere Namen kennt nido noch nicht.
111
+
112
+ ## Weitere Befehle
113
+
114
+ ```bash
115
+ nido create shop --secret anthropic --network pypi.org
116
+ nido ls
117
+ nido exec shop -- ls -la /sandbox
118
+ nido cp ./data shop:/sandbox/data
119
+ nido cp shop:/sandbox/work/output ./out
120
+ nido logs shop
121
+ nido stop shop && nido start shop
122
+ nido rm shop
123
+ ```
124
+
125
+ ## Entwicklung
126
+
127
+ ```bash
128
+ npm test # unit tests
129
+ npm run build # dist/
130
+ ```
131
+
132
+ Die Regeln für Hoch- und Herunterladen stecken in `src/sync.ts` und sind
133
+ reine Funktionen. Die Aufrufe an die API liegen in `src/api.ts`, die Anmeldung
134
+ in `src/login.ts`. Mit OpenShell spricht nur noch
135
+ [agents-api](../agents-api).
136
+
137
+ Veröffentlichen: `npm publish` im Ordner `nido` (baut vorher neu).
138
+
139
+ ## Grenzen
140
+
141
+ - Höchstens 5 Sandboxes pro Konto in der Beta.
142
+ - `nido shell` fehlt noch. Stattdessen `nido exec`.
143
+ - Der Agent läuft im Vordergrund. Bricht die Verbindung ab, holt
144
+ `nido pull` das, was schon in `output/` liegt.
145
+ - Keine dauerhaften Arbeitsbereiche und kein Backup (#695).
package/dist/agents.js ADDED
@@ -0,0 +1,52 @@
1
+ /** Working folder in the sandbox. The local folder ends up here. */
2
+ export const WORKDIR = "/sandbox/work";
3
+ /** The command that runs in the sandbox for a manifest. */
4
+ export function agentCommand(m) {
5
+ if (m.agent === "claude") {
6
+ const task = `${m.task}\n\nPut every file meant for the user into ${m.output}/ (relative to the current folder).`;
7
+ return ["claude", "-p", task, "--dangerously-skip-permissions", "--output-format", "stream-json", "--verbose"];
8
+ }
9
+ if (typeof m.command === "string")
10
+ return ["bash", "-lc", m.command];
11
+ if (Array.isArray(m.command))
12
+ return m.command;
13
+ throw new Error("Nothing to run");
14
+ }
15
+ /**
16
+ * Claude Code's stream-json output, shortened to one line per step.
17
+ * Returns undefined for events that are not worth showing.
18
+ */
19
+ export function formatClaudeEvent(line) {
20
+ let event;
21
+ try {
22
+ event = JSON.parse(line);
23
+ }
24
+ catch {
25
+ return line.trim() ? line : undefined;
26
+ }
27
+ if (event.type === "assistant") {
28
+ const parts = [];
29
+ for (const item of event.message?.content ?? []) {
30
+ if (item.type === "text" && item.text?.trim())
31
+ parts.push(item.text.trim());
32
+ if (item.type === "tool_use")
33
+ parts.push(`▸ ${item.name} ${summarizeInput(item.input)}`.trimEnd());
34
+ }
35
+ return parts.length ? parts.join("\n") : undefined;
36
+ }
37
+ if (event.type === "result") {
38
+ const seconds = event.duration_ms ? `${Math.round(event.duration_ms / 1000)} s` : "";
39
+ const cost = typeof event.total_cost_usd === "number" ? `$${event.total_cost_usd.toFixed(2)}` : "";
40
+ const status = event.is_error ? "failed" : "done";
41
+ return `■ ${status}${seconds || cost ? ` (${[seconds, cost].filter(Boolean).join(", ")})` : ""}`;
42
+ }
43
+ return undefined;
44
+ }
45
+ function summarizeInput(input) {
46
+ if (!input)
47
+ return "";
48
+ const value = input.command ?? input.file_path ?? input.path ?? input.pattern ?? input.url ?? input.description;
49
+ const text = typeof value === "string" ? value : "";
50
+ const oneLine = text.replace(/\s+/g, " ").trim();
51
+ return oneLine.length > 100 ? `${oneLine.slice(0, 97)}…` : oneLine;
52
+ }
package/dist/api.js ADDED
@@ -0,0 +1,155 @@
1
+ // Client for pirol-agents-api. Files travel as tar.gz streams, command
2
+ // output comes back as NDJSON while the command runs.
3
+ import { spawn } from "node:child_process";
4
+ import { Readable } from "node:stream";
5
+ import { loadConfig } from "./config.js";
6
+ export class ApiError extends Error {
7
+ status;
8
+ constructor(status, message) {
9
+ super(message);
10
+ this.status = status;
11
+ }
12
+ }
13
+ export class Api {
14
+ base;
15
+ key;
16
+ constructor(base, key) {
17
+ this.base = base;
18
+ this.key = key;
19
+ }
20
+ static fromConfig() {
21
+ const config = loadConfig();
22
+ if (!config.key)
23
+ throw new Error("Not logged in. Run: nido login");
24
+ return new Api(config.api, config.key);
25
+ }
26
+ async call(method, path, init = {}) {
27
+ let res;
28
+ try {
29
+ res = await fetch(`${this.base}${path}`, {
30
+ ...init,
31
+ method,
32
+ headers: { authorization: `Bearer ${this.key}`, ...(init.headers ?? {}) },
33
+ });
34
+ }
35
+ catch (err) {
36
+ throw new Error(`Cannot reach ${this.base}: ${err.message}`);
37
+ }
38
+ if (!res.ok) {
39
+ const body = (await res.json().catch(() => ({})));
40
+ throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`);
41
+ }
42
+ return res;
43
+ }
44
+ async json(method, path, body) {
45
+ const res = await this.call(method, path, {
46
+ headers: body === undefined ? {} : { "content-type": "application/json" },
47
+ body: body === undefined ? undefined : JSON.stringify(body),
48
+ });
49
+ return (res.status === 204 ? undefined : await res.json());
50
+ }
51
+ whoami() {
52
+ return this.json("GET", "/v1/whoami");
53
+ }
54
+ async list() {
55
+ return (await this.json("GET", "/v1/sandboxes")).sandboxes;
56
+ }
57
+ async get(name) {
58
+ try {
59
+ return await this.json("GET", `/v1/sandboxes/${encodeURIComponent(name)}`);
60
+ }
61
+ catch (err) {
62
+ if (err instanceof ApiError && err.status === 404)
63
+ return undefined;
64
+ throw err;
65
+ }
66
+ }
67
+ create(spec) {
68
+ return this.json("POST", "/v1/sandboxes", spec);
69
+ }
70
+ remove(name) {
71
+ return this.json("DELETE", `/v1/sandboxes/${encodeURIComponent(name)}`);
72
+ }
73
+ stop(name) {
74
+ return this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/stop`);
75
+ }
76
+ start(name) {
77
+ return this.json("POST", `/v1/sandboxes/${encodeURIComponent(name)}/start`);
78
+ }
79
+ async logs(name, lines = 200) {
80
+ return (await this.call("GET", `/v1/sandboxes/${encodeURIComponent(name)}/logs?lines=${lines}`)).text();
81
+ }
82
+ /** Runs a command; onOut/onErr get output as it arrives. Returns the exit code. */
83
+ async exec(name, command, opts) {
84
+ const res = await this.call("POST", `/v1/sandboxes/${encodeURIComponent(name)}/exec`, {
85
+ headers: { "content-type": "application/json" },
86
+ body: JSON.stringify({ command, workdir: opts.workdir, stdin: opts.stdin }),
87
+ });
88
+ let exit = 1;
89
+ let partial = "";
90
+ const decoder = new TextDecoder();
91
+ for await (const chunk of res.body) {
92
+ const lines = (partial + decoder.decode(chunk, { stream: true })).split("\n");
93
+ partial = lines.pop() ?? "";
94
+ for (const line of lines) {
95
+ if (!line)
96
+ continue;
97
+ const event = JSON.parse(line);
98
+ if (event.o !== undefined)
99
+ opts.onOut(event.o);
100
+ if (event.e !== undefined)
101
+ opts.onErr(event.e);
102
+ if (event.exit !== undefined)
103
+ exit = event.exit;
104
+ }
105
+ }
106
+ return exit;
107
+ }
108
+ /** Uploads the contents of a local folder into `path` (merging). */
109
+ async upload(name, localDir, path) {
110
+ // COPYFILE_DISABLE: macOS tar would add a ._ file with metadata for every file.
111
+ const tar = spawn("tar", ["-czf", "-", "-C", localDir, "."], {
112
+ stdio: ["ignore", "pipe", "pipe"],
113
+ env: { ...process.env, COPYFILE_DISABLE: "1" },
114
+ });
115
+ let stderr = "";
116
+ tar.stderr.on("data", (d) => (stderr += d.toString("utf8")));
117
+ const done = new Promise((resolve, reject) => tar.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`tar failed: ${stderr.trim()}`)))));
118
+ await this.call("PUT", `/v1/sandboxes/${encodeURIComponent(name)}/files?path=${encodeURIComponent(path)}`, {
119
+ headers: { "content-type": "application/gzip" },
120
+ body: Readable.toWeb(tar.stdout),
121
+ // @ts-expect-error Node's fetch needs this for streamed request bodies.
122
+ duplex: "half",
123
+ });
124
+ await done;
125
+ }
126
+ /** Downloads `path` into a local folder. Returns false if it does not exist. */
127
+ async download(name, path, localDir) {
128
+ let res;
129
+ try {
130
+ res = await this.call("GET", `/v1/sandboxes/${encodeURIComponent(name)}/files?path=${encodeURIComponent(path)}`);
131
+ }
132
+ catch (err) {
133
+ if (err instanceof ApiError && err.status === 404)
134
+ return false;
135
+ throw err;
136
+ }
137
+ await new Promise((resolve, reject) => {
138
+ const tar = spawn("tar", ["-xzf", "-", "-C", localDir], { stdio: ["pipe", "ignore", "pipe"] });
139
+ let stderr = "";
140
+ tar.stderr.on("data", (d) => (stderr += d.toString("utf8")));
141
+ tar.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`tar failed: ${stderr.trim()}`))));
142
+ Readable.fromWeb(res.body).pipe(tar.stdin);
143
+ });
144
+ return true;
145
+ }
146
+ async secrets() {
147
+ return (await this.json("GET", "/v1/secrets")).secrets;
148
+ }
149
+ async setSecret(name, value) {
150
+ return (await this.json("PUT", `/v1/secrets/${encodeURIComponent(name)}`, { value })).result;
151
+ }
152
+ removeSecret(name) {
153
+ return this.json("DELETE", `/v1/secrets/${encodeURIComponent(name)}`);
154
+ }
155
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, realpathSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { copy, createSandbox, exec, init, list, login, logout, pull, runFolder, secretList, secretRemove, secretSet, simple, whoami, } from "./commands.js";
5
+ const HELP = `nido — run agents in isolated sandboxes (closed beta)
6
+
7
+ Start
8
+ nido login [--manual] log in with your Pirol account
9
+ nido whoami | nido logout
10
+
11
+ Folder workflow
12
+ nido init [--command] write a nido.json into this folder
13
+ nido run [--fresh] upload the folder, run the agent, bring output/ back
14
+ nido pull bring output/ back again
15
+
16
+ Sandboxes
17
+ nido create <name> [--image <ref>] [--secret <name>]… [--network <host>]…
18
+ nido ls list your sandboxes
19
+ nido exec <name> -- <cmd>… run a command
20
+ nido cp <src> <dst> copy files; write the sandbox side as <name>:<path>
21
+ nido logs <name> sandbox logs
22
+ nido stop|start|rm <name>
23
+
24
+ Secrets (stored once, never readable again)
25
+ nido secret set <name> [--from-gh] anthropic, openai, github
26
+ nido secret ls
27
+ nido secret rm <name>
28
+
29
+ Docs: https://pirol.ai/docs/sandboxes
30
+ `;
31
+ async function main(argv) {
32
+ const [cmd, ...rest] = argv;
33
+ const { flags, args, passthrough } = parse(rest);
34
+ const one = (what) => {
35
+ if (!args[0])
36
+ throw new Error(`Usage: nido ${cmd} <${what}>`);
37
+ return args[0];
38
+ };
39
+ switch (cmd) {
40
+ case undefined:
41
+ case "help":
42
+ case "--help":
43
+ case "-h":
44
+ process.stdout.write(HELP);
45
+ return 0;
46
+ case "version":
47
+ case "--version":
48
+ process.stdout.write(`nido ${version()}\n`);
49
+ return 0;
50
+ case "login":
51
+ await login({ manual: flags.has("manual") });
52
+ return 0;
53
+ case "whoami":
54
+ await whoami();
55
+ return 0;
56
+ case "logout":
57
+ logout();
58
+ return 0;
59
+ case "init":
60
+ init(process.cwd(), flags.has("command") ? "command" : "claude");
61
+ return 0;
62
+ case "run":
63
+ return runFolder(process.cwd(), { fresh: flags.has("fresh") });
64
+ case "pull":
65
+ await pull(process.cwd());
66
+ return 0;
67
+ case "create":
68
+ await createSandbox(one("name"), {
69
+ image: flags.get("image")?.[0],
70
+ secrets: flags.get("secret") ?? [],
71
+ network: flags.get("network") ?? [],
72
+ });
73
+ return 0;
74
+ case "ls":
75
+ await list();
76
+ return 0;
77
+ case "exec":
78
+ if (!passthrough.length)
79
+ throw new Error("Usage: nido exec <name> -- <command>…");
80
+ return exec(one("name"), passthrough);
81
+ case "shell":
82
+ throw new Error(`nido shell is not in the beta yet. Use: nido exec ${args[0] ?? "<name>"} -- <command>`);
83
+ case "logs":
84
+ case "stop":
85
+ case "start":
86
+ case "rm":
87
+ await simple(cmd, one("name"));
88
+ return 0;
89
+ case "cp":
90
+ if (args.length !== 2)
91
+ throw new Error("Usage: nido cp <src> <dst> (sandbox side as <name>:<path>)");
92
+ await copy(args[0], args[1]);
93
+ return 0;
94
+ case "secret":
95
+ return secret(args, flags);
96
+ default:
97
+ throw new Error(`Unknown command "${cmd}". See: nido help`);
98
+ }
99
+ }
100
+ async function secret(args, flags) {
101
+ const [sub, name] = args;
102
+ if (sub === "set" && name)
103
+ await secretSet(name, { fromGh: flags.has("from-gh") });
104
+ else if (sub === "ls")
105
+ await secretList();
106
+ else if (sub === "rm" && name)
107
+ await secretRemove(name);
108
+ else
109
+ throw new Error("Usage: nido secret set <name> | nido secret ls | nido secret rm <name>");
110
+ return 0;
111
+ }
112
+ const VALUE_FLAGS = new Set(["image", "secret", "network"]);
113
+ export function parse(argv) {
114
+ const flags = new Map();
115
+ const args = [];
116
+ const dash = argv.indexOf("--");
117
+ const own = dash === -1 ? argv : argv.slice(0, dash);
118
+ for (let i = 0; i < own.length; i++) {
119
+ const token = own[i];
120
+ if (!token.startsWith("--")) {
121
+ args.push(token);
122
+ continue;
123
+ }
124
+ const [key, inline] = token.slice(2).split(/=(.*)/s, 2);
125
+ const values = flags.get(key) ?? [];
126
+ if (VALUE_FLAGS.has(key)) {
127
+ const value = inline ?? own[++i];
128
+ if (value === undefined)
129
+ throw new Error(`--${key} needs a value`);
130
+ values.push(value);
131
+ }
132
+ flags.set(key, values);
133
+ }
134
+ return { flags, args, passthrough: dash === -1 ? [] : argv.slice(dash + 1) };
135
+ }
136
+ function version() {
137
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
138
+ return pkg.version;
139
+ }
140
+ // Run only as the program itself, not when tests import this file. npm
141
+ // links the binary, so compare real paths.
142
+ const isMain = (() => {
143
+ try {
144
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ })();
150
+ if (isMain) {
151
+ main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
152
+ process.stderr.write(`nido: ${err.message}\n`);
153
+ process.exit(1);
154
+ });
155
+ }
@@ -0,0 +1,317 @@
1
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { agentCommand, formatClaudeEvent, WORKDIR } from "./agents.js";
6
+ import { Api, ApiError } from "./api.js";
7
+ import { forgetLogin, loadConfig, saveLogin } from "./config.js";
8
+ import { excludeMatcher, hashFiles, listFiles } from "./files.js";
9
+ import { loginWithPirol } from "./login.js";
10
+ import { MANIFEST, manifestTemplate, readManifest } from "./manifest.js";
11
+ import { secretKind } from "./secrets.js";
12
+ import { planDownload, planUpload, readState, writeState } from "./sync.js";
13
+ const say = (text) => void process.stderr.write(text + "\n");
14
+ // ---------------------------------------------------------------- login
15
+ export async function login(opts) {
16
+ const result = await loginWithPirol({ manual: opts.manual, ask });
17
+ saveLogin(result.key, result.email);
18
+ try {
19
+ const me = await Api.fromConfig().whoami();
20
+ say(`Logged in as ${me.email}`);
21
+ }
22
+ catch (err) {
23
+ if (err instanceof ApiError && err.status === 403) {
24
+ say(`Logged in as ${result.email}. ${err.message}`);
25
+ return;
26
+ }
27
+ throw err;
28
+ }
29
+ }
30
+ export async function whoami() {
31
+ const config = loadConfig();
32
+ if (!config.key)
33
+ throw new Error("Not logged in. Run: nido login");
34
+ const me = await Api.fromConfig().whoami();
35
+ process.stdout.write(`${me.email}\n`);
36
+ }
37
+ export function logout() {
38
+ forgetLogin();
39
+ say("Logged out");
40
+ }
41
+ // ---------------------------------------------------------------- init
42
+ export function init(dir, kind) {
43
+ const file = join(dir, MANIFEST);
44
+ if (existsSync(file))
45
+ throw new Error(`${MANIFEST} exists already`);
46
+ writeFileSync(file, manifestTemplate(kind));
47
+ say(`Wrote ${MANIFEST}. Edit the task, then: nido run`);
48
+ if (kind === "claude")
49
+ say("Claude needs your Anthropic key once: nido secret set anthropic");
50
+ }
51
+ // ---------------------------------------------------------------- sandboxes
52
+ export async function createSandbox(name, spec) {
53
+ const api = Api.fromConfig();
54
+ say(`Creating sandbox ${name}…`);
55
+ const info = await api.create({ name, ...spec });
56
+ say(`Sandbox ${name} ready in ${(info.ready_ms / 1000).toFixed(1)} s`);
57
+ }
58
+ export async function list() {
59
+ const sandboxes = await Api.fromConfig().list();
60
+ if (!sandboxes.length)
61
+ return say("No sandboxes. Create one with: nido run or nido create <name>");
62
+ const width = Math.max(4, ...sandboxes.map((s) => s.name.length));
63
+ process.stdout.write(`${"NAME".padEnd(width)} ${"CREATED".padEnd(19)} PHASE\n`);
64
+ for (const s of sandboxes)
65
+ process.stdout.write(`${s.name.padEnd(width)} ${s.created.padEnd(19)} ${s.phase}\n`);
66
+ }
67
+ export async function exec(name, command) {
68
+ return Api.fromConfig().exec(name, command, {
69
+ onOut: (text) => process.stdout.write(text),
70
+ onErr: (text) => process.stderr.write(text),
71
+ });
72
+ }
73
+ export async function simple(action, name) {
74
+ const api = Api.fromConfig();
75
+ if (action === "rm")
76
+ await api.remove(name);
77
+ if (action === "stop")
78
+ await api.stop(name);
79
+ if (action === "start")
80
+ await api.start(name);
81
+ if (action === "logs")
82
+ return void process.stdout.write(await api.logs(name));
83
+ say(`${action === "rm" ? "Removed" : action === "stop" ? "Stopped" : "Started"} ${name}`);
84
+ }
85
+ /** nido cp: "shop:/sandbox/x" is in the sandbox, anything else is local. */
86
+ export async function copy(src, dst) {
87
+ const remote = /^([a-z0-9][a-z0-9-]*):(\/.*)$/;
88
+ const from = remote.exec(src);
89
+ const to = remote.exec(dst);
90
+ const api = Api.fromConfig();
91
+ if (to && !from) {
92
+ const local = resolve(src);
93
+ if (!existsSync(local))
94
+ throw new Error(`${src} does not exist`);
95
+ if (statSync(local).isDirectory())
96
+ return api.upload(to[1], local, to[2]);
97
+ const tmp = mkdtempSync(join(tmpdir(), "nido-"));
98
+ try {
99
+ copyFileSync(local, join(tmp, basename(local)));
100
+ await api.upload(to[1], tmp, to[2]);
101
+ }
102
+ finally {
103
+ rmSync(tmp, { recursive: true, force: true });
104
+ }
105
+ return;
106
+ }
107
+ if (from && !to) {
108
+ mkdirSync(resolve(dst), { recursive: true });
109
+ if (!(await api.download(from[1], from[2], resolve(dst))))
110
+ throw new Error(`${from[2]} does not exist in ${from[1]}`);
111
+ return;
112
+ }
113
+ throw new Error("nido cp copies between this machine and one sandbox: nido cp ./data shop:/sandbox/data");
114
+ }
115
+ // ---------------------------------------------------------------- run
116
+ /** nido run: upload the folder, run the agent, bring the results back. */
117
+ export async function runFolder(dir, opts = {}) {
118
+ const m = readManifest(dir);
119
+ const api = Api.fromConfig();
120
+ const isNew = await ensureSandbox(api, m, Boolean(opts.fresh));
121
+ const state = readState(dir, m.name);
122
+ if (isNew)
123
+ state.uploaded = {};
124
+ await upload(api, dir, m, state.uploaded);
125
+ writeState(dir, state);
126
+ const command = agentCommand(m);
127
+ say(m.agent ? `Running ${m.agent} in ${m.name}…` : `Running in ${m.name}: ${command.join(" ")}`);
128
+ let partial = "";
129
+ const code = await api.exec(m.name, command, {
130
+ workdir: WORKDIR,
131
+ onOut: m.agent
132
+ ? (text) => {
133
+ const lines = (partial + text).split("\n");
134
+ partial = lines.pop() ?? "";
135
+ for (const line of lines) {
136
+ const shown = formatClaudeEvent(line);
137
+ if (shown)
138
+ process.stdout.write(shown + "\n");
139
+ }
140
+ }
141
+ : (text) => process.stdout.write(text),
142
+ onErr: (text) => process.stderr.write(text),
143
+ });
144
+ await download(api, dir, m, state.downloaded);
145
+ writeState(dir, state);
146
+ say(`Sandbox ${m.name} keeps running. Remove it with: nido rm ${m.name}`);
147
+ return code;
148
+ }
149
+ /** nido pull: fetch the output folder again. */
150
+ export async function pull(dir) {
151
+ const m = readManifest(dir);
152
+ const api = Api.fromConfig();
153
+ if (!(await api.get(m.name)))
154
+ throw new Error(`Sandbox ${m.name} does not exist`);
155
+ const state = readState(dir, m.name);
156
+ await download(api, dir, m, state.downloaded);
157
+ writeState(dir, state);
158
+ }
159
+ async function ensureSandbox(api, m, fresh) {
160
+ let info = await api.get(m.name);
161
+ if (info && fresh) {
162
+ await api.remove(m.name);
163
+ info = undefined;
164
+ }
165
+ if (!info) {
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 });
168
+ say(`Sandbox ${m.name} ready in ${(created.ready_ms / 1000).toFixed(1)} s`);
169
+ return true;
170
+ }
171
+ if (info.phase !== "Ready")
172
+ await api.start(m.name);
173
+ return false;
174
+ }
175
+ async function upload(api, dir, m, uploaded) {
176
+ const isExcluded = excludeMatcher([...m.exclude, `/${m.output}`]);
177
+ const local = await hashFiles(dir, listFiles(dir, isExcluded));
178
+ const plan = planUpload(local, uploaded);
179
+ if (!plan.changed.length && !plan.removed.length)
180
+ return say("Files unchanged, nothing to upload");
181
+ if (plan.changed.length) {
182
+ const stage = mkdtempSync(join(tmpdir(), "nido-"));
183
+ try {
184
+ for (const path of plan.changed) {
185
+ mkdirSync(dirname(join(stage, path)), { recursive: true });
186
+ copyFileSync(join(dir, path), join(stage, path));
187
+ }
188
+ say(`Uploading ${count(plan.changed.length, "file")}…`);
189
+ await api.upload(m.name, stage, WORKDIR);
190
+ }
191
+ finally {
192
+ rmSync(stage, { recursive: true, force: true });
193
+ }
194
+ }
195
+ if (plan.removed.length) {
196
+ await api.exec(m.name, ["rm", "-f", "--", ...plan.removed], { workdir: WORKDIR, onOut: () => { }, onErr: () => { } });
197
+ }
198
+ for (const path of plan.removed)
199
+ delete uploaded[path];
200
+ Object.assign(uploaded, local);
201
+ }
202
+ async function download(api, dir, m, downloaded) {
203
+ const tmp = mkdtempSync(join(tmpdir(), "nido-"));
204
+ try {
205
+ if (!(await api.download(m.name, `${WORKDIR}/${m.output}`, tmp))) {
206
+ return say(`No ${m.output}/ in the sandbox, nothing to bring back`);
207
+ }
208
+ const paths = listFiles(tmp, () => false);
209
+ const remote = await hashFiles(tmp, paths);
210
+ const outDir = join(dir, m.output);
211
+ const local = await hashFiles(outDir, paths.filter((path) => existsSync(join(outDir, path))));
212
+ const lines = [];
213
+ for (const action of planDownload(remote, local, downloaded)) {
214
+ const target = action.kind === "conflict" ? action.savedAs : action.path;
215
+ if (action.kind !== "same") {
216
+ mkdirSync(dirname(join(outDir, target)), { recursive: true });
217
+ copyFileSync(join(tmp, action.path), join(outDir, target));
218
+ }
219
+ // A conflict leaves the user's file alone; its last download stays
220
+ // the reference, so the next pull reports it again.
221
+ if (action.kind !== "conflict")
222
+ downloaded[action.path] = remote[action.path];
223
+ if (action.kind === "new")
224
+ lines.push(` + ${m.output}/${action.path}`);
225
+ if (action.kind === "update")
226
+ lines.push(` ~ ${m.output}/${action.path}`);
227
+ if (action.kind === "conflict") {
228
+ lines.push(` ! ${m.output}/${action.path} changed here too, sandbox version saved as ${m.output}/${action.savedAs}`);
229
+ }
230
+ }
231
+ say(lines.length ? `Results:\n${lines.join("\n")}` : "Results unchanged");
232
+ }
233
+ finally {
234
+ rmSync(tmp, { recursive: true, force: true });
235
+ }
236
+ }
237
+ function count(n, word) {
238
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
239
+ }
240
+ // ---------------------------------------------------------------- secrets
241
+ export async function secretSet(name, opts) {
242
+ const kind = secretKind(name);
243
+ let value;
244
+ if (opts.fromGh) {
245
+ const { run } = await import("./run.js");
246
+ const gh = await run("gh", ["auth", "token"]);
247
+ if (gh.code !== 0)
248
+ throw new Error("gh auth token failed. Log in with: gh auth login");
249
+ value = gh.stdout.trim();
250
+ }
251
+ else {
252
+ value = (await readSecret(`${kind.hint}: `)).trim();
253
+ }
254
+ if (!value)
255
+ throw new Error("No value given");
256
+ const result = await Api.fromConfig().setSecret(name, value);
257
+ say(result === "updated" ? `Updated secret ${name}. Running sandboxes get it for new processes.` : `Saved secret ${name}`);
258
+ }
259
+ export async function secretList() {
260
+ const names = await Api.fromConfig().secrets();
261
+ process.stdout.write(names.length ? names.join("\n") + "\n" : "");
262
+ if (!names.length)
263
+ say("No secrets yet. Add one with: nido secret set anthropic");
264
+ }
265
+ export async function secretRemove(name) {
266
+ await Api.fromConfig().removeSecret(name);
267
+ say(`Removed secret ${name}`);
268
+ }
269
+ async function ask(question) {
270
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
271
+ try {
272
+ return await rl.question(question);
273
+ }
274
+ finally {
275
+ rl.close();
276
+ }
277
+ }
278
+ /** Reads a value without echo; from stdin when it is not a terminal. */
279
+ async function readSecret(prompt) {
280
+ const stdin = process.stdin;
281
+ if (!stdin.isTTY) {
282
+ let data = "";
283
+ for await (const chunk of stdin)
284
+ data += chunk;
285
+ return data;
286
+ }
287
+ process.stderr.write(prompt);
288
+ stdin.setRawMode(true);
289
+ stdin.resume();
290
+ stdin.setEncoding("utf8");
291
+ return new Promise((resolvePromise, reject) => {
292
+ let value = "";
293
+ const onData = (chunk) => {
294
+ for (const char of chunk) {
295
+ if (char === "\r" || char === "\n")
296
+ return done();
297
+ if (char === "\u0003")
298
+ return done(new Error("Cancelled"));
299
+ if (char === "\u007f")
300
+ value = value.slice(0, -1);
301
+ else
302
+ value += char;
303
+ }
304
+ };
305
+ const done = (err) => {
306
+ stdin.off("data", onData);
307
+ stdin.setRawMode(false);
308
+ stdin.pause();
309
+ process.stderr.write("\n");
310
+ if (err)
311
+ reject(err);
312
+ else
313
+ resolvePromise(value);
314
+ };
315
+ stdin.on("data", onData);
316
+ });
317
+ }
package/dist/config.js ADDED
@@ -0,0 +1,35 @@
1
+ // Where nido finds the API and the Pirol key. `nido login` writes
2
+ // ~/.nido/config.json (0600); PIROL_API_KEY and NIDO_API_URL override it,
3
+ // for CI and for agents that already hold a key.
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { nidoHome } from "./paths.js";
7
+ export const DEFAULT_API = "https://nido.pirol.ai";
8
+ function file() {
9
+ return join(nidoHome(), "config.json");
10
+ }
11
+ export function loadConfig() {
12
+ let saved = {};
13
+ if (existsSync(file())) {
14
+ try {
15
+ saved = JSON.parse(readFileSync(file(), "utf8"));
16
+ }
17
+ catch {
18
+ saved = {};
19
+ }
20
+ }
21
+ return {
22
+ api: (process.env.NIDO_API_URL ?? saved.api ?? DEFAULT_API).replace(/\/+$/, ""),
23
+ key: process.env.PIROL_API_KEY ?? saved.key,
24
+ email: process.env.PIROL_API_KEY ? undefined : saved.email,
25
+ };
26
+ }
27
+ export function saveLogin(key, email, api) {
28
+ mkdirSync(nidoHome(), { recursive: true, mode: 0o700 });
29
+ const current = existsSync(file()) ? JSON.parse(readFileSync(file(), "utf8")) : {};
30
+ writeFileSync(file(), JSON.stringify({ ...current, ...(api ? { api } : {}), key, email }, null, 2) + "\n", { mode: 0o600 });
31
+ chmodSync(file(), 0o600);
32
+ }
33
+ export function forgetLogin() {
34
+ rmSync(file(), { force: true });
35
+ }
package/dist/files.js ADDED
@@ -0,0 +1,76 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream, readdirSync } from "node:fs";
3
+ import { join, posix } from "node:path";
4
+ /** Never uploaded: nido's own state and version control. */
5
+ const ALWAYS_EXCLUDED = [".nido", ".git", ".DS_Store"];
6
+ /**
7
+ * Turns an exclude pattern into a test on relative paths (with "/").
8
+ * Without a slash a pattern matches any file or folder name, like
9
+ * .gitignore. With a slash it matches the path from the folder root.
10
+ */
11
+ export function excludeMatcher(patterns) {
12
+ const tests = [...ALWAYS_EXCLUDED, ...patterns].map((raw) => {
13
+ const pattern = raw.replace(/\/+$/, "");
14
+ const anchored = pattern.includes("/");
15
+ const regex = globToRegex(pattern.replace(/^\//, ""));
16
+ return anchored
17
+ ? (path) => prefixes(path).some((p) => regex.test(p))
18
+ : (path) => path.split("/").some((segment) => regex.test(segment));
19
+ });
20
+ return (path) => tests.some((test) => test(path));
21
+ }
22
+ function prefixes(path) {
23
+ const parts = path.split("/");
24
+ return parts.map((_, i) => parts.slice(0, i + 1).join("/"));
25
+ }
26
+ export function globToRegex(glob) {
27
+ let out = "";
28
+ for (let i = 0; i < glob.length; i++) {
29
+ const c = glob[i];
30
+ if (c === "*" && glob[i + 1] === "*") {
31
+ out += ".*";
32
+ i++;
33
+ if (glob[i + 1] === "/")
34
+ i++;
35
+ }
36
+ else if (c === "*")
37
+ out += "[^/]*";
38
+ else if (c === "?")
39
+ out += "[^/]";
40
+ else
41
+ out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
42
+ }
43
+ return new RegExp(`^${out}$`);
44
+ }
45
+ /** All regular files under root, as sorted paths relative to root. */
46
+ export function listFiles(root, isExcluded) {
47
+ const out = [];
48
+ const walk = (rel) => {
49
+ for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) {
50
+ const path = rel ? posix.join(rel, entry.name) : entry.name;
51
+ if (isExcluded(path))
52
+ continue;
53
+ if (entry.isDirectory())
54
+ walk(path);
55
+ else if (entry.isFile())
56
+ out.push(path);
57
+ }
58
+ };
59
+ walk("");
60
+ return out.sort();
61
+ }
62
+ export function hashFile(file) {
63
+ return new Promise((resolve, reject) => {
64
+ const hash = createHash("sha256");
65
+ createReadStream(file)
66
+ .on("data", (chunk) => hash.update(chunk))
67
+ .on("error", reject)
68
+ .on("end", () => resolve(hash.digest("hex")));
69
+ });
70
+ }
71
+ export async function hashFiles(root, paths) {
72
+ const out = {};
73
+ for (const path of paths)
74
+ out[path] = await hashFile(join(root, path));
75
+ return out;
76
+ }
package/dist/login.js ADDED
@@ -0,0 +1,145 @@
1
+ // "Login with Pirol", the same flow as `kyte login`: the browser opens the
2
+ // Pirol consent page, a one-time code comes back to a loopback address
3
+ // (127.0.0.1, random port) and is exchanged for a Pirol key. PKCE ties the
4
+ // code to this process. --manual prints the URL and takes the pasted code,
5
+ // for SSH sessions without a browser.
6
+ import { spawn } from "node:child_process";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { createServer } from "node:http";
9
+ export const CLI_AUTH_URL = process.env.PIROL_CLI_AUTH_URL ?? "https://pirol.ai/cli-auth";
10
+ export const PIROL_API = process.env.PIROL_API_BASE_URL ?? "https://api.pirol.ai";
11
+ const TIMEOUT_MS = 180_000;
12
+ export function randomVerifier() {
13
+ return randomBytes(32).toString("base64url");
14
+ }
15
+ export function pkceChallenge(verifier) {
16
+ return createHash("sha256").update(verifier).digest("base64url");
17
+ }
18
+ export function authUrl(challenge, redirectUri) {
19
+ const url = new URL(CLI_AUTH_URL);
20
+ url.searchParams.set("redirect_uri", redirectUri);
21
+ url.searchParams.set("app_name", "nido");
22
+ url.searchParams.set("code_challenge", challenge);
23
+ return url.toString();
24
+ }
25
+ /** The code from a callback URL, or the code itself when pasted alone. */
26
+ export function codeFrom(input) {
27
+ const text = input.trim();
28
+ if (!text.includes("?") && !text.includes("=")) {
29
+ if (!text)
30
+ throw new Error("No code given");
31
+ return text;
32
+ }
33
+ const params = new URLSearchParams(text.slice(text.indexOf("?") + 1));
34
+ const error = params.get("error");
35
+ if (error)
36
+ throw new Error(params.get("error_description") ?? error);
37
+ const code = params.get("code");
38
+ if (!code)
39
+ throw new Error("The callback contains no code");
40
+ return code;
41
+ }
42
+ function page(title, body) {
43
+ return `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:system-ui;margin:3rem;max-width:34rem;line-height:1.5"><h2>${title}</h2><p>${body}</p></body>`;
44
+ }
45
+ function startReceiver() {
46
+ return new Promise((resolve, reject) => {
47
+ let settle;
48
+ const code = new Promise((ok, fail) => (settle = { ok, fail }));
49
+ code.catch(() => { });
50
+ let done = false;
51
+ const finish = (fn) => {
52
+ if (!done) {
53
+ done = true;
54
+ fn();
55
+ }
56
+ };
57
+ const server = createServer((req, res) => {
58
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
59
+ if (url.pathname !== "/callback")
60
+ return void res.writeHead(404).end();
61
+ try {
62
+ const value = codeFrom(url.toString());
63
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page("Logged in ✓", "You can close this window and go back to the terminal."));
64
+ finish(() => settle.ok(value));
65
+ }
66
+ catch (err) {
67
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(page("Login failed", String(err.message)));
68
+ finish(() => settle.fail(err));
69
+ }
70
+ });
71
+ const timer = setTimeout(() => {
72
+ finish(() => settle.fail(new Error("The browser did not come back in time")));
73
+ server.close();
74
+ }, TIMEOUT_MS);
75
+ timer.unref();
76
+ server.on("error", reject);
77
+ server.listen(0, "127.0.0.1", () => {
78
+ const addr = server.address();
79
+ if (!addr || typeof addr === "string")
80
+ return reject(new Error("Could not open a local port"));
81
+ resolve({
82
+ redirectUri: `http://127.0.0.1:${addr.port}/callback`,
83
+ code,
84
+ close() {
85
+ clearTimeout(timer);
86
+ finish(() => settle.fail(new Error("cancelled")));
87
+ server.close();
88
+ },
89
+ });
90
+ });
91
+ });
92
+ }
93
+ export function browserCommand(url, platform = process.platform) {
94
+ if (platform === "darwin")
95
+ return { cmd: "open", args: [url] };
96
+ if (platform === "win32")
97
+ return { cmd: "cmd.exe", args: ["/c", "start", "", url.replace(/&/g, "^&")] };
98
+ return { cmd: "xdg-open", args: [url] };
99
+ }
100
+ function openBrowser(url) {
101
+ return new Promise((resolve) => {
102
+ try {
103
+ const { cmd, args } = browserCommand(url);
104
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
105
+ child.on("error", () => resolve(false));
106
+ child.on("spawn", () => {
107
+ child.unref();
108
+ resolve(true);
109
+ });
110
+ }
111
+ catch {
112
+ resolve(false);
113
+ }
114
+ });
115
+ }
116
+ export async function loginWithPirol(opts) {
117
+ const verifier = randomVerifier();
118
+ const challenge = pkceChallenge(verifier);
119
+ let code;
120
+ if (opts.manual) {
121
+ process.stderr.write(`Open this page in a browser:\n\n ${authUrl(challenge, "http://127.0.0.1:1/callback")}\n\n`);
122
+ code = codeFrom(await opts.ask("Paste the code (or the whole callback URL): "));
123
+ }
124
+ else {
125
+ const receiver = await startReceiver();
126
+ try {
127
+ const url = authUrl(challenge, receiver.redirectUri);
128
+ const opened = await openBrowser(url);
129
+ process.stderr.write(opened ? "Browser opened. Confirm the login there…\n" : `Open this page in a browser:\n\n ${url}\n\n`);
130
+ code = await receiver.code;
131
+ }
132
+ finally {
133
+ receiver.close();
134
+ }
135
+ }
136
+ const res = await fetch(`${PIROL_API}/v1/auth/exchange`, {
137
+ method: "POST",
138
+ headers: { "content-type": "application/json" },
139
+ body: JSON.stringify({ code, code_verifier: verifier }),
140
+ });
141
+ const body = (await res.json().catch(() => ({})));
142
+ if (!res.ok || !body.key)
143
+ throw new Error(`Login failed: ${body.error?.message ?? `HTTP ${res.status}`}`);
144
+ return { key: body.key, email: body.email ?? "" };
145
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ export const MANIFEST = "nido.json";
4
+ export const AGENTS = ["claude"];
5
+ /**
6
+ * OpenShell community image with Claude Code, Codex and OpenCode, used until
7
+ * our own `coding` template exists. Pinned, and pre-pulled on every node
8
+ * (infra/agent-sandbox/versions.env, SANDBOX_IMAGE).
9
+ */
10
+ export const DEFAULT_IMAGE = "ghcr.io/nvidia/openshell-community/sandboxes/base@sha256:aeef1c63f00e2913ea002ccb3aaf925f338b5c5d70e63576f0d95c16a138044e";
11
+ export function readManifest(dir) {
12
+ const file = join(dir, MANIFEST);
13
+ if (!existsSync(file))
14
+ throw new Error(`No ${MANIFEST} here. Create one with: nido init`);
15
+ let raw;
16
+ try {
17
+ raw = JSON.parse(readFileSync(file, "utf8"));
18
+ }
19
+ catch (err) {
20
+ throw new Error(`${MANIFEST} is not valid JSON: ${err.message}`);
21
+ }
22
+ return parseManifest(raw, basename(dir));
23
+ }
24
+ export function parseManifest(raw, folderName) {
25
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
26
+ throw new Error(`${MANIFEST} must be an object`);
27
+ const m = raw;
28
+ const known = ["name", "agent", "task", "command", "image", "files", "secrets", "network", "output"];
29
+ const unknown = Object.keys(m).filter((key) => !known.includes(key) && !key.startsWith("$"));
30
+ if (unknown.length)
31
+ throw new Error(`${MANIFEST}: unknown field ${unknown.map((k) => `"${k}"`).join(", ")}`);
32
+ const agent = optionalString(m, "agent");
33
+ if (agent !== undefined && !AGENTS.includes(agent)) {
34
+ throw new Error(`${MANIFEST}: agent "${agent}" is not supported yet. Use ${AGENTS.join(", ")} or "command".`);
35
+ }
36
+ const task = optionalString(m, "task");
37
+ const command = m.command;
38
+ if (command !== undefined && typeof command !== "string" && !isStringArray(command)) {
39
+ throw new Error(`${MANIFEST}: "command" must be a string or a list of strings`);
40
+ }
41
+ if (agent && !task)
42
+ throw new Error(`${MANIFEST}: "agent" needs a "task"`);
43
+ if (agent && command !== undefined)
44
+ throw new Error(`${MANIFEST}: use either "agent" or "command", not both`);
45
+ if (!agent && command === undefined)
46
+ throw new Error(`${MANIFEST}: set "agent" and "task", or "command"`);
47
+ const files = m.files;
48
+ if (files !== undefined && (typeof files !== "object" || files === null || Array.isArray(files))) {
49
+ throw new Error(`${MANIFEST}: "files" must be an object like {"exclude": ["node_modules"]}`);
50
+ }
51
+ const exclude = files?.exclude ?? [];
52
+ if (!isStringArray(exclude))
53
+ throw new Error(`${MANIFEST}: "files.exclude" must be a list of strings`);
54
+ const output = (optionalString(m, "output") ?? "output/").replace(/^\.\//, "").replace(/\/+$/, "");
55
+ if (!output || output.startsWith("/") || output.split("/").includes("..")) {
56
+ throw new Error(`${MANIFEST}: "output" must be a folder inside this folder`);
57
+ }
58
+ return {
59
+ name: sandboxName(optionalString(m, "name") ?? folderName),
60
+ agent: agent,
61
+ task,
62
+ command: command,
63
+ image: optionalString(m, "image") ?? DEFAULT_IMAGE,
64
+ imageSet: m.image !== undefined,
65
+ exclude,
66
+ secrets: stringList(m, "secrets"),
67
+ network: stringList(m, "network"),
68
+ output,
69
+ };
70
+ }
71
+ /** Sandbox names are DNS labels: lowercase letters, digits and dashes. */
72
+ export function sandboxName(input) {
73
+ const name = input
74
+ .toLowerCase()
75
+ .replace(/[^a-z0-9-]+/g, "-")
76
+ .replace(/^-+|-+$/g, "")
77
+ .slice(0, 40)
78
+ .replace(/-+$/, "");
79
+ return name || "nest";
80
+ }
81
+ function optionalString(m, key) {
82
+ const value = m[key];
83
+ if (value === undefined)
84
+ return undefined;
85
+ if (typeof value !== "string" || !value.trim())
86
+ throw new Error(`${MANIFEST}: "${key}" must be a non-empty string`);
87
+ return value;
88
+ }
89
+ function stringList(m, key) {
90
+ const value = m[key] ?? [];
91
+ if (!isStringArray(value))
92
+ throw new Error(`${MANIFEST}: "${key}" must be a list of strings`);
93
+ return value;
94
+ }
95
+ function isStringArray(value) {
96
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
97
+ }
98
+ export function manifestTemplate(agent) {
99
+ const body = agent === "claude"
100
+ ? {
101
+ agent: "claude",
102
+ task: "Read the files in this folder and write a short summary to output/summary.md",
103
+ files: { exclude: ["node_modules", ".venv", "*.log"] },
104
+ secrets: ["anthropic"],
105
+ output: "output/",
106
+ }
107
+ : {
108
+ command: "mkdir -p output && ls -la > output/files.txt && uname -a >> output/files.txt",
109
+ files: { exclude: ["node_modules", ".venv", "*.log"] },
110
+ output: "output/",
111
+ };
112
+ return JSON.stringify(body, null, 2) + "\n";
113
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,6 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ /** Everything nido keeps on this machine lives here. */
4
+ export function nidoHome() {
5
+ return process.env.NIDO_HOME ?? join(homedir(), ".nido");
6
+ }
package/dist/run.js ADDED
@@ -0,0 +1,12 @@
1
+ import { spawn } from "node:child_process";
2
+ export function run(cmd, args) {
3
+ return new Promise((resolve, reject) => {
4
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
5
+ let stdout = "";
6
+ let stderr = "";
7
+ child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
8
+ child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
9
+ child.on("error", reject);
10
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
11
+ });
12
+ }
@@ -0,0 +1,30 @@
1
+ // Secret names in nido map to OpenShell provider profiles. A profile says
2
+ // which env var holds the value and which hosts may receive it. The value
3
+ // stays in the gateway; the sandbox only sees a placeholder.
4
+ export const SECRET_KINDS = {
5
+ anthropic: {
6
+ profile: "claude-code",
7
+ envVar: "ANTHROPIC_API_KEY",
8
+ hosts: ["api.anthropic.com", "statsig.anthropic.com", "sentry.io"],
9
+ hint: "Anthropic API key (console.anthropic.com → API keys)",
10
+ },
11
+ openai: {
12
+ profile: "openai",
13
+ envVar: "OPENAI_API_KEY",
14
+ hosts: ["api.openai.com"],
15
+ hint: "OpenAI API key",
16
+ },
17
+ github: {
18
+ profile: "github",
19
+ envVar: "GITHUB_TOKEN",
20
+ hosts: ["api.github.com", "github.com"],
21
+ hint: "GitHub token (or use --from-gh)",
22
+ },
23
+ };
24
+ export function secretKind(name) {
25
+ const kind = SECRET_KINDS[name];
26
+ if (!kind) {
27
+ throw new Error(`Unknown secret "${name}". Supported for now: ${Object.keys(SECRET_KINDS).join(", ")}`);
28
+ }
29
+ return kind;
30
+ }
package/dist/sync.js ADDED
@@ -0,0 +1,56 @@
1
+ // Decides what goes up and what comes back. Pure functions, so the rules
2
+ // are easy to test: upload only changes, never overwrite a file the user
3
+ // changed locally since the last download.
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { dirname, extname, join } from "node:path";
6
+ export const STATE_FILE = ".nido/state.json";
7
+ export function readState(dir, sandbox) {
8
+ const empty = { sandbox, uploaded: {}, downloaded: {} };
9
+ const file = join(dir, STATE_FILE);
10
+ if (!existsSync(file))
11
+ return empty;
12
+ try {
13
+ const state = JSON.parse(readFileSync(file, "utf8"));
14
+ // A different sandbox has none of our files yet. Downloads stay valid,
15
+ // they describe the local folder.
16
+ return state.sandbox === sandbox ? state : { ...empty, downloaded: state.downloaded ?? {} };
17
+ }
18
+ catch {
19
+ return empty;
20
+ }
21
+ }
22
+ export function writeState(dir, state) {
23
+ const file = join(dir, STATE_FILE);
24
+ mkdirSync(dirname(file), { recursive: true });
25
+ writeFileSync(file, JSON.stringify(state, null, 2) + "\n");
26
+ }
27
+ export function planUpload(local, uploaded) {
28
+ return {
29
+ changed: Object.keys(local).filter((path) => uploaded[path] !== local[path]).sort(),
30
+ removed: Object.keys(uploaded).filter((path) => !(path in local)).sort(),
31
+ };
32
+ }
33
+ /**
34
+ * remote: files in the sandbox's output folder.
35
+ * local: the same paths in the local folder (missing = not there).
36
+ * downloaded: what we wrote last time.
37
+ */
38
+ export function planDownload(remote, local, downloaded) {
39
+ return Object.keys(remote)
40
+ .sort()
41
+ .map((path) => {
42
+ const here = local[path];
43
+ if (here === undefined)
44
+ return { path, kind: "new" };
45
+ if (here === remote[path])
46
+ return { path, kind: "same" };
47
+ if (here === downloaded[path])
48
+ return { path, kind: "update" };
49
+ return { path, kind: "conflict", savedAs: conflictName(path) };
50
+ });
51
+ }
52
+ /** report.md → report.remote.md, so the copy opens in the same app. */
53
+ export function conflictName(path) {
54
+ const ext = extname(path);
55
+ return ext && !path.endsWith(`/${ext}`) ? `${path.slice(0, -ext.length)}.remote${ext}` : `${path}.remote`;
56
+ }
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@pirol/nido",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Run agents in isolated sandboxes. Give it a folder, get results back.",
5
+ "type": "module",
6
+ "bin": {
7
+ "nido": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "scripts": {
16
+ "prebuild": "rm -rf dist",
17
+ "build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
18
+ "test": "vitest run",
19
+ "typecheck": "tsc -p tsconfig.json --noEmit",
20
+ "prepublishOnly": "npm run build && npm test"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.10.0",
24
+ "typescript": "^5.6.3",
25
+ "vitest": "^4.1.9"
26
+ },
27
+ "license": "UNLICENSED",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "homepage": "https://pirol.ai/docs/sandboxes",
32
+ "keywords": [
33
+ "sandbox",
34
+ "agents",
35
+ "claude",
36
+ "kata",
37
+ "openshell"
38
+ ]
39
+ }