bottleship 0.0.1 → 0.1.0-prototype.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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +183 -2
- package/bin/bottleship +2 -0
- package/dist/presets/providers/command.d.ts +3 -0
- package/dist/presets/providers/command.js +39 -0
- package/dist/presets/providers/daytona.d.ts +3 -0
- package/dist/presets/providers/daytona.js +173 -0
- package/dist/presets/providers/docker.d.ts +3 -0
- package/dist/presets/providers/docker.js +31 -0
- package/dist/presets/providers/e2b.d.ts +3 -0
- package/dist/presets/providers/e2b.js +124 -0
- package/dist/presets/providers/exe.d.ts +3 -0
- package/dist/presets/providers/exe.js +28 -0
- package/dist/presets/providers/http.example.d.ts +3 -0
- package/dist/presets/providers/http.example.js +47 -0
- package/dist/presets/providers/local.d.ts +3 -0
- package/dist/presets/providers/local.js +52 -0
- package/dist/presets/providers/modal.d.ts +3 -0
- package/dist/presets/providers/modal.js +117 -0
- package/dist/presets/providers/ssh.d.ts +3 -0
- package/dist/presets/providers/ssh.js +49 -0
- package/dist/presets/providers/util.d.ts +13 -0
- package/dist/presets/providers/util.js +102 -0
- package/dist/src/boxes.d.ts +64 -0
- package/dist/src/boxes.js +265 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +201 -0
- package/dist/src/config.d.ts +27 -0
- package/dist/src/config.js +151 -0
- package/dist/src/init.d.ts +12 -0
- package/dist/src/init.js +345 -0
- package/dist/src/paths.d.ts +4 -0
- package/dist/src/paths.js +17 -0
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +58 -0
- package/dist/src/types.d.ts +120 -0
- package/dist/src/types.js +1 -0
- package/dist/src/ui.d.ts +31 -0
- package/dist/src/ui.js +127 -0
- package/package.json +50 -4
- package/presets/agents/claude.json +12 -0
- package/presets/agents/codex.json +15 -0
- package/presets/agents/cursor.json +14 -0
- package/presets/agents/opencode.json +19 -0
- package/presets/providers/command.ts +43 -0
- package/presets/providers/daytona.ts +181 -0
- package/presets/providers/docker.ts +38 -0
- package/presets/providers/e2b.ts +130 -0
- package/presets/providers/exe.ts +35 -0
- package/presets/providers/http.example.ts +64 -0
- package/presets/providers/local.ts +56 -0
- package/presets/providers/modal.ts +121 -0
- package/presets/providers/ssh.ts +54 -0
- package/presets/providers/util.ts +112 -0
package/dist/src/ui.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The terminal side of `bottleship init`: the header, prompts, spinners. Only used on a TTY.
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
const color = !process.env.NO_COLOR && process.stdout.isTTY;
|
|
5
|
+
const sgr = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
6
|
+
export const c = {
|
|
7
|
+
bold: sgr("1"),
|
|
8
|
+
dim: sgr("2"),
|
|
9
|
+
cyan: sgr("36"),
|
|
10
|
+
green: sgr("32"),
|
|
11
|
+
red: sgr("31"),
|
|
12
|
+
yellow: sgr("33"),
|
|
13
|
+
blue: sgr("34"),
|
|
14
|
+
white: sgr("97"),
|
|
15
|
+
};
|
|
16
|
+
// a ship in a bottle, with a color mask per line: c glass, y cork and stars, w sails, r hull and flags, b sea
|
|
17
|
+
const ART = [
|
|
18
|
+
" .-----------------------------------------------.",
|
|
19
|
+
" ccccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
20
|
+
" / \\",
|
|
21
|
+
" c c",
|
|
22
|
+
" _________/ * |> |> \\",
|
|
23
|
+
" cccccccccc y wr wr c",
|
|
24
|
+
"[####| /|\\ /|\\ . |",
|
|
25
|
+
"yyyyyc www www y c",
|
|
26
|
+
"[####| . / | \\ / | \\ |",
|
|
27
|
+
"yyyyyc y w w w w w w c",
|
|
28
|
+
"[####|____ /__|__\\ /__|__\\ * |",
|
|
29
|
+
"yyyyyccccc wwwwwww wwwwwww y c",
|
|
30
|
+
" \\ ~~~~~~\\__o___o___o___o___o__/~~~~~~ /",
|
|
31
|
+
" c bbbbbbrrryrrryrrryrrryrrryrrrbbbbbb c",
|
|
32
|
+
" \\ ~ ~ ~ ~ ~ ~ ~ ~ /",
|
|
33
|
+
" c b b b b b b b b c",
|
|
34
|
+
" '-----------------------------------------------'",
|
|
35
|
+
" ccccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
36
|
+
];
|
|
37
|
+
const paint = { c: c.cyan, y: c.yellow, w: c.white, r: c.red, b: c.blue };
|
|
38
|
+
export function header() {
|
|
39
|
+
const rows = [];
|
|
40
|
+
for (let i = 0; i < ART.length; i += 2) {
|
|
41
|
+
const [text, mask] = [ART[i], ART[i + 1]];
|
|
42
|
+
rows.push([...text].map((ch, j) => (paint[mask[j]] ?? String)(ch)).join(""));
|
|
43
|
+
}
|
|
44
|
+
return `\n${rows.join("\n")}\n\n ${c.bold("bottleship")} ${c.dim("coding agents in little boxes, run by your agent")}\n`;
|
|
45
|
+
}
|
|
46
|
+
// soft-wraps each line to width, keeping a list item's hanging indent
|
|
47
|
+
function wrap(text, width) {
|
|
48
|
+
return text.split("\n").map((line) => {
|
|
49
|
+
const indent = " ".repeat(line.match(/^\s*(- )?/)[0].length);
|
|
50
|
+
const out = [];
|
|
51
|
+
let cur = "";
|
|
52
|
+
for (const word of line.split(" ")) {
|
|
53
|
+
if (cur && (cur + " " + word).length > width) {
|
|
54
|
+
out.push(cur);
|
|
55
|
+
cur = indent + word;
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
cur = cur ? cur + " " + word : word;
|
|
59
|
+
}
|
|
60
|
+
return [...out, cur].join("\n");
|
|
61
|
+
}).join("\n");
|
|
62
|
+
}
|
|
63
|
+
// Ctrl-C at any prompt ends init without writing anything half-done
|
|
64
|
+
function answered(v) {
|
|
65
|
+
if (p.isCancel(v)) {
|
|
66
|
+
p.cancel("Setup cancelled, nothing changed.");
|
|
67
|
+
process.exit(130);
|
|
68
|
+
}
|
|
69
|
+
return v;
|
|
70
|
+
}
|
|
71
|
+
export function terminalUI() {
|
|
72
|
+
let closed = false;
|
|
73
|
+
return {
|
|
74
|
+
intro() {
|
|
75
|
+
process.stdout.write(header() + "\n");
|
|
76
|
+
p.intro(c.bold(" setup "));
|
|
77
|
+
},
|
|
78
|
+
say: (line = "") => p.log.message(line.replace(/^ +/gm, "")),
|
|
79
|
+
section: (title) => p.log.step(c.bold(title)),
|
|
80
|
+
ok: (label, detail) => p.log.success(`${label}${detail ? " " + c.dim(detail) : ""}`),
|
|
81
|
+
warn: (label, detail) => p.log.warn(`${label}${detail ? " " + c.dim(detail) : ""}`),
|
|
82
|
+
note: (title, body) => p.note(wrap(body, Math.min((process.stdout.columns || 80) - 8, 88)), title),
|
|
83
|
+
async text(question, def) {
|
|
84
|
+
const v = answered(await p.text({ message: question, placeholder: def, defaultValue: def }));
|
|
85
|
+
return v.trim() || def || "";
|
|
86
|
+
},
|
|
87
|
+
async choose(question, options, def) {
|
|
88
|
+
return answered(await p.select({ message: question, initialValue: def, options: options.map((o) => ({ value: o.value, label: o.value, hint: o.hint })) }));
|
|
89
|
+
},
|
|
90
|
+
async secret(question) {
|
|
91
|
+
return answered(await p.password({ message: `${question} ${c.dim("(Enter to skip)")}`, mask: "•" })).trim();
|
|
92
|
+
},
|
|
93
|
+
async confirm(question) {
|
|
94
|
+
return answered(await p.confirm({ message: question }));
|
|
95
|
+
},
|
|
96
|
+
async handOver(command) {
|
|
97
|
+
p.log.info(`running ${c.bold(command)}`);
|
|
98
|
+
return spawnSync("sh", ["-c", command], { stdio: "inherit" }).status;
|
|
99
|
+
},
|
|
100
|
+
async step(label, fn) {
|
|
101
|
+
const s = p.spinner();
|
|
102
|
+
const t0 = Date.now();
|
|
103
|
+
const secs = () => c.dim(`${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
104
|
+
s.start(label);
|
|
105
|
+
try {
|
|
106
|
+
const v = await fn();
|
|
107
|
+
s.stop(`${label} ${secs()}`);
|
|
108
|
+
return v;
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
s.stop(`${c.red(label)} ${secs()}`, 1);
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
fail(message, fix) {
|
|
116
|
+
p.log.error(`${message}${fix ? "\n" + c.dim(fix) : ""}`);
|
|
117
|
+
p.outro(c.red("Setup did not finish."));
|
|
118
|
+
closed = true;
|
|
119
|
+
},
|
|
120
|
+
close(message) {
|
|
121
|
+
if (closed)
|
|
122
|
+
return;
|
|
123
|
+
closed = true;
|
|
124
|
+
p.outro(message ?? "Done.");
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bottleship",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
5
|
-
"license": "
|
|
3
|
+
"version": "0.1.0-prototype.0",
|
|
4
|
+
"description": "Run coding agents in remote sandboxes, like subagents, from your local coding agent.",
|
|
5
|
+
"license": "MIT",
|
|
6
6
|
"author": "Joshua Knauber",
|
|
7
|
-
"
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"bottleship": "bin/bottleship"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"dist/src",
|
|
14
|
+
"dist/presets",
|
|
15
|
+
"presets",
|
|
16
|
+
"README.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/src/types.d.ts",
|
|
23
|
+
"default": "./dist/src/types.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
29
|
+
"test": "npm run build && vitest run",
|
|
30
|
+
"prepare": "npm run build",
|
|
31
|
+
"release": "node scripts/release.mjs"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.18"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^22.20.5",
|
|
38
|
+
"typescript": "5.9",
|
|
39
|
+
"vitest": "^3.2.7"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@clack/prompts": "^0.11.0",
|
|
43
|
+
"@daytonaio/sdk": "^0.231.1",
|
|
44
|
+
"e2b": "^2.63.0",
|
|
45
|
+
"modal": "^0.11.1"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/joshuaKnauber/bottleship.git"
|
|
53
|
+
}
|
|
8
54
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"install": ["command -v claude >/dev/null || npm install -g --silent @anthropic-ai/claude-code"],
|
|
3
|
+
"run": "claude -p {prompt} --dangerously-skip-permissions",
|
|
4
|
+
"resume": "claude -p {prompt} --continue --dangerously-skip-permissions",
|
|
5
|
+
"env": { "IS_SANDBOX": "1" },
|
|
6
|
+
"unset": ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
7
|
+
"login": {
|
|
8
|
+
"routes": ["token"],
|
|
9
|
+
"use": "token",
|
|
10
|
+
"token": { "env": "CLAUDE_CODE_OAUTH_TOKEN", "store": "claude", "command": "claude setup-token", "hint": "paste the long-lived token `claude setup-token` prints; it uses your Claude subscription" }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"install": ["command -v codex >/dev/null || npm install -g --silent @openai/codex"],
|
|
3
|
+
"run": "codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -- {prompt}",
|
|
4
|
+
"resume": "codex exec resume --last --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -- {prompt}",
|
|
5
|
+
"login": {
|
|
6
|
+
"routes": ["device", "token"],
|
|
7
|
+
"use": "device",
|
|
8
|
+
"device": {
|
|
9
|
+
"command": "codex login --device-auth",
|
|
10
|
+
"status": "codex login status",
|
|
11
|
+
"setup": "in ChatGPT, open Settings, Security and turn on device code login for Codex"
|
|
12
|
+
},
|
|
13
|
+
"token": { "env": "CODEX_API_KEY", "store": "openai", "hint": "create an OpenAI API key at https://platform.openai.com/api-keys and paste it; it bills to the API, not your ChatGPT plan" }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"install": ["command -v cursor-agent >/dev/null || { curl -fsS https://cursor.com/install | bash; command -v cursor-agent >/dev/null; }"],
|
|
3
|
+
"run": "cursor-agent -p --force --trust --sandbox disabled --output-format text -- {prompt}",
|
|
4
|
+
"resume": "cursor-agent -p --continue --force --trust --sandbox disabled --output-format text -- {prompt}",
|
|
5
|
+
"login": {
|
|
6
|
+
"routes": ["token", "device"],
|
|
7
|
+
"use": "token",
|
|
8
|
+
"token": { "env": "CURSOR_API_KEY", "store": "cursor", "hint": "create an API key at https://cursor.com/dashboard/api and paste it; it bills to your Cursor plan" },
|
|
9
|
+
"device": {
|
|
10
|
+
"command": "NO_OPEN_BROWSER=1 cursor-agent login",
|
|
11
|
+
"status": "! cursor-agent status 2>&1 | grep -qi 'not logged in'"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"install": ["command -v opencode >/dev/null || npm install -g --silent @opencode/cli"],
|
|
3
|
+
"run": "opencode run --standalone --auto -- {prompt}",
|
|
4
|
+
"resume": "opencode run --standalone --continue --auto -- {prompt}",
|
|
5
|
+
"login": {
|
|
6
|
+
"routes": ["none", "device", "token"],
|
|
7
|
+
"use": "none",
|
|
8
|
+
"device": {
|
|
9
|
+
"command": "opencode auth login openai --method chatgpt-headless --standalone",
|
|
10
|
+
"status": "opencode auth list --standalone --format json | grep -q '\"openai\"'"
|
|
11
|
+
},
|
|
12
|
+
"token": { "env": "OPENCODE_API_KEY", "store": "opencode", "hint": "sign in at https://opencode.ai/console, copy your API key (OpenCode Zen) and paste it" },
|
|
13
|
+
"tokens": [
|
|
14
|
+
{ "env": "OPENCODE_API_KEY", "store": "opencode", "hint": "OpenCode Zen: sign in at https://opencode.ai/console, copy your API key and paste it" },
|
|
15
|
+
{ "env": "ANTHROPIC_API_KEY", "store": "anthropic", "hint": "an Anthropic API key (not a Claude subscription) from https://console.anthropic.com/settings/keys" },
|
|
16
|
+
{ "env": "OPENAI_API_KEY", "store": "openai", "hint": "an OpenAI API key from https://platform.openai.com/api-keys" }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// A provider driven by shell command templates, for sandboxes that have a CLI.
|
|
2
|
+
// Values substituted into a template are shell-quoted: {script}, {id}.
|
|
3
|
+
// {name!} inserts the raw value, for URLs and the like.
|
|
4
|
+
//
|
|
5
|
+
// options:
|
|
6
|
+
// create: creates box {id}; may print JSON {"id": ...} or a bare id (else {id} is used)
|
|
7
|
+
// exec: runs {script} with bash in box {id}; must pass stdin/stdout through
|
|
8
|
+
// stream: (optional) like exec but kept open, binary-clean (no TTY); defaults to exec
|
|
9
|
+
// destroy: removes box {id}
|
|
10
|
+
// status: (optional) exits 0 if box {id} is running
|
|
11
|
+
//
|
|
12
|
+
// example:
|
|
13
|
+
// "corp": { "module": "builtin:command",
|
|
14
|
+
// "create": "corpbox create --name {id}",
|
|
15
|
+
// "exec": "corpbox ssh {id} -- bash -c {script}",
|
|
16
|
+
// "destroy": "corpbox rm {id}" }
|
|
17
|
+
import { fill, run, spawnStream } from "./util.ts";
|
|
18
|
+
import type { ProviderFactory, RunOptions } from "../../src/types.ts";
|
|
19
|
+
|
|
20
|
+
const command: ProviderFactory = ({ options }) => {
|
|
21
|
+
for (const k of ["create", "exec", "destroy"]) if (!options[k]) throw new Error(`command provider: missing "${k}" template`);
|
|
22
|
+
const sh = (tpl: string, values: Record<string, unknown>, opts?: RunOptions) => run("sh", ["-c", fill(tpl, values)], opts);
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
async create({ id }) {
|
|
26
|
+
const out = (await sh(options.create, { id })).stdout.trim();
|
|
27
|
+
try {
|
|
28
|
+
return { id: JSON.parse(out).id ?? id };
|
|
29
|
+
} catch {
|
|
30
|
+
return { id: out && !/\s/.test(out) ? out : id };
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
exec: (id, script, { stdin } = {}) => sh(options.exec, { id, script }, { stdin, check: false }),
|
|
35
|
+
|
|
36
|
+
openStream: (id, script) => spawnStream("sh", ["-c", fill(options.stream ?? options.exec, { id, script })]),
|
|
37
|
+
|
|
38
|
+
...(options.status && { status: async (id: string) => ((await sh(options.status, { id }, { check: false })).code === 0 ? "running" : "gone") }),
|
|
39
|
+
|
|
40
|
+
destroy: (id) => sh(options.destroy, { id }, { check: false }),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
export default command;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Daytona sandboxes through @daytonaio/sdk. A box is a sandbox; its id is the sandbox id.
|
|
2
|
+
//
|
|
3
|
+
// options: {
|
|
4
|
+
// apiKey (secret source, e.g. { "store": "daytona" }), apiUrl?, target? (region),
|
|
5
|
+
// snapshot? (default: Daytona's default snapshot, user "daytona" with bash, git, curl and a writable $HOME),
|
|
6
|
+
// image? (any registry image instead of a snapshot; it needs bash, git, curl, base64 and a writable $HOME),
|
|
7
|
+
// autoStopMinutes? (default 0 = never)
|
|
8
|
+
// }
|
|
9
|
+
//
|
|
10
|
+
// template() builds a Daytona snapshot from the Dockerfile, in the organization's default region or `target`; COPY from a local context does not work since spec Dockerfiles have none.
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { PassThrough, Writable } from "node:stream";
|
|
16
|
+
import { shq } from "./util.ts";
|
|
17
|
+
import type { Sandbox } from "@daytonaio/sdk";
|
|
18
|
+
import type { BoxStream, ExecResult, ProviderFactory } from "../../src/types.ts";
|
|
19
|
+
|
|
20
|
+
const GONE = new Set(["destroyed", "destroying", "error", "build_failed"]);
|
|
21
|
+
const RESTARTABLE = new Set(["stopped", "archived", "paused"]);
|
|
22
|
+
const hex = () => randomBytes(6).toString("hex");
|
|
23
|
+
const SNAPSHOT_BUSY = new Set(["pending", "building", "pulling", "snapshotting", "removing"]);
|
|
24
|
+
|
|
25
|
+
const daytona: ProviderFactory = async ({ options, secret, log }) => {
|
|
26
|
+
if (!options.apiKey) throw new Error('daytona needs an apiKey option, e.g. "apiKey": { "store": "daytona" }');
|
|
27
|
+
// loaded here so other providers never pay for the SDK
|
|
28
|
+
const { Daytona, DaytonaNotFoundError, Image } = await import("@daytonaio/sdk");
|
|
29
|
+
const client = new Daytona({ apiKey: secret(options.apiKey), apiUrl: options.apiUrl, target: options.target });
|
|
30
|
+
// ponytail: never auto-stops, because Daytona counts a running agent as idle and would stop it mid-task; idle boxes bill until `bottleship rm`. Set autoStopMinutes to cap that, a stopped box restarts on the next exec but its agent is gone
|
|
31
|
+
const autoStopInterval: number = options.autoStopMinutes ?? 0;
|
|
32
|
+
|
|
33
|
+
const cache = new Map<string, Sandbox>();
|
|
34
|
+
const lookup = async (id: string): Promise<Sandbox | undefined> => {
|
|
35
|
+
try {
|
|
36
|
+
const sb = await client.get(id);
|
|
37
|
+
cache.set(id, sb);
|
|
38
|
+
return sb;
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e instanceof DaytonaNotFoundError) return undefined;
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// a sandbox that can run commands, started again if it was stopped or archived
|
|
46
|
+
async function started(id: string): Promise<Sandbox> {
|
|
47
|
+
const cached = cache.get(id);
|
|
48
|
+
if (cached?.state === "started") return cached;
|
|
49
|
+
const sb = await lookup(id);
|
|
50
|
+
if (!sb || GONE.has(sb.state ?? "")) throw new Error(`daytona sandbox ${id} is gone`);
|
|
51
|
+
if (RESTARTABLE.has(sb.state ?? "")) await sb.start(600);
|
|
52
|
+
else await sb.waitUntilStarted(600);
|
|
53
|
+
return sb;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// drops the cached sandbox on failure, so the next call looks its state up again
|
|
57
|
+
async function withBox<T>(id: string, fn: (sb: Sandbox) => Promise<T>): Promise<T> {
|
|
58
|
+
try {
|
|
59
|
+
return await fn(await started(id));
|
|
60
|
+
} catch (e) {
|
|
61
|
+
cache.delete(id);
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// the snapshot's state, or undefined once there is none by that name
|
|
67
|
+
const snapshotState = (name: string) =>
|
|
68
|
+
client.snapshot.get(name).then((s) => s.state as string, (e) => {
|
|
69
|
+
if (e instanceof DaytonaNotFoundError) return undefined;
|
|
70
|
+
throw e;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ponytail: a snapshot another init is still building is polled for at most 30 minutes; raise it if agent installs get that slow
|
|
74
|
+
async function settled(name: string): Promise<string | undefined> {
|
|
75
|
+
for (let i = 0; i < 900; i++) {
|
|
76
|
+
const state = await snapshotState(name);
|
|
77
|
+
if (!state || !SNAPSHOT_BUSY.has(state)) return state;
|
|
78
|
+
await new Promise((res) => setTimeout(res, 2000));
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`daytona snapshot ${name} is still not active after 30 minutes`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
// snapshots belong to the organization, so a name cannot resolve to another organization's snapshot
|
|
85
|
+
async template({ name, dockerfile }) {
|
|
86
|
+
let state = await settled(name);
|
|
87
|
+
// Daytona deactivates a snapshot after 2 weeks without use
|
|
88
|
+
if (state === "inactive") {
|
|
89
|
+
await client.snapshot.activate(name);
|
|
90
|
+
state = await settled(name);
|
|
91
|
+
}
|
|
92
|
+
if (state === "active") return name;
|
|
93
|
+
// a failed build keeps its name taken, so it goes before the rebuild
|
|
94
|
+
if (state) {
|
|
95
|
+
await client.snapshot.delete(name);
|
|
96
|
+
await settled(name);
|
|
97
|
+
}
|
|
98
|
+
// the SDK reads a Dockerfile only from disk
|
|
99
|
+
const dir = mkdtempSync(join(tmpdir(), "bottleship-daytona-"));
|
|
100
|
+
try {
|
|
101
|
+
writeFileSync(join(dir, "Dockerfile"), dockerfile);
|
|
102
|
+
await client.snapshot.create({ name, image: Image.fromDockerfile(join(dir, "Dockerfile")) }, { onLogs: (l) => log(l) });
|
|
103
|
+
} finally {
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
return name;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async create({ id, template }) {
|
|
110
|
+
const base = { name: id, labels: { bottleship: "1" }, autoStopInterval };
|
|
111
|
+
const sb = options.image && !template
|
|
112
|
+
? await client.create({ ...base, image: options.image }, { timeout: 600 })
|
|
113
|
+
: await client.create({ ...base, snapshot: template ?? options.snapshot }, { timeout: 300 });
|
|
114
|
+
cache.set(sb.id, sb);
|
|
115
|
+
return { id: sb.id };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
// the one-shot API merges stdout and stderr and takes no stdin, so stdin goes up as a file and both outputs come back base64'd
|
|
119
|
+
exec: (id, script, { stdin } = {}) =>
|
|
120
|
+
withBox(id, async (sb): Promise<ExecResult> => {
|
|
121
|
+
// a fresh 0700 dir, since stdin carries secrets (env.sh) and output may too
|
|
122
|
+
const d = `/tmp/bottleship-${hex()}`;
|
|
123
|
+
if (stdin !== undefined) {
|
|
124
|
+
await sb.fs.createFolder(d, "700");
|
|
125
|
+
await sb.fs.uploadFile(Buffer.from(stdin), `${d}/in`);
|
|
126
|
+
}
|
|
127
|
+
const r = await sb.process.executeCommand(
|
|
128
|
+
`${stdin !== undefined ? "" : `mkdir -m 700 ${d} || exit 1; `}bash -c ${shq(script)} < ${stdin !== undefined ? `${d}/in` : "/dev/null"} > ${d}/out 2> ${d}/err; echo $?; base64 < ${d}/out | tr -d '\\n'; echo; base64 < ${d}/err | tr -d '\\n'; echo; rm -rf ${d}`,
|
|
129
|
+
);
|
|
130
|
+
const [code, out, err] = r.result.split("\n");
|
|
131
|
+
if (r.exitCode !== 0 || !/^\d+$/.test(code ?? "")) throw new Error(`daytona exec in ${id} failed (exit ${r.exitCode}): ${r.result.slice(-500)}`);
|
|
132
|
+
return { code: Number(code), stdout: Buffer.from(out ?? "", "base64").toString(), stderr: Buffer.from(err ?? "", "base64").toString() };
|
|
133
|
+
}),
|
|
134
|
+
|
|
135
|
+
// a session command: stdin goes as base64 lines decoded in the box, so it stays binary-clean and "." ends it
|
|
136
|
+
// ponytail: Daytona relays output line by line as UTF-8 text, so a partial line shows once it ends and binary output is mangled; a PTY or a raw socket would fix that
|
|
137
|
+
openStream: (id, script) =>
|
|
138
|
+
withBox(id, async (sb): Promise<BoxStream> => {
|
|
139
|
+
const session = `bottleship-${hex()}`;
|
|
140
|
+
await sb.process.createSession(session);
|
|
141
|
+
const wrapper = `exec 3< <(while IFS= read -r l; do [ "$l" = . ] && break; printf %s "$l" | base64 -d; done)
|
|
142
|
+
bash -c ${shq(script)} <&3; c=$?; kill $! 2>/dev/null; exit $c`;
|
|
143
|
+
const { cmdId } = await sb.process.executeSessionCommand(session, { command: `bash -c ${shq(wrapper)}`, runAsync: true, suppressInputEcho: true });
|
|
144
|
+
const send = (line: string) => sb.process.sendSessionCommandInput(session, cmdId!, line).catch(() => {});
|
|
145
|
+
|
|
146
|
+
const stdout = new PassThrough();
|
|
147
|
+
const stderr = new PassThrough();
|
|
148
|
+
const closers: ((code?: number | null) => void)[] = [];
|
|
149
|
+
const end = () => sb.process.deleteSession(session).catch(() => {});
|
|
150
|
+
const stdin = new Writable({
|
|
151
|
+
write: (chunk: Buffer, _enc, cb) => void send(chunk.toString("base64") + "\n").then(() => cb()),
|
|
152
|
+
final: (cb) => void send(".\n").then(() => cb()),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
sb.process
|
|
156
|
+
.getSessionCommandLogs(session, cmdId!, (s) => stdout.write(s), (s) => stderr.write(s))
|
|
157
|
+
.catch(() => {})
|
|
158
|
+
.then(() => sb.process.getSessionCommand(session, cmdId!).then((c) => c.exitCode ?? null, () => null))
|
|
159
|
+
.then((code) => {
|
|
160
|
+
stdout.end();
|
|
161
|
+
stderr.end();
|
|
162
|
+
closers.forEach((cb) => cb(code));
|
|
163
|
+
void end();
|
|
164
|
+
});
|
|
165
|
+
return { stdin, stdout, stderr, close: () => void end(), onClose: (cb) => closers.push(cb) };
|
|
166
|
+
}),
|
|
167
|
+
|
|
168
|
+
// a stopped or archived sandbox still counts as running, since exec starts it again
|
|
169
|
+
async status(id) {
|
|
170
|
+
const sb = await lookup(id);
|
|
171
|
+
return sb && !GONE.has(sb.state ?? "") ? "running" : "gone";
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async destroy(id) {
|
|
175
|
+
const sb = await lookup(id);
|
|
176
|
+
cache.delete(id);
|
|
177
|
+
if (sb && !GONE.has(sb.state ?? "")) await sb.delete(120);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
export default daytona;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Local Docker as a provider. Boxes are containers that live until `bottleship rm`.
|
|
2
|
+
// The template is a local image tagged with its spec name, running as its own non-root user box.
|
|
3
|
+
//
|
|
4
|
+
// options: { image? (used without a template; default exeuntu, a dev image with git, curl and a non-root user), user? }
|
|
5
|
+
import { run, spawnStream } from "./util.ts";
|
|
6
|
+
import type { ProviderFactory } from "../../src/types.ts";
|
|
7
|
+
|
|
8
|
+
const docker: ProviderFactory = ({ options }) => {
|
|
9
|
+
const image: string = options.image ?? "ghcr.io/boldsoftware/exeuntu:latest";
|
|
10
|
+
const user: string | undefined = options.user ?? (image.includes("exeuntu") ? "exedev" : undefined);
|
|
11
|
+
// execs run as the container's user, set at create
|
|
12
|
+
const execArgs = (id: string, script: string) => ["exec", "-i", id, "bash", "-c", script];
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
async template({ name, dockerfile }) {
|
|
16
|
+
if ((await run("docker", ["image", "inspect", name], { check: false })).code !== 0) await run("docker", ["build", "-q", "-t", name, "-"], { stdin: dockerfile });
|
|
17
|
+
return name;
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
async create({ id, template }) {
|
|
21
|
+
const from = template ? [template] : [...(user ? ["--user", user] : []), image];
|
|
22
|
+
await run("docker", ["run", "-d", "--name", id, "--label", "bottleship=1", "--entrypoint", "sleep", ...from, "infinity"]);
|
|
23
|
+
return { id };
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
exec: (id, script, { stdin } = {}) => run("docker", execArgs(id, script), { stdin, check: false }),
|
|
27
|
+
|
|
28
|
+
openStream: (id, script) => spawnStream("docker", execArgs(id, script)),
|
|
29
|
+
|
|
30
|
+
async status(id) {
|
|
31
|
+
const r = await run("docker", ["inspect", "-f", "{{.State.Running}}", id], { check: false });
|
|
32
|
+
return r.code === 0 && r.stdout.trim() === "true" ? "running" : "gone";
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
destroy: (id) => run("docker", ["rm", "-f", id], { check: false }),
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
export default docker;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// E2B: Firecracker sandboxes over the e2b SDK. The default "base" template is Debian with bash, git, curl,
|
|
2
|
+
// node and gh, run as a user with a writable $HOME.
|
|
3
|
+
//
|
|
4
|
+
// template() builds an E2B template from the Dockerfile; the E2B builder ignores SHELL, and COPY or ADD from a local context does not work since spec Dockerfiles have none.
|
|
5
|
+
//
|
|
6
|
+
// options: { apiKey? (secret source, else E2B_API_KEY), template? (default "base"), timeoutMinutes? (default 60) }
|
|
7
|
+
import { PassThrough, Writable } from "node:stream";
|
|
8
|
+
import type { BoxStream, ExecResult, ProviderFactory } from "../../src/types.ts";
|
|
9
|
+
import type { CommandResult, Sandbox as SandboxT } from "e2b";
|
|
10
|
+
|
|
11
|
+
const e2b: ProviderFactory = async ({ options, secret, log }) => {
|
|
12
|
+
const { Sandbox, Template, ApiClient, ConnectionConfig, CommandExitError, NotFoundError } = await import("e2b");
|
|
13
|
+
const apiKey: string | undefined = options.apiKey ? secret(options.apiKey) : undefined;
|
|
14
|
+
// ponytail: a box dies this long after bottleship last touched it, and E2B caps a box's whole life at 1h (Hobby) or 24h (Pro); pause/resume lifecycle if agents run longer
|
|
15
|
+
const timeoutMs = (options.timeoutMinutes ?? 60) * 60_000;
|
|
16
|
+
|
|
17
|
+
// connecting also extends the box's timeout, so every exec and status keeps a box someone checks on alive
|
|
18
|
+
const boxes = new Map<string, { sbx: SandboxT; at: number }>();
|
|
19
|
+
async function box(id: string): Promise<SandboxT> {
|
|
20
|
+
const hit = boxes.get(id);
|
|
21
|
+
if (hit && Date.now() - hit.at < 60_000) return hit.sbx;
|
|
22
|
+
const sbx = await Sandbox.connect(id, { apiKey, timeoutMs });
|
|
23
|
+
boxes.set(id, { sbx, at: Date.now() });
|
|
24
|
+
return sbx;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = (r: CommandResult): ExecResult => ({ code: r.exitCode, stdout: r.stdout, stderr: r.stderr });
|
|
28
|
+
// a non-zero exit is a result, not an error
|
|
29
|
+
const settle = (p: Promise<CommandResult>) => p.then(result, (e) => {
|
|
30
|
+
if (e instanceof CommandExitError) return result(e);
|
|
31
|
+
throw e;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// the id of this team's template named name with a finished build; a bare name can also resolve to another team's public template, so names are never handed to create
|
|
35
|
+
async function ownTemplate(name: string): Promise<string | undefined> {
|
|
36
|
+
const { api } = new ApiClient(new ConnectionConfig({ apiKey }));
|
|
37
|
+
let nextToken: string | undefined;
|
|
38
|
+
do {
|
|
39
|
+
const res = await api.GET("/v2/templates", { params: { query: { limit: 100, nextToken } } });
|
|
40
|
+
if (!res.data) throw new Error(`e2b could not list templates: ${res.response.status} ${JSON.stringify(res.error)}`);
|
|
41
|
+
const hit = res.data.find((t) => !t.isDefault && t.buildStatus === "ready" && t.names.some((n) => n === name || n.endsWith(`/${name}`)));
|
|
42
|
+
if (hit) return hit.templateID;
|
|
43
|
+
nextToken = res.response.headers.get("X-Next-Token") || undefined;
|
|
44
|
+
} while (nextToken);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
// ponytail: two inits racing on one name both build, E2B keeps the later build; fine since both come from the same Dockerfile
|
|
49
|
+
async template({ name, dockerfile }) {
|
|
50
|
+
const found = await ownTemplate(name);
|
|
51
|
+
if (found) return found;
|
|
52
|
+
const built = await Template.build(Template().fromDockerfile(dockerfile), name, { apiKey, onBuildLogs: (e) => log(String(e)) });
|
|
53
|
+
return built.templateId;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// the bottleship id goes into metadata; E2B picks the sandbox id
|
|
57
|
+
async create({ id, template }) {
|
|
58
|
+
const sbx = await Sandbox.create(template ?? options.template ?? "base", { apiKey, timeoutMs, metadata: { bottleship: id } });
|
|
59
|
+
await sbx.commands.run("true");
|
|
60
|
+
boxes.set(sbx.sandboxId, { sbx, at: Date.now() });
|
|
61
|
+
return { id: sbx.sandboxId };
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// the SDK runs `bash -l -c <script>`; timeoutMs 0 lifts its 60 second command limit
|
|
65
|
+
async exec(id, script, { stdin } = {}) {
|
|
66
|
+
const sbx = await box(id);
|
|
67
|
+
if (stdin === undefined) return settle(sbx.commands.run(script, { timeoutMs: 0 }));
|
|
68
|
+
const h = await sbx.commands.run(script, { background: true, stdin: true, timeoutMs: 0 });
|
|
69
|
+
if (stdin.length) await h.sendStdin(stdin);
|
|
70
|
+
await h.closeStdin();
|
|
71
|
+
return settle(h.wait());
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// ponytail: the SDK hands output over as UTF-8 text, so binary stdout gets mangled; fine for text protocols
|
|
75
|
+
async openStream(id, script): Promise<BoxStream> {
|
|
76
|
+
const sbx = await box(id);
|
|
77
|
+
const stdout = new PassThrough();
|
|
78
|
+
const stderr = new PassThrough();
|
|
79
|
+
const h = await sbx.commands.run(script, {
|
|
80
|
+
background: true,
|
|
81
|
+
stdin: true,
|
|
82
|
+
timeoutMs: 0,
|
|
83
|
+
onStdout: (d) => void stdout.write(d),
|
|
84
|
+
onStderr: (d) => void stderr.write(d),
|
|
85
|
+
});
|
|
86
|
+
let code: number | null | undefined;
|
|
87
|
+
const closers: ((code?: number | null) => void)[] = [];
|
|
88
|
+
const done = h.wait().then(
|
|
89
|
+
(r) => r.exitCode,
|
|
90
|
+
(e) => (e instanceof CommandExitError ? e.exitCode : null),
|
|
91
|
+
);
|
|
92
|
+
void done.then((c) => {
|
|
93
|
+
code = c;
|
|
94
|
+
stdout.end();
|
|
95
|
+
stderr.end();
|
|
96
|
+
closers.splice(0).forEach((cb) => cb(c));
|
|
97
|
+
});
|
|
98
|
+
// Writable runs one write at a time, so stdin arrives in order
|
|
99
|
+
const stdin = new Writable({
|
|
100
|
+
write: (chunk: Buffer, _enc, cb) => void h.sendStdin(chunk).then(() => cb(), cb),
|
|
101
|
+
final: (cb) => void h.closeStdin().then(() => cb(), cb),
|
|
102
|
+
});
|
|
103
|
+
stdin.on("error", () => {});
|
|
104
|
+
return {
|
|
105
|
+
stdin,
|
|
106
|
+
stdout,
|
|
107
|
+
stderr,
|
|
108
|
+
close: () => void h.kill().catch(() => {}),
|
|
109
|
+
onClose: (cb) => (code === undefined ? closers.push(cb) : cb(code)),
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
async status(id) {
|
|
114
|
+
boxes.delete(id);
|
|
115
|
+
try {
|
|
116
|
+
await box(id);
|
|
117
|
+
return "running";
|
|
118
|
+
} catch (e) {
|
|
119
|
+
if (e instanceof NotFoundError) return "gone";
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async destroy(id) {
|
|
125
|
+
boxes.delete(id);
|
|
126
|
+
return Sandbox.kill(id, { apiKey });
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
export default e2b;
|