@madarco/agentbox 0.6.0 → 0.7.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/dist/_cloud-attach-DMVH6GWO.js +12 -0
- package/dist/chunk-7KOEFGN2.js +1162 -0
- package/dist/chunk-7KOEFGN2.js.map +1 -0
- package/dist/chunk-I24B6AXR.js +600 -0
- package/dist/chunk-I24B6AXR.js.map +1 -0
- package/dist/chunk-NAVL4R34.js +7546 -0
- package/dist/chunk-NAVL4R34.js.map +1 -0
- package/dist/chunk-NW5NYTQM.js +1366 -0
- package/dist/chunk-NW5NYTQM.js.map +1 -0
- package/dist/chunk-UK72UQ5U.js +237 -0
- package/dist/chunk-UK72UQ5U.js.map +1 -0
- package/dist/chunk-V5KZGB5V.js +722 -0
- package/dist/chunk-V5KZGB5V.js.map +1 -0
- package/dist/cloud-poller-ZIWSADJB-JXFRJUEM.js +10 -0
- package/dist/dist-ETCFRVPA.js +423 -0
- package/dist/dist-QZGJIBT5.js +1339 -0
- package/dist/dist-QZGJIBT5.js.map +1 -0
- package/dist/dist-R67WMLCF.js +183 -0
- package/dist/dist-R67WMLCF.js.map +1 -0
- package/dist/index.js +3998 -1569
- package/dist/index.js.map +1 -1
- package/package.json +8 -3
- package/runtime/docker/Dockerfile.box +98 -14
- package/runtime/docker/apps/cli/share/agentbox-setup/SKILL.md +15 -8
- package/runtime/docker/packages/ctl/dist/bin.cjs +10220 -773
- package/runtime/docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json +37 -0
- package/runtime/docker/packages/sandbox-docker/scripts/agentbox-open +9 -9
- package/runtime/hetzner/agentbox-checkpoint-cleanup +52 -0
- package/runtime/hetzner/agentbox-codex-hooks.json +37 -0
- package/runtime/hetzner/agentbox-dockerd-start +132 -0
- package/runtime/hetzner/agentbox-open +28 -0
- package/runtime/hetzner/agentbox-setup-skill.md +196 -0
- package/runtime/hetzner/agentbox-vnc-start +77 -0
- package/runtime/hetzner/claude-managed-settings.json +54 -0
- package/runtime/hetzner/ctl.cjs +22350 -0
- package/runtime/hetzner/custom-system-CLAUDE.md +27 -0
- package/runtime/hetzner/scripts/install-box.sh +365 -0
- package/runtime/relay/bin.cjs +9118 -809
- package/share/agentbox-setup/SKILL.md +15 -8
- package/dist/chunk-BBZMA2K6.js +0 -238
- package/dist/chunk-BBZMA2K6.js.map +0 -1
- package/dist/chunk-HHMWQNLF.js +0 -1709
- package/dist/chunk-HHMWQNLF.js.map +0 -1
- package/dist/chunk-HPZMD5DE.js +0 -106
- package/dist/chunk-HPZMD5DE.js.map +0 -1
- package/dist/chunk-HTTKML3C.js +0 -2655
- package/dist/chunk-HTTKML3C.js.map +0 -1
- package/dist/chunk-KJNZP6I3.js +0 -586
- package/dist/chunk-KJNZP6I3.js.map +0 -1
- package/dist/chunk-M7I247BK.js +0 -525
- package/dist/chunk-M7I247BK.js.map +0 -1
- package/dist/create-6PWXI6HO-OWAMHBAK.js +0 -15
- package/dist/lifecycle-EMXR46DI-DUVBXNTV.js +0 -38
- package/dist/state-KD7M46ZP-KHFTHFUS.js +0 -26
- package/dist/stats-SZXOJE3D-N7OODCHW.js +0 -19
- package/dist/stats-SZXOJE3D-N7OODCHW.js.map +0 -1
- /package/dist/{create-6PWXI6HO-OWAMHBAK.js.map → _cloud-attach-DMVH6GWO.js.map} +0 -0
- /package/dist/{lifecycle-EMXR46DI-DUVBXNTV.js.map → cloud-poller-ZIWSADJB-JXFRJUEM.js.map} +0 -0
- /package/dist/{state-KD7M46ZP-KHFTHFUS.js.map → dist-ETCFRVPA.js.map} +0 -0
|
@@ -0,0 +1,1339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_HCLOUD_ENDPOINT,
|
|
4
|
+
HetznerApiError,
|
|
5
|
+
createPerBoxFirewall,
|
|
6
|
+
deletePerBoxFirewall,
|
|
7
|
+
detectEgressIp,
|
|
8
|
+
ensureHetznerCredentials,
|
|
9
|
+
ensureHetznerEnvLoaded,
|
|
10
|
+
isAttemptTimeout,
|
|
11
|
+
isRetriable,
|
|
12
|
+
makeHetznerClient,
|
|
13
|
+
maskKey,
|
|
14
|
+
normalizeSourceCidr,
|
|
15
|
+
readHetznerCredStatus,
|
|
16
|
+
secretsPath,
|
|
17
|
+
sshOnlyInboundRule,
|
|
18
|
+
syncFirewallSource,
|
|
19
|
+
withHetznerRetry
|
|
20
|
+
} from "./chunk-I24B6AXR.js";
|
|
21
|
+
import {
|
|
22
|
+
createCloudProvider
|
|
23
|
+
} from "./chunk-NW5NYTQM.js";
|
|
24
|
+
import {
|
|
25
|
+
stageClaudeCredentialsForUpload,
|
|
26
|
+
stageClaudeStaticForUpload,
|
|
27
|
+
stageCodexCredentialsForUpload,
|
|
28
|
+
stageCodexStaticForUpload,
|
|
29
|
+
stageOpencodeCredentialsForUpload,
|
|
30
|
+
stageOpencodeStaticForUpload
|
|
31
|
+
} from "./chunk-NAVL4R34.js";
|
|
32
|
+
import "./chunk-UK72UQ5U.js";
|
|
33
|
+
|
|
34
|
+
// ../../packages/sandbox-hetzner/dist/index.js
|
|
35
|
+
import { existsSync as existsSync4 } from "fs";
|
|
36
|
+
import { rm as rm2, rename, mkdir as mkdir3 } from "fs/promises";
|
|
37
|
+
import { join as join4 } from "path";
|
|
38
|
+
import { execa as execa4 } from "execa";
|
|
39
|
+
import { resolve as resolvePath } from "path";
|
|
40
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
41
|
+
import { homedir } from "os";
|
|
42
|
+
import { dirname, resolve } from "path";
|
|
43
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
44
|
+
import { join as join2 } from "path";
|
|
45
|
+
import { createHash } from "crypto";
|
|
46
|
+
import { existsSync as existsSync2 } from "fs";
|
|
47
|
+
import { dirname as dirname2, resolve as resolve2 } from "path";
|
|
48
|
+
import { fileURLToPath } from "url";
|
|
49
|
+
import { mkdir, readFile } from "fs/promises";
|
|
50
|
+
import { dirname as dirname3, join, resolve as resolve3 } from "path";
|
|
51
|
+
import { execa } from "execa";
|
|
52
|
+
import { execa as execa2 } from "execa";
|
|
53
|
+
import { existsSync as existsSync3 } from "fs";
|
|
54
|
+
import { mkdir as mkdir2, rm } from "fs/promises";
|
|
55
|
+
import { createServer } from "net";
|
|
56
|
+
import { homedir as homedir2 } from "os";
|
|
57
|
+
import { dirname as dirname4, join as join3, resolve as resolve4 } from "path";
|
|
58
|
+
import { execa as execa3 } from "execa";
|
|
59
|
+
function generatePrepareCloudInit(opts) {
|
|
60
|
+
const pubkey = opts.sshPubkey.trim();
|
|
61
|
+
return [
|
|
62
|
+
"#cloud-config",
|
|
63
|
+
"# AgentBox temporary prepare VPS \u2014 used by `agentbox prepare --provider hetzner`",
|
|
64
|
+
"# to bake the base snapshot. SSH key is single-use and discarded on VPS destroy.",
|
|
65
|
+
"disable_root: false",
|
|
66
|
+
"ssh_pwauth: false",
|
|
67
|
+
// Hetzner's Ubuntu 24.04 stock image enforces a first-login password
|
|
68
|
+
// change for root. With key-based auth that path can't run (no TTY),
|
|
69
|
+
// so sshd refuses with "Password change required but no TTY available."
|
|
70
|
+
// Telling cloud-init to NOT expire passwords + clearing root's expiry
|
|
71
|
+
// via `passwd -d` removes the gate. Belt-and-braces: the chpasswd block
|
|
72
|
+
// covers cloud-init's own users-and-groups run; the runcmd covers the
|
|
73
|
+
// case where the image's pre-baked expiry survives cloud-init.
|
|
74
|
+
"chpasswd:",
|
|
75
|
+
" expire: false",
|
|
76
|
+
"users:",
|
|
77
|
+
" - name: root",
|
|
78
|
+
" lock_passwd: false",
|
|
79
|
+
" ssh_authorized_keys:",
|
|
80
|
+
` - ${yamlScalar(pubkey)}`,
|
|
81
|
+
"runcmd:",
|
|
82
|
+
" - [ passwd, -d, root ]",
|
|
83
|
+
' - [ chage, -E, "-1", -I, "-1", -M, "99999", root ]',
|
|
84
|
+
' - [ bash, -lc, "echo agentbox-prepare-ready" ]',
|
|
85
|
+
""
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
function generateBoxCloudInit(opts) {
|
|
89
|
+
const pubkey = opts.sshPubkey.trim();
|
|
90
|
+
const lines = [
|
|
91
|
+
"#cloud-config",
|
|
92
|
+
`# AgentBox per-box VPS \u2014 box '${opts.boxName}'`,
|
|
93
|
+
"disable_root: true",
|
|
94
|
+
"ssh_pwauth: false",
|
|
95
|
+
// Same first-login expiry guard as the prepare cloud-init — keeps
|
|
96
|
+
// Hetzner's Ubuntu hardening from blocking our key-based vscode login.
|
|
97
|
+
"chpasswd:",
|
|
98
|
+
" expire: false",
|
|
99
|
+
"users:",
|
|
100
|
+
" - name: vscode",
|
|
101
|
+
" lock_passwd: false",
|
|
102
|
+
" sudo: ALL=(ALL) NOPASSWD:ALL",
|
|
103
|
+
" ssh_authorized_keys:",
|
|
104
|
+
` - ${yamlScalar(pubkey)}`
|
|
105
|
+
];
|
|
106
|
+
const writeFiles = [
|
|
107
|
+
" path: /etc/hosts",
|
|
108
|
+
" append: true",
|
|
109
|
+
` content: "127.0.0.1 ${opts.boxName}.localhost\\n"`
|
|
110
|
+
];
|
|
111
|
+
lines.push("write_files:");
|
|
112
|
+
lines.push(" - " + writeFiles[0]);
|
|
113
|
+
for (let i = 1; i < writeFiles.length; i++) {
|
|
114
|
+
lines.push(" " + writeFiles[i]);
|
|
115
|
+
}
|
|
116
|
+
if (opts.boxEnv && Object.keys(opts.boxEnv).length > 0) {
|
|
117
|
+
const envContent = Object.entries(opts.boxEnv).map(([k, v]) => `${k}=${v}`).join("\\n") + "\\n";
|
|
118
|
+
lines.push(" - path: /etc/agentbox/box.env");
|
|
119
|
+
lines.push(' permissions: "0644"');
|
|
120
|
+
lines.push(` content: "${envContent}"`);
|
|
121
|
+
}
|
|
122
|
+
lines.push("");
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
function yamlScalar(value) {
|
|
126
|
+
if (/["\\]/.test(value)) {
|
|
127
|
+
return JSON.stringify(value);
|
|
128
|
+
}
|
|
129
|
+
return `"${value}"`;
|
|
130
|
+
}
|
|
131
|
+
async function pollUntil(label, check, opts = {}) {
|
|
132
|
+
const deadline = Date.now() + (opts.deadlineMs ?? 5 * 6e4);
|
|
133
|
+
const max = opts.maxIntervalMs ?? 1e4;
|
|
134
|
+
let interval = opts.intervalMs ?? 1e3;
|
|
135
|
+
let attempt = 0;
|
|
136
|
+
while (true) {
|
|
137
|
+
attempt += 1;
|
|
138
|
+
const out = await check();
|
|
139
|
+
if (out !== null && out !== void 0) return out;
|
|
140
|
+
if (Date.now() >= deadline) {
|
|
141
|
+
throw new Error(`hetzner: timed out waiting for ${label} after ${String(attempt)} attempts`);
|
|
142
|
+
}
|
|
143
|
+
opts.onPoll?.(`${label}: not ready yet (attempt ${String(attempt)}); polling again in ${String(interval)}ms`);
|
|
144
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
145
|
+
interval = Math.min(interval * 2, max);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
var SCHEMA = 1;
|
|
149
|
+
function preparedStatePath() {
|
|
150
|
+
return resolve(homedir(), ".agentbox", "hetzner-prepared.json");
|
|
151
|
+
}
|
|
152
|
+
function readPreparedState() {
|
|
153
|
+
const path = preparedStatePath();
|
|
154
|
+
if (!existsSync(path)) return { schema: SCHEMA, projects: {} };
|
|
155
|
+
try {
|
|
156
|
+
const raw = readFileSync(path, "utf8");
|
|
157
|
+
const parsed = JSON.parse(raw);
|
|
158
|
+
if (parsed.schema !== SCHEMA) {
|
|
159
|
+
return { schema: SCHEMA, projects: {} };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
schema: SCHEMA,
|
|
163
|
+
base: parsed.base,
|
|
164
|
+
projects: parsed.projects ?? {}
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return { schema: SCHEMA, projects: {} };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function writePreparedState(state) {
|
|
171
|
+
const path = preparedStatePath();
|
|
172
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
173
|
+
const body = JSON.stringify(state, null, 2) + "\n";
|
|
174
|
+
const tmp = `${path}.tmp`;
|
|
175
|
+
writeFileSync(tmp, body, { mode: 384 });
|
|
176
|
+
renameSync(tmp, path);
|
|
177
|
+
}
|
|
178
|
+
function updatePreparedState(mutate) {
|
|
179
|
+
const s = readPreparedState();
|
|
180
|
+
mutate(s);
|
|
181
|
+
writePreparedState(s);
|
|
182
|
+
}
|
|
183
|
+
var SELF = dirname2(fileURLToPath(import.meta.url));
|
|
184
|
+
function findStagedCliRuntimeRoot() {
|
|
185
|
+
const candidates = [
|
|
186
|
+
resolve2(SELF, "..", "runtime"),
|
|
187
|
+
// <cliRoot>/dist/.. → <cliRoot> then /runtime
|
|
188
|
+
resolve2(SELF, "..", "..", "runtime")
|
|
189
|
+
// chunk-NNNN.js at <cliRoot>/dist/<sub>/.. → <cliRoot>/runtime
|
|
190
|
+
];
|
|
191
|
+
for (const c of candidates) {
|
|
192
|
+
if (existsSync2(resolve2(c, "hetzner", "scripts", "install-box.sh"))) return c;
|
|
193
|
+
}
|
|
194
|
+
return void 0;
|
|
195
|
+
}
|
|
196
|
+
var RUNTIME_ASSETS = [
|
|
197
|
+
{ name: "install-box.sh", remoteBasename: "agentbox-install.sh", remoteMode: 493 },
|
|
198
|
+
{ name: "agentbox-ctl", remoteBasename: "agentbox-ctl", remoteMode: 493 },
|
|
199
|
+
{ name: "agentbox-vnc-start", remoteBasename: "agentbox-vnc-start", remoteMode: 493 },
|
|
200
|
+
{ name: "agentbox-dockerd-start", remoteBasename: "agentbox-dockerd-start", remoteMode: 493 },
|
|
201
|
+
{ name: "agentbox-checkpoint-cleanup", remoteBasename: "agentbox-checkpoint-cleanup", remoteMode: 493 },
|
|
202
|
+
{ name: "agentbox-open", remoteBasename: "agentbox-open", remoteMode: 493 },
|
|
203
|
+
{ name: "custom-system-CLAUDE.md", remoteBasename: "agentbox-custom-CLAUDE.md", remoteMode: 420 },
|
|
204
|
+
{ name: "claude-managed-settings.json", remoteBasename: "agentbox-managed-settings.json", remoteMode: 420 },
|
|
205
|
+
{ name: "agentbox-codex-hooks.json", remoteBasename: "agentbox-codex-hooks.json", remoteMode: 420 },
|
|
206
|
+
{ name: "agentbox-setup-skill.md", remoteBasename: "agentbox-setup-skill.md", remoteMode: 420 }
|
|
207
|
+
];
|
|
208
|
+
function candidatesFor(name, opts = {}) {
|
|
209
|
+
const cliRoot = opts.cliRuntimeRoot;
|
|
210
|
+
const monorepo = opts.repoRoot ?? guessRepoRoot();
|
|
211
|
+
const monorepoRelative = {
|
|
212
|
+
"install-box.sh": ["packages/sandbox-hetzner/scripts/install-box.sh"],
|
|
213
|
+
"agentbox-ctl": ["packages/ctl/dist/bin.cjs"],
|
|
214
|
+
"agentbox-vnc-start": ["packages/sandbox-docker/scripts/agentbox-vnc-start"],
|
|
215
|
+
"agentbox-dockerd-start": ["packages/sandbox-docker/scripts/agentbox-dockerd-start"],
|
|
216
|
+
"agentbox-checkpoint-cleanup": ["packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup"],
|
|
217
|
+
"agentbox-open": ["packages/sandbox-docker/scripts/agentbox-open"],
|
|
218
|
+
"custom-system-CLAUDE.md": ["packages/sandbox-docker/scripts/custom-system-CLAUDE.md"],
|
|
219
|
+
"claude-managed-settings.json": ["packages/sandbox-docker/scripts/claude-managed-settings.json"],
|
|
220
|
+
"agentbox-codex-hooks.json": ["packages/sandbox-docker/scripts/agentbox-codex-hooks.json"],
|
|
221
|
+
"agentbox-setup-skill.md": ["apps/cli/share/agentbox-setup/SKILL.md"]
|
|
222
|
+
};
|
|
223
|
+
const cliRelative = {
|
|
224
|
+
"install-box.sh": ["hetzner/scripts/install-box.sh"],
|
|
225
|
+
"agentbox-ctl": ["hetzner/ctl.cjs"],
|
|
226
|
+
"agentbox-vnc-start": ["hetzner/agentbox-vnc-start", "docker/packages/sandbox-docker/scripts/agentbox-vnc-start"],
|
|
227
|
+
"agentbox-dockerd-start": ["hetzner/agentbox-dockerd-start", "docker/packages/sandbox-docker/scripts/agentbox-dockerd-start"],
|
|
228
|
+
"agentbox-checkpoint-cleanup": ["hetzner/agentbox-checkpoint-cleanup", "docker/packages/sandbox-docker/scripts/agentbox-checkpoint-cleanup"],
|
|
229
|
+
"agentbox-open": ["hetzner/agentbox-open", "docker/packages/sandbox-docker/scripts/agentbox-open"],
|
|
230
|
+
"custom-system-CLAUDE.md": ["hetzner/custom-system-CLAUDE.md", "docker/packages/sandbox-docker/scripts/custom-system-CLAUDE.md"],
|
|
231
|
+
"claude-managed-settings.json": ["hetzner/claude-managed-settings.json", "docker/packages/sandbox-docker/scripts/claude-managed-settings.json"],
|
|
232
|
+
"agentbox-codex-hooks.json": ["hetzner/agentbox-codex-hooks.json", "docker/packages/sandbox-docker/scripts/agentbox-codex-hooks.json"],
|
|
233
|
+
"agentbox-setup-skill.md": ["hetzner/agentbox-setup-skill.md", "docker/apps/cli/share/agentbox-setup/SKILL.md"]
|
|
234
|
+
};
|
|
235
|
+
const out = [];
|
|
236
|
+
if (cliRoot) {
|
|
237
|
+
for (const rel of cliRelative[name] ?? []) out.push(resolve2(cliRoot, rel));
|
|
238
|
+
}
|
|
239
|
+
for (const rel of monorepoRelative[name] ?? []) out.push(resolve2(monorepo, rel));
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
function resolveRuntimeAssets(opts = {}) {
|
|
243
|
+
const out = [];
|
|
244
|
+
const missing = [];
|
|
245
|
+
for (const asset of RUNTIME_ASSETS) {
|
|
246
|
+
const cands = candidatesFor(asset.name, opts);
|
|
247
|
+
const hit = cands.find((p) => existsSync2(p));
|
|
248
|
+
if (!hit) {
|
|
249
|
+
missing.push({ name: asset.name, tried: cands });
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
out.push({ ...asset, localPath: hit });
|
|
253
|
+
}
|
|
254
|
+
if (missing.length > 0) {
|
|
255
|
+
const lines = missing.flatMap((m) => [` - ${m.name}: tried`, ...m.tried.map((p) => ` ${p}`)]);
|
|
256
|
+
throw new Error(
|
|
257
|
+
`hetzner: could not resolve runtime assets \u2014 these files are needed to install on the prepare VPS:
|
|
258
|
+
` + lines.join("\n") + `
|
|
259
|
+
|
|
260
|
+
If you are running from the monorepo, ensure \`pnpm -w build\` has run so packages/ctl/dist/bin.cjs exists. If you are running from a published CLI bundle, the runtime/hetzner tree should be staged automatically.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
function guessRepoRoot() {
|
|
266
|
+
let cur = SELF;
|
|
267
|
+
for (let i = 0; i < 8; i++) {
|
|
268
|
+
if (existsSync2(resolve2(cur, "pnpm-workspace.yaml"))) return cur;
|
|
269
|
+
const parent = dirname2(cur);
|
|
270
|
+
if (parent === cur) break;
|
|
271
|
+
cur = parent;
|
|
272
|
+
}
|
|
273
|
+
return SELF;
|
|
274
|
+
}
|
|
275
|
+
async function mintSshKey(targetDir, comment) {
|
|
276
|
+
const dir = resolve3(targetDir);
|
|
277
|
+
const priv = join(dir, "id_ed25519");
|
|
278
|
+
const pub = `${priv}.pub`;
|
|
279
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
280
|
+
await execa(
|
|
281
|
+
"ssh-keygen",
|
|
282
|
+
["-t", "ed25519", "-N", "", "-C", comment, "-f", priv, "-q"],
|
|
283
|
+
{ stdio: "pipe" }
|
|
284
|
+
);
|
|
285
|
+
const publicKey = (await readFile(pub, "utf8")).trim();
|
|
286
|
+
return { dir, privatePath: priv, publicPath: pub, publicKey };
|
|
287
|
+
}
|
|
288
|
+
async function mintPrepareKey() {
|
|
289
|
+
const root = resolve3(homedirOrCwd(), ".agentbox", "hetzner", `prepare-${Date.now().toString(36)}`);
|
|
290
|
+
const key = await mintSshKey(root, `agentbox-prepare-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
|
|
291
|
+
return {
|
|
292
|
+
...key,
|
|
293
|
+
cleanup: async () => {
|
|
294
|
+
try {
|
|
295
|
+
const { rm: rm3 } = await import("fs/promises");
|
|
296
|
+
await rm3(dirname3(key.privatePath), { recursive: true, force: true });
|
|
297
|
+
} catch {
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function homedirOrCwd() {
|
|
303
|
+
try {
|
|
304
|
+
return process.env.HOME ?? process.cwd();
|
|
305
|
+
} catch {
|
|
306
|
+
return process.cwd();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function sshOptArgs(target) {
|
|
310
|
+
const out = [
|
|
311
|
+
"-i",
|
|
312
|
+
target.identity,
|
|
313
|
+
"-o",
|
|
314
|
+
"StrictHostKeyChecking=accept-new",
|
|
315
|
+
"-o",
|
|
316
|
+
`UserKnownHostsFile=${target.knownHosts}`,
|
|
317
|
+
"-o",
|
|
318
|
+
"GlobalKnownHostsFile=/dev/null",
|
|
319
|
+
"-o",
|
|
320
|
+
"BatchMode=yes",
|
|
321
|
+
"-o",
|
|
322
|
+
"LogLevel=ERROR"
|
|
323
|
+
];
|
|
324
|
+
if (target.controlPath) {
|
|
325
|
+
out.push("-o", `ControlPath=${target.controlPath}`);
|
|
326
|
+
}
|
|
327
|
+
for (const [k, v] of Object.entries(target.options ?? {})) {
|
|
328
|
+
out.push("-o", `${k}=${v}`);
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
async function sshExec(target, remoteCmd, opts = {}) {
|
|
333
|
+
const argv = [
|
|
334
|
+
...sshOptArgs(target),
|
|
335
|
+
`${target.user}@${target.host}`,
|
|
336
|
+
remoteCmd
|
|
337
|
+
];
|
|
338
|
+
const child = execa2("ssh", argv, {
|
|
339
|
+
reject: false,
|
|
340
|
+
timeout: opts.timeoutMs,
|
|
341
|
+
env: { ...process.env, ...opts.env },
|
|
342
|
+
stdio: opts.onLine ? ["ignore", "pipe", "pipe"] : ["ignore", "pipe", "pipe"]
|
|
343
|
+
});
|
|
344
|
+
if (opts.onLine) {
|
|
345
|
+
const handle = (chunk) => {
|
|
346
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
347
|
+
for (const line of text.split(/\r?\n/)) {
|
|
348
|
+
if (line.length > 0) opts.onLine?.(line);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
child.stdout?.on("data", handle);
|
|
352
|
+
child.stderr?.on("data", handle);
|
|
353
|
+
}
|
|
354
|
+
const res = await child;
|
|
355
|
+
return {
|
|
356
|
+
exitCode: typeof res.exitCode === "number" ? res.exitCode : 1,
|
|
357
|
+
stdout: typeof res.stdout === "string" ? res.stdout : "",
|
|
358
|
+
stderr: typeof res.stderr === "string" ? res.stderr : ""
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
async function scpUpload(target, localPath, remotePath, opts = {}) {
|
|
362
|
+
const argv = [
|
|
363
|
+
...sshOptArgs(target),
|
|
364
|
+
localPath,
|
|
365
|
+
`${target.user}@${target.host}:${remotePath}`
|
|
366
|
+
];
|
|
367
|
+
const res = await execa2("scp", argv, {
|
|
368
|
+
reject: false,
|
|
369
|
+
timeout: opts.timeoutMs
|
|
370
|
+
});
|
|
371
|
+
if (res.exitCode !== 0) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`scp upload failed (exit ${String(res.exitCode)}): ${localPath} \u2192 ${remotePath}
|
|
374
|
+
${res.stderr ?? ""}`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function scpDownload(target, remotePath, localPath, opts = {}) {
|
|
379
|
+
const argv = [
|
|
380
|
+
...sshOptArgs(target),
|
|
381
|
+
`${target.user}@${target.host}:${remotePath}`,
|
|
382
|
+
localPath
|
|
383
|
+
];
|
|
384
|
+
const res = await execa2("scp", argv, {
|
|
385
|
+
reject: false,
|
|
386
|
+
timeout: opts.timeoutMs
|
|
387
|
+
});
|
|
388
|
+
if (res.exitCode !== 0) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`scp download failed (exit ${String(res.exitCode)}): ${remotePath} \u2192 ${localPath}
|
|
391
|
+
${res.stderr ?? ""}`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async function waitForSsh(target, deadlineMs, intervalMs = 5e3) {
|
|
396
|
+
const stop = Date.now() + deadlineMs;
|
|
397
|
+
while (Date.now() < stop) {
|
|
398
|
+
const res = await sshExec(
|
|
399
|
+
{ ...target, options: { ...target.options, ConnectTimeout: "5" } },
|
|
400
|
+
"true",
|
|
401
|
+
{ timeoutMs: 1e4 }
|
|
402
|
+
);
|
|
403
|
+
if (res.exitCode === 0) return true;
|
|
404
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
405
|
+
}
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
var TEMP_SERVER_TYPE_DEFAULT = "cx23";
|
|
409
|
+
var TEMP_SERVER_LOCATION_DEFAULT = "nbg1";
|
|
410
|
+
var PREPARE_SSH_DEADLINE_MS = 5 * 6e4;
|
|
411
|
+
var INSTALL_SCRIPT_TIMEOUT_MS = 30 * 6e4;
|
|
412
|
+
var SNAPSHOT_DEADLINE_MS = 20 * 6e4;
|
|
413
|
+
async function prepareHetzner(opts = {}) {
|
|
414
|
+
await ensureHetznerCredentials();
|
|
415
|
+
const client2 = makeHetznerClient();
|
|
416
|
+
const log = opts.onLog ?? (() => {
|
|
417
|
+
});
|
|
418
|
+
const progress = (step) => log(`prepare-hetzner: ${step}`);
|
|
419
|
+
const existingState = readPreparedState();
|
|
420
|
+
const assets = resolveRuntimeAssets({
|
|
421
|
+
cliRuntimeRoot: opts.cliRuntimeRoot ?? findStagedCliRuntimeRoot(),
|
|
422
|
+
repoRoot: opts.repoRoot
|
|
423
|
+
});
|
|
424
|
+
const installAsset = assets.find((a) => a.name === "install-box.sh");
|
|
425
|
+
if (!installAsset) {
|
|
426
|
+
throw new Error("prepare: missing install-box.sh asset (this should have been caught earlier)");
|
|
427
|
+
}
|
|
428
|
+
const installSha = await sha256OfFile(installAsset.localPath);
|
|
429
|
+
if (!opts.force && existingState.base) {
|
|
430
|
+
const remote = await client2.getImage(existingState.base.imageId).catch(() => null);
|
|
431
|
+
if (remote && existingState.base.installScriptSha256 === installSha) {
|
|
432
|
+
progress(
|
|
433
|
+
`base snapshot ${String(existingState.base.imageId)} already exists (sha matches); skipping rebuild (pass --force to override)`
|
|
434
|
+
);
|
|
435
|
+
return {
|
|
436
|
+
snapshotName: existingState.base.description,
|
|
437
|
+
imageId: existingState.base.imageId
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (!remote) {
|
|
441
|
+
progress(`recorded base snapshot ${String(existingState.base.imageId)} is gone on Hetzner; rebuilding`);
|
|
442
|
+
} else {
|
|
443
|
+
progress(`install script changed; rebuilding base snapshot`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
progress("minting ephemeral ssh key");
|
|
447
|
+
const key = await mintPrepareKey();
|
|
448
|
+
let firewallId = null;
|
|
449
|
+
let serverId = null;
|
|
450
|
+
try {
|
|
451
|
+
progress("detecting host egress IP");
|
|
452
|
+
const source = opts.firewallSource ? normalizeSourceCidr(opts.firewallSource) : `${await detectEgressIp({ onLog: log })}/32`;
|
|
453
|
+
const stamp = Date.now().toString(36);
|
|
454
|
+
const firewallName = `agentbox-prepare-${stamp}`;
|
|
455
|
+
progress(`creating firewall ${firewallName} (source ${source})`);
|
|
456
|
+
const firewall = await createPerBoxFirewall(client2, {
|
|
457
|
+
name: firewallName,
|
|
458
|
+
sourceCidr: source,
|
|
459
|
+
labels: { "agentbox.role": "prepare" }
|
|
460
|
+
});
|
|
461
|
+
firewallId = firewall.id;
|
|
462
|
+
const serverName = `agentbox-prepare-${stamp}`;
|
|
463
|
+
const cloudInit = generatePrepareCloudInit({ sshPubkey: key.publicKey });
|
|
464
|
+
progress(`creating temp VPS ${serverName} (${opts.serverType ?? TEMP_SERVER_TYPE_DEFAULT} / ${opts.location ?? TEMP_SERVER_LOCATION_DEFAULT})`);
|
|
465
|
+
const created = await client2.createServer({
|
|
466
|
+
name: serverName,
|
|
467
|
+
server_type: opts.serverType ?? TEMP_SERVER_TYPE_DEFAULT,
|
|
468
|
+
image: "ubuntu-24.04",
|
|
469
|
+
location: opts.location ?? TEMP_SERVER_LOCATION_DEFAULT,
|
|
470
|
+
user_data: cloudInit,
|
|
471
|
+
firewalls: [{ firewall: firewall.id }],
|
|
472
|
+
labels: { "agentbox.managed": "true", "agentbox.role": "prepare" },
|
|
473
|
+
start_after_create: true
|
|
474
|
+
});
|
|
475
|
+
serverId = created.server.id;
|
|
476
|
+
const ip = created.server.public_net.ipv4?.ip;
|
|
477
|
+
if (!ip) {
|
|
478
|
+
throw new Error("hetzner: temp VPS came up without an IPv4 address");
|
|
479
|
+
}
|
|
480
|
+
progress(`waiting for ssh on ${ip} (deadline ${String(PREPARE_SSH_DEADLINE_MS / 1e3)}s)`);
|
|
481
|
+
const sshTarget = {
|
|
482
|
+
host: ip,
|
|
483
|
+
user: "root",
|
|
484
|
+
identity: key.privatePath,
|
|
485
|
+
knownHosts: join2(key.dir, "known_hosts")
|
|
486
|
+
};
|
|
487
|
+
const up = await waitForSsh(sshTarget, PREPARE_SSH_DEADLINE_MS);
|
|
488
|
+
if (!up) {
|
|
489
|
+
throw new Error(`hetzner: ssh on ${ip} did not come up within ${String(PREPARE_SSH_DEADLINE_MS / 1e3)}s`);
|
|
490
|
+
}
|
|
491
|
+
progress("ssh up \u2014 scp'ing runtime assets");
|
|
492
|
+
for (const asset of assets) {
|
|
493
|
+
const remote = `/tmp/${asset.remoteBasename}`;
|
|
494
|
+
log(`prepare-hetzner: scp ${asset.name} -> ${remote}`);
|
|
495
|
+
await scpUpload(sshTarget, asset.localPath, remote);
|
|
496
|
+
if (asset.remoteMode !== void 0) {
|
|
497
|
+
const modeOctal = asset.remoteMode.toString(8);
|
|
498
|
+
await sshExec(sshTarget, `chmod ${modeOctal} ${remote}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
progress("running install-box.sh on temp VPS (this takes ~5-15 min)");
|
|
502
|
+
const installRes = await sshExec(
|
|
503
|
+
sshTarget,
|
|
504
|
+
`sudo mkdir -p /var/log/agentbox && set -o pipefail && bash -x /tmp/agentbox-install.sh 2>&1 | sudo tee /var/log/agentbox/install.log`,
|
|
505
|
+
{
|
|
506
|
+
timeoutMs: INSTALL_SCRIPT_TIMEOUT_MS,
|
|
507
|
+
onLine: (line) => log(`[install] ${line}`)
|
|
508
|
+
}
|
|
509
|
+
);
|
|
510
|
+
if (installRes.exitCode !== 0) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`install-box.sh failed on temp VPS (exit ${String(installRes.exitCode)})
|
|
513
|
+
Last stderr: ${installRes.stderr.slice(-500) || "(empty)"}
|
|
514
|
+
The full trace was preserved at /var/log/agentbox/install.log inside any box made from the resulting snapshot.`
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
progress("install script complete");
|
|
518
|
+
progress("staging host agent static config");
|
|
519
|
+
const stagings = [];
|
|
520
|
+
try {
|
|
521
|
+
const claudeTar = await stageClaudeStaticForUpload({ hostWorkspace: opts.hostWorkspace });
|
|
522
|
+
for (const w of claudeTar.warnings) log(`prepare-hetzner: ${w}`);
|
|
523
|
+
if (claudeTar.tarballPath) stagings.push({ kind: "claude", tar: claudeTar, dest: "/home/vscode/.claude" });
|
|
524
|
+
else await claudeTar.cleanup();
|
|
525
|
+
const codexTar = await stageCodexStaticForUpload();
|
|
526
|
+
for (const w of codexTar.warnings) log(`prepare-hetzner: ${w}`);
|
|
527
|
+
if (codexTar.tarballPath) stagings.push({ kind: "codex", tar: codexTar, dest: "/home/vscode/.codex" });
|
|
528
|
+
else await codexTar.cleanup();
|
|
529
|
+
const opencodeTar = await stageOpencodeStaticForUpload();
|
|
530
|
+
for (const w of opencodeTar.warnings) log(`prepare-hetzner: ${w}`);
|
|
531
|
+
if (opencodeTar.tarballPath) stagings.push({ kind: "opencode", tar: opencodeTar, dest: "/home/vscode/.local/share/opencode" });
|
|
532
|
+
else await opencodeTar.cleanup();
|
|
533
|
+
for (const s of stagings) {
|
|
534
|
+
const remote = `/tmp/agentbox-${s.kind}-static.tar.gz`;
|
|
535
|
+
log(`prepare-hetzner: scp ${s.kind} static (${s.tar.tarballPath}) -> ${remote}`);
|
|
536
|
+
await scpUpload(sshTarget, s.tar.tarballPath, remote);
|
|
537
|
+
const extractCmd = `sudo -u vscode mkdir -p ${s.dest} && sudo -u vscode tar -xzf ${remote} -C ${s.dest} --no-same-permissions --no-same-owner -m && rm -f ${remote}`;
|
|
538
|
+
const r = await sshExec(sshTarget, extractCmd, { onLine: (line) => log(`[stage:${s.kind}] ${line}`) });
|
|
539
|
+
if (r.exitCode !== 0) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
`prepare-hetzner: ${s.kind} static extract failed (exit ${String(r.exitCode)}): ${r.stderr.slice(-300)}`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
progress(`baked ${s.kind} static config into snapshot`);
|
|
545
|
+
}
|
|
546
|
+
} finally {
|
|
547
|
+
for (const s of stagings) await s.tar.cleanup();
|
|
548
|
+
}
|
|
549
|
+
const description = opts.name ?? `agentbox-base-${stamp}`;
|
|
550
|
+
progress(`creating snapshot '${description}' from VPS ${String(serverId)}`);
|
|
551
|
+
const snap = await client2.createImage(serverId, {
|
|
552
|
+
type: "snapshot",
|
|
553
|
+
description,
|
|
554
|
+
labels: { "agentbox.role": "base", "agentbox.schema": "1" }
|
|
555
|
+
});
|
|
556
|
+
progress(`snapshot create requested (image id ${String(snap.image.id)}); polling until available`);
|
|
557
|
+
const ready = await pollUntil(
|
|
558
|
+
`image ${String(snap.image.id)} availability`,
|
|
559
|
+
async () => {
|
|
560
|
+
const img = await client2.getImage(snap.image.id);
|
|
561
|
+
if (!img) return null;
|
|
562
|
+
if (img.status === "available") return img;
|
|
563
|
+
return null;
|
|
564
|
+
},
|
|
565
|
+
{ deadlineMs: SNAPSHOT_DEADLINE_MS, intervalMs: 3e3, maxIntervalMs: 1e4, onPoll: (l) => log(`prepare-hetzner: ${l}`) }
|
|
566
|
+
);
|
|
567
|
+
progress("persisting hetzner-prepared.json");
|
|
568
|
+
const state = readPreparedState();
|
|
569
|
+
state.base = {
|
|
570
|
+
imageId: ready.id,
|
|
571
|
+
description: ready.description,
|
|
572
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
573
|
+
installScriptSha256: installSha
|
|
574
|
+
};
|
|
575
|
+
writePreparedState(state);
|
|
576
|
+
log(`prepare-hetzner: wrote ${preparedStatePath()}`);
|
|
577
|
+
progress(`deleting temp VPS ${String(serverId)}`);
|
|
578
|
+
await client2.deleteServer(serverId);
|
|
579
|
+
serverId = null;
|
|
580
|
+
progress(`deleting per-prepare firewall ${String(firewallId)}`);
|
|
581
|
+
await deletePerBoxFirewall(client2, firewallId);
|
|
582
|
+
firewallId = null;
|
|
583
|
+
progress(`prepare complete \u2014 base snapshot ${String(ready.id)} (${ready.description})`);
|
|
584
|
+
return { snapshotName: ready.description, imageId: ready.id };
|
|
585
|
+
} catch (err) {
|
|
586
|
+
if (serverId !== null) {
|
|
587
|
+
log(`prepare-hetzner: cleanup \u2014 deleting temp VPS ${String(serverId)} after failure`);
|
|
588
|
+
try {
|
|
589
|
+
await client2.deleteServer(serverId);
|
|
590
|
+
} catch (cleanupErr) {
|
|
591
|
+
log(
|
|
592
|
+
`prepare-hetzner: WARN \u2014 failed to delete temp VPS ${String(serverId)}; check the Hetzner dashboard manually. ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (firewallId !== null) {
|
|
597
|
+
log(`prepare-hetzner: cleanup \u2014 deleting per-prepare firewall ${String(firewallId)} after failure`);
|
|
598
|
+
try {
|
|
599
|
+
await deletePerBoxFirewall(client2, firewallId);
|
|
600
|
+
} catch (cleanupErr) {
|
|
601
|
+
log(
|
|
602
|
+
`prepare-hetzner: WARN \u2014 failed to delete firewall ${String(firewallId)}; check the Hetzner dashboard manually. ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
throw err;
|
|
607
|
+
} finally {
|
|
608
|
+
await key.cleanup();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async function sha256OfFile(path) {
|
|
612
|
+
const buf = await readFile2(path);
|
|
613
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
614
|
+
}
|
|
615
|
+
var prepareHetznerProvider = (req) => prepareHetzner({
|
|
616
|
+
name: req.name,
|
|
617
|
+
hostWorkspace: req.hostWorkspace ?? process.cwd(),
|
|
618
|
+
force: req.force,
|
|
619
|
+
onLog: req.onLog
|
|
620
|
+
});
|
|
621
|
+
async function ensureHetznerBaseSnapshot() {
|
|
622
|
+
const state = readPreparedState();
|
|
623
|
+
if (state.base !== void 0) return;
|
|
624
|
+
throw new Error(
|
|
625
|
+
"no Hetzner base snapshot found.\nRun `agentbox prepare --provider hetzner` first (Hetzner cannot build images from a Dockerfile,\nso the base snapshot is a one-time prerequisite for cloud boxes on this backend)."
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
var HOST = "127.0.0.1";
|
|
629
|
+
var EPHEMERAL_MIN = 49152;
|
|
630
|
+
var EPHEMERAL_MAX = 65535;
|
|
631
|
+
var SshTunnelManager = class {
|
|
632
|
+
boxes = /* @__PURE__ */ new Map();
|
|
633
|
+
/**
|
|
634
|
+
* Open the ControlMaster for `boxId`. Idempotent: if a master is already
|
|
635
|
+
* up for this box (socket exists + responsive), no-op. Otherwise spawn a
|
|
636
|
+
* fresh `ssh -fNT -M` and wait for the socket to appear.
|
|
637
|
+
*/
|
|
638
|
+
async open(opts) {
|
|
639
|
+
const boxSshDir = opts.boxSshDir ?? defaultBoxSshDir(opts.boxId);
|
|
640
|
+
await mkdir2(boxSshDir, { recursive: true, mode: 448 });
|
|
641
|
+
const controlPath = join3(boxSshDir, "control.sock");
|
|
642
|
+
const knownHosts = join3(boxSshDir, "known_hosts");
|
|
643
|
+
const user = opts.vpsUser ?? "vscode";
|
|
644
|
+
const tunnel = {
|
|
645
|
+
controlPath,
|
|
646
|
+
vpsHost: opts.vpsHost,
|
|
647
|
+
vpsUser: user,
|
|
648
|
+
identity: opts.identity,
|
|
649
|
+
boxSshDir,
|
|
650
|
+
forwards: /* @__PURE__ */ new Map()
|
|
651
|
+
};
|
|
652
|
+
if (existsSync3(controlPath) && await this.isAlive(controlPath)) {
|
|
653
|
+
this.boxes.set(opts.boxId, tunnel);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
if (existsSync3(controlPath)) {
|
|
657
|
+
await rm(controlPath, { force: true });
|
|
658
|
+
}
|
|
659
|
+
const connectTimeout = opts.connectTimeoutSeconds ?? 10;
|
|
660
|
+
const argv = [
|
|
661
|
+
"-fNT",
|
|
662
|
+
"-M",
|
|
663
|
+
"-S",
|
|
664
|
+
controlPath,
|
|
665
|
+
"-i",
|
|
666
|
+
opts.identity,
|
|
667
|
+
"-o",
|
|
668
|
+
"StrictHostKeyChecking=accept-new",
|
|
669
|
+
"-o",
|
|
670
|
+
`UserKnownHostsFile=${knownHosts}`,
|
|
671
|
+
"-o",
|
|
672
|
+
"GlobalKnownHostsFile=/dev/null",
|
|
673
|
+
"-o",
|
|
674
|
+
"BatchMode=yes",
|
|
675
|
+
"-o",
|
|
676
|
+
"LogLevel=ERROR",
|
|
677
|
+
"-o",
|
|
678
|
+
"ExitOnForwardFailure=yes",
|
|
679
|
+
"-o",
|
|
680
|
+
"ServerAliveInterval=30",
|
|
681
|
+
"-o",
|
|
682
|
+
"ServerAliveCountMax=3",
|
|
683
|
+
"-o",
|
|
684
|
+
`ConnectTimeout=${String(connectTimeout)}`,
|
|
685
|
+
`${user}@${opts.vpsHost}`
|
|
686
|
+
];
|
|
687
|
+
const res = await execa3("ssh", argv, { reject: false });
|
|
688
|
+
if (res.exitCode !== 0 || !existsSync3(controlPath)) {
|
|
689
|
+
throw new Error(
|
|
690
|
+
`ssh ControlMaster failed for ${opts.boxId} (exit ${String(res.exitCode)}): ${res.stderr || res.stdout || "(no output)"}`
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
this.boxes.set(opts.boxId, tunnel);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Mint (or fetch the cached) `127.0.0.1:<localPort> → vps:127.0.0.1:<remotePort>`
|
|
697
|
+
* forward. Returns the local port. Idempotent per (boxId, remotePort).
|
|
698
|
+
*/
|
|
699
|
+
async forward(boxId, remotePort) {
|
|
700
|
+
const tunnel = this.getTunnelOrThrow(boxId);
|
|
701
|
+
const cached = tunnel.forwards.get(remotePort);
|
|
702
|
+
if (cached !== void 0) return cached;
|
|
703
|
+
const localPort = await pickFreePort();
|
|
704
|
+
const argv = [
|
|
705
|
+
"-O",
|
|
706
|
+
"forward",
|
|
707
|
+
"-L",
|
|
708
|
+
`${HOST}:${String(localPort)}:${HOST}:${String(remotePort)}`,
|
|
709
|
+
"-S",
|
|
710
|
+
tunnel.controlPath,
|
|
711
|
+
"dummy"
|
|
712
|
+
// the target host is ignored when -O is used, but argv needs one
|
|
713
|
+
];
|
|
714
|
+
const res = await execa3("ssh", argv, { reject: false });
|
|
715
|
+
if (res.exitCode !== 0) {
|
|
716
|
+
throw new Error(
|
|
717
|
+
`ssh -O forward failed for ${boxId} (exit ${String(res.exitCode)}): ${res.stderr || res.stdout || "(no output)"}`
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
tunnel.forwards.set(remotePort, localPort);
|
|
721
|
+
return localPort;
|
|
722
|
+
}
|
|
723
|
+
/** Tear down a single forward. Idempotent — unknown ports are no-ops. */
|
|
724
|
+
async unforward(boxId, remotePort) {
|
|
725
|
+
const tunnel = this.getTunnelOrThrow(boxId);
|
|
726
|
+
const localPort = tunnel.forwards.get(remotePort);
|
|
727
|
+
if (localPort === void 0) return;
|
|
728
|
+
const argv = [
|
|
729
|
+
"-O",
|
|
730
|
+
"cancel",
|
|
731
|
+
"-L",
|
|
732
|
+
`${HOST}:${String(localPort)}:${HOST}:${String(remotePort)}`,
|
|
733
|
+
"-S",
|
|
734
|
+
tunnel.controlPath,
|
|
735
|
+
"dummy"
|
|
736
|
+
];
|
|
737
|
+
await execa3("ssh", argv, { reject: false });
|
|
738
|
+
tunnel.forwards.delete(remotePort);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Close the ControlMaster (and all its forwards). Idempotent — if no
|
|
742
|
+
* master is recorded, no-op. Removes the socket file.
|
|
743
|
+
*/
|
|
744
|
+
async close(boxId) {
|
|
745
|
+
const tunnel = this.boxes.get(boxId);
|
|
746
|
+
if (!tunnel) return;
|
|
747
|
+
if (existsSync3(tunnel.controlPath)) {
|
|
748
|
+
await execa3("ssh", ["-O", "exit", "-S", tunnel.controlPath, "dummy"], { reject: false });
|
|
749
|
+
await rm(tunnel.controlPath, { force: true });
|
|
750
|
+
}
|
|
751
|
+
this.boxes.delete(boxId);
|
|
752
|
+
}
|
|
753
|
+
/** Tear down every open box. */
|
|
754
|
+
async closeAll() {
|
|
755
|
+
const ids = Array.from(this.boxes.keys());
|
|
756
|
+
await Promise.all(ids.map((id) => this.close(id)));
|
|
757
|
+
}
|
|
758
|
+
/** Path to the ControlMaster socket for `boxId`, if open. */
|
|
759
|
+
controlPath(boxId) {
|
|
760
|
+
return this.boxes.get(boxId)?.controlPath;
|
|
761
|
+
}
|
|
762
|
+
/** True if a ControlMaster is registered for `boxId` (regardless of liveness). */
|
|
763
|
+
has(boxId) {
|
|
764
|
+
return this.boxes.has(boxId);
|
|
765
|
+
}
|
|
766
|
+
/** Per-box ssh dir (tests use this to verify the layout). */
|
|
767
|
+
boxSshDir(boxId) {
|
|
768
|
+
return this.boxes.get(boxId)?.boxSshDir;
|
|
769
|
+
}
|
|
770
|
+
/** Re-open the manager record for an existing-on-disk control socket. */
|
|
771
|
+
registerExisting(boxId, opts) {
|
|
772
|
+
const boxSshDir = opts.boxSshDir ?? defaultBoxSshDir(opts.boxId);
|
|
773
|
+
this.boxes.set(boxId, {
|
|
774
|
+
controlPath: join3(boxSshDir, "control.sock"),
|
|
775
|
+
vpsHost: opts.vpsHost,
|
|
776
|
+
vpsUser: opts.vpsUser ?? "vscode",
|
|
777
|
+
identity: opts.identity,
|
|
778
|
+
boxSshDir,
|
|
779
|
+
forwards: /* @__PURE__ */ new Map()
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
async isAlive(controlPath) {
|
|
783
|
+
const res = await execa3("ssh", ["-O", "check", "-S", controlPath, "dummy"], { reject: false });
|
|
784
|
+
return res.exitCode === 0;
|
|
785
|
+
}
|
|
786
|
+
getTunnelOrThrow(boxId) {
|
|
787
|
+
const t = this.boxes.get(boxId);
|
|
788
|
+
if (!t) throw new Error(`no SSH ControlMaster registered for box ${boxId}; call open() first`);
|
|
789
|
+
return t;
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
function defaultBoxSshDir(boxId) {
|
|
793
|
+
return resolve4(homedir2(), ".agentbox", "boxes", boxId, "ssh");
|
|
794
|
+
}
|
|
795
|
+
async function pickFreePort() {
|
|
796
|
+
return new Promise((resolveOk, reject) => {
|
|
797
|
+
const srv = createServer();
|
|
798
|
+
srv.unref();
|
|
799
|
+
srv.on("error", reject);
|
|
800
|
+
srv.listen(0, HOST, () => {
|
|
801
|
+
const addr = srv.address();
|
|
802
|
+
if (!addr || typeof addr === "string") {
|
|
803
|
+
srv.close();
|
|
804
|
+
reject(new Error("could not get a free local port"));
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const port = addr.port;
|
|
808
|
+
srv.close(() => {
|
|
809
|
+
if (port < EPHEMERAL_MIN || port > EPHEMERAL_MAX) {
|
|
810
|
+
resolveOk(port);
|
|
811
|
+
} else {
|
|
812
|
+
resolveOk(port);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
var HETZNER_DEFAULT_BOX_IMAGE_REF = "agentbox-base";
|
|
819
|
+
var SCAFFOLDING_FALLBACK_IMAGE = "agentbox/box:dev";
|
|
820
|
+
var VPS_USER = "vscode";
|
|
821
|
+
var PROVISION_SSH_DEADLINE_MS = 5 * 6e4;
|
|
822
|
+
var ACTION_DEADLINE_MS = 5 * 6e4;
|
|
823
|
+
var SNAPSHOT_DEADLINE_MS2 = 20 * 6e4;
|
|
824
|
+
var HETZNER_DEFAULT_SERVER_TYPE = "cx23";
|
|
825
|
+
var HETZNER_DEFAULT_LOCATION = "nbg1";
|
|
826
|
+
var tunnels = new SshTunnelManager();
|
|
827
|
+
function mapState(s) {
|
|
828
|
+
switch (s) {
|
|
829
|
+
case "running":
|
|
830
|
+
return "running";
|
|
831
|
+
case "starting":
|
|
832
|
+
case "initializing":
|
|
833
|
+
case "stopping":
|
|
834
|
+
case "migrating":
|
|
835
|
+
case "rebuilding":
|
|
836
|
+
return "running";
|
|
837
|
+
case "off":
|
|
838
|
+
return "paused";
|
|
839
|
+
case "deleting":
|
|
840
|
+
case "unknown":
|
|
841
|
+
default:
|
|
842
|
+
return "missing";
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function client() {
|
|
846
|
+
return makeHetznerClient();
|
|
847
|
+
}
|
|
848
|
+
async function getServerStrict(id) {
|
|
849
|
+
const s = await client().getServer(id);
|
|
850
|
+
if (!s) {
|
|
851
|
+
throw new Error(`hetzner: server ${String(id)} not found (already destroyed?)`);
|
|
852
|
+
}
|
|
853
|
+
return s;
|
|
854
|
+
}
|
|
855
|
+
async function findImageByDescription(c, description) {
|
|
856
|
+
const all = await c.listImages({ type: "snapshot" });
|
|
857
|
+
return all.find((i) => i.description === description) ?? null;
|
|
858
|
+
}
|
|
859
|
+
async function resolveImageId(c, req) {
|
|
860
|
+
const ref = req.snapshot ?? req.image;
|
|
861
|
+
if (!ref || ref === HETZNER_DEFAULT_BOX_IMAGE_REF || ref === SCAFFOLDING_FALLBACK_IMAGE) {
|
|
862
|
+
await ensureHetznerBaseSnapshot();
|
|
863
|
+
const state = readPreparedState();
|
|
864
|
+
if (!state.base) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
"no Hetzner base snapshot found \u2014 run `agentbox prepare --provider hetzner` to bake one."
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
return state.base.imageId;
|
|
870
|
+
}
|
|
871
|
+
if (/^\d+$/.test(ref)) {
|
|
872
|
+
return Number.parseInt(ref, 10);
|
|
873
|
+
}
|
|
874
|
+
const snap = await findImageByDescription(c, ref);
|
|
875
|
+
if (snap) return snap.id;
|
|
876
|
+
return ref;
|
|
877
|
+
}
|
|
878
|
+
function perBoxDir(sandboxId) {
|
|
879
|
+
return resolvePath(defaultBoxSshDir(sandboxId), "..");
|
|
880
|
+
}
|
|
881
|
+
async function ensurePerBoxState(sandboxId) {
|
|
882
|
+
const dir = perBoxDir(sandboxId);
|
|
883
|
+
const sshDir = join4(dir, "ssh");
|
|
884
|
+
await mkdir3(sshDir, { recursive: true, mode: 448 });
|
|
885
|
+
return {
|
|
886
|
+
dir,
|
|
887
|
+
identity: join4(sshDir, "id_ed25519"),
|
|
888
|
+
knownHosts: join4(sshDir, "known_hosts")
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function bashScript(s) {
|
|
892
|
+
return `bash -lc ${shellQuote(s)}`;
|
|
893
|
+
}
|
|
894
|
+
function shellQuote(s) {
|
|
895
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
896
|
+
}
|
|
897
|
+
function buildSshTarget(state, vpsIp, controlPath) {
|
|
898
|
+
return {
|
|
899
|
+
host: vpsIp,
|
|
900
|
+
user: VPS_USER,
|
|
901
|
+
identity: state.identity,
|
|
902
|
+
knownHosts: state.knownHosts,
|
|
903
|
+
controlPath
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
async function ensureTunnel(sandboxId, state, vpsIp) {
|
|
907
|
+
if (tunnels.has(sandboxId)) return;
|
|
908
|
+
await tunnels.open({
|
|
909
|
+
boxId: sandboxId,
|
|
910
|
+
vpsHost: vpsIp,
|
|
911
|
+
identity: state.identity
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
async function ensureLiveTarget(sandboxId) {
|
|
915
|
+
const id = Number.parseInt(sandboxId, 10);
|
|
916
|
+
if (!Number.isFinite(id)) {
|
|
917
|
+
throw new Error(`hetzner: invalid sandboxId ${sandboxId}`);
|
|
918
|
+
}
|
|
919
|
+
const server = await getServerStrict(id);
|
|
920
|
+
const vpsIp = server.public_net.ipv4?.ip;
|
|
921
|
+
if (!vpsIp) {
|
|
922
|
+
throw new Error(`hetzner: server ${String(id)} has no IPv4 address`);
|
|
923
|
+
}
|
|
924
|
+
const state = await ensurePerBoxState(sandboxId);
|
|
925
|
+
if (!existsSync4(state.identity)) {
|
|
926
|
+
throw new Error(
|
|
927
|
+
`hetzner: per-box SSH key missing for sandbox ${sandboxId} (expected at ${state.identity}). If this box was created by a different host, you'll need to re-create it on this host.`
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
await ensureTunnel(sandboxId, state, vpsIp);
|
|
931
|
+
const controlPath = tunnels.controlPath(sandboxId);
|
|
932
|
+
return { target: buildSshTarget(state, vpsIp, controlPath), state, vpsIp };
|
|
933
|
+
}
|
|
934
|
+
var hetznerBackend = {
|
|
935
|
+
name: "hetzner",
|
|
936
|
+
async provision(req) {
|
|
937
|
+
const c = client();
|
|
938
|
+
const onLog = req.onLog ?? (() => {
|
|
939
|
+
});
|
|
940
|
+
const progress = (s) => onLog(`hetzner: ${s}`);
|
|
941
|
+
await ensureHetznerBaseSnapshot();
|
|
942
|
+
const imageRef = await resolveImageId(c, req);
|
|
943
|
+
const egressOverride = req.env?.AGENTBOX_HETZNER_FIREWALL_SOURCE ?? process.env.AGENTBOX_HETZNER_FIREWALL_SOURCE;
|
|
944
|
+
const source = egressOverride ? normalizeSourceCidr(egressOverride) : `${await detectEgressIp({ onLog })}/32`;
|
|
945
|
+
progress(`firewall source: ${source}`);
|
|
946
|
+
const stamp = Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
|
|
947
|
+
const tempDir = resolvePath(
|
|
948
|
+
process.env.HOME ?? process.cwd(),
|
|
949
|
+
".agentbox",
|
|
950
|
+
"hetzner",
|
|
951
|
+
`pending-${stamp}`,
|
|
952
|
+
"ssh"
|
|
953
|
+
);
|
|
954
|
+
const key = await mintSshKey(tempDir, `agentbox-box-${req.name}-${stamp}`);
|
|
955
|
+
let firewallId = null;
|
|
956
|
+
let serverId = null;
|
|
957
|
+
try {
|
|
958
|
+
const firewall = await createPerBoxFirewall(c, {
|
|
959
|
+
name: `agentbox-${req.name}-${stamp}`,
|
|
960
|
+
sourceCidr: source,
|
|
961
|
+
labels: {
|
|
962
|
+
"agentbox.box": req.name,
|
|
963
|
+
"agentbox.role": "box"
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
firewallId = firewall.id;
|
|
967
|
+
const boxEnv = {};
|
|
968
|
+
for (const [k, v] of Object.entries(req.env ?? {})) {
|
|
969
|
+
if (k.startsWith("AGENTBOX_")) boxEnv[k] = v;
|
|
970
|
+
}
|
|
971
|
+
const cloudInit = generateBoxCloudInit({
|
|
972
|
+
sshPubkey: key.publicKey,
|
|
973
|
+
boxName: req.name,
|
|
974
|
+
boxEnv: Object.keys(boxEnv).length > 0 ? boxEnv : void 0
|
|
975
|
+
});
|
|
976
|
+
progress(`creating VPS '${req.name}' from image ${String(imageRef)} (cx22 / nbg1 unless overridden)`);
|
|
977
|
+
const created = await withHetznerRetry(
|
|
978
|
+
{ method: "createServer", retryOnAmbiguous: false, attemptTimeoutMs: 12e4 },
|
|
979
|
+
() => c.createServer({
|
|
980
|
+
name: `agentbox-${req.name}-${stamp}`,
|
|
981
|
+
server_type: HETZNER_DEFAULT_SERVER_TYPE,
|
|
982
|
+
image: imageRef,
|
|
983
|
+
location: HETZNER_DEFAULT_LOCATION,
|
|
984
|
+
user_data: cloudInit,
|
|
985
|
+
firewalls: [{ firewall: firewall.id }],
|
|
986
|
+
labels: {
|
|
987
|
+
"agentbox.managed": "true",
|
|
988
|
+
"agentbox.role": "box",
|
|
989
|
+
"agentbox.box": req.name,
|
|
990
|
+
"agentbox.firewall": String(firewall.id)
|
|
991
|
+
},
|
|
992
|
+
start_after_create: true
|
|
993
|
+
})
|
|
994
|
+
);
|
|
995
|
+
serverId = created.server.id;
|
|
996
|
+
const vpsIp = created.server.public_net.ipv4?.ip;
|
|
997
|
+
if (!vpsIp) {
|
|
998
|
+
throw new Error(`hetzner: server ${String(serverId)} came up without an IPv4 address`);
|
|
999
|
+
}
|
|
1000
|
+
progress(`server ${String(serverId)} provisioned at ${vpsIp}; waiting for ssh`);
|
|
1001
|
+
const sandboxId = String(serverId);
|
|
1002
|
+
const state = await ensurePerBoxState(sandboxId);
|
|
1003
|
+
await rename(key.privatePath, state.identity);
|
|
1004
|
+
await rename(key.publicPath, `${state.identity}.pub`);
|
|
1005
|
+
await rm2(key.dir, { recursive: true, force: true });
|
|
1006
|
+
const pendingParent = resolvePath(key.dir, "..");
|
|
1007
|
+
await rm2(pendingParent, { recursive: true, force: true });
|
|
1008
|
+
const up = await waitForSsh(buildSshTarget(state, vpsIp), PROVISION_SSH_DEADLINE_MS);
|
|
1009
|
+
if (!up) {
|
|
1010
|
+
throw new Error(`hetzner: ssh on ${vpsIp} did not come up within ${String(PROVISION_SSH_DEADLINE_MS / 1e3)}s`);
|
|
1011
|
+
}
|
|
1012
|
+
await ensureTunnel(sandboxId, state, vpsIp);
|
|
1013
|
+
progress("ssh up; ControlMaster open");
|
|
1014
|
+
const liveTarget = buildSshTarget(state, vpsIp, tunnels.controlPath(sandboxId));
|
|
1015
|
+
try {
|
|
1016
|
+
await pushHetznerAgentCredentials(liveTarget, onLog);
|
|
1017
|
+
} catch (credErr) {
|
|
1018
|
+
onLog(
|
|
1019
|
+
`hetzner: WARN \u2014 agent credential push failed (${credErr instanceof Error ? credErr.message : String(credErr)}); in-box claude/codex/opencode will prompt for interactive login`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
return { sandboxId };
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
if (serverId !== null) {
|
|
1025
|
+
progress(`cleanup \u2014 deleting server ${String(serverId)} after provision failure`);
|
|
1026
|
+
try {
|
|
1027
|
+
await c.deleteServer(serverId);
|
|
1028
|
+
} catch (cleanupErr) {
|
|
1029
|
+
onLog(
|
|
1030
|
+
`hetzner: WARN \u2014 failed to delete server ${String(serverId)}; check the Hetzner dashboard manually. ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}`
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (firewallId !== null) {
|
|
1035
|
+
try {
|
|
1036
|
+
await deletePerBoxFirewall(c, firewallId);
|
|
1037
|
+
} catch {
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
try {
|
|
1041
|
+
if (existsSync4(key.dir)) await rm2(key.dir, { recursive: true, force: true });
|
|
1042
|
+
if (serverId !== null) {
|
|
1043
|
+
const finalDir = perBoxDir(String(serverId));
|
|
1044
|
+
if (existsSync4(finalDir)) await rm2(finalDir, { recursive: true, force: true });
|
|
1045
|
+
}
|
|
1046
|
+
} catch {
|
|
1047
|
+
}
|
|
1048
|
+
throw err;
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
async get(sandboxId) {
|
|
1052
|
+
const id = Number.parseInt(sandboxId, 10);
|
|
1053
|
+
if (!Number.isFinite(id)) return null;
|
|
1054
|
+
const server = await client().getServer(id);
|
|
1055
|
+
return server ? { sandboxId } : null;
|
|
1056
|
+
},
|
|
1057
|
+
async list() {
|
|
1058
|
+
const servers = await client().listServers({ label_selector: "agentbox.managed=true" });
|
|
1059
|
+
return servers.map((s) => ({
|
|
1060
|
+
sandboxId: String(s.id),
|
|
1061
|
+
name: s.labels["agentbox.box"] ?? s.name,
|
|
1062
|
+
createdAt: s.created,
|
|
1063
|
+
state: mapState(s.status)
|
|
1064
|
+
}));
|
|
1065
|
+
},
|
|
1066
|
+
async start(h) {
|
|
1067
|
+
const id = Number.parseInt(h.sandboxId, 10);
|
|
1068
|
+
await client().powerOn(id);
|
|
1069
|
+
await pollUntil(
|
|
1070
|
+
`server ${h.sandboxId} running`,
|
|
1071
|
+
async () => {
|
|
1072
|
+
const s = await client().getServer(id);
|
|
1073
|
+
return s?.status === "running" ? s : null;
|
|
1074
|
+
},
|
|
1075
|
+
{ deadlineMs: ACTION_DEADLINE_MS, intervalMs: 2e3, maxIntervalMs: 8e3 }
|
|
1076
|
+
);
|
|
1077
|
+
},
|
|
1078
|
+
async stop(h) {
|
|
1079
|
+
const id = Number.parseInt(h.sandboxId, 10);
|
|
1080
|
+
try {
|
|
1081
|
+
await client().shutdown(id);
|
|
1082
|
+
await pollUntil(
|
|
1083
|
+
`server ${h.sandboxId} off`,
|
|
1084
|
+
async () => {
|
|
1085
|
+
const s = await client().getServer(id);
|
|
1086
|
+
return s?.status === "off" ? s : null;
|
|
1087
|
+
},
|
|
1088
|
+
{ deadlineMs: 6e4, intervalMs: 2e3, maxIntervalMs: 8e3 }
|
|
1089
|
+
);
|
|
1090
|
+
} catch {
|
|
1091
|
+
await client().powerOff(id);
|
|
1092
|
+
}
|
|
1093
|
+
await tunnels.close(h.sandboxId);
|
|
1094
|
+
},
|
|
1095
|
+
async pause(h) {
|
|
1096
|
+
await this.stop(h);
|
|
1097
|
+
},
|
|
1098
|
+
async resume(h) {
|
|
1099
|
+
await this.start(h);
|
|
1100
|
+
},
|
|
1101
|
+
async destroy(h) {
|
|
1102
|
+
const id = Number.parseInt(h.sandboxId, 10);
|
|
1103
|
+
await tunnels.close(h.sandboxId);
|
|
1104
|
+
const c = client();
|
|
1105
|
+
let firewallId;
|
|
1106
|
+
try {
|
|
1107
|
+
const server = await c.getServer(id);
|
|
1108
|
+
firewallId = server ? Number.parseInt(server.labels["agentbox.firewall"] ?? "", 10) : void 0;
|
|
1109
|
+
} catch {
|
|
1110
|
+
}
|
|
1111
|
+
try {
|
|
1112
|
+
await c.deleteServer(id);
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
if (!(err instanceof HetznerApiError && (err.statusCode === 404 || err.code === "not_found"))) {
|
|
1115
|
+
throw err;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (firewallId && Number.isFinite(firewallId)) {
|
|
1119
|
+
await deletePerBoxFirewall(c, firewallId);
|
|
1120
|
+
}
|
|
1121
|
+
const dir = perBoxDir(h.sandboxId);
|
|
1122
|
+
try {
|
|
1123
|
+
await rm2(dir, { recursive: true, force: true });
|
|
1124
|
+
} catch {
|
|
1125
|
+
}
|
|
1126
|
+
},
|
|
1127
|
+
async state(h) {
|
|
1128
|
+
const id = Number.parseInt(h.sandboxId, 10);
|
|
1129
|
+
const s = await client().getServer(id);
|
|
1130
|
+
return s ? mapState(s.status) : "missing";
|
|
1131
|
+
},
|
|
1132
|
+
async exec(h, cmd, opts) {
|
|
1133
|
+
const { target } = await ensureLiveTarget(h.sandboxId);
|
|
1134
|
+
const argv = [
|
|
1135
|
+
...sshOptArgs(target),
|
|
1136
|
+
`${target.user}@${target.host}`,
|
|
1137
|
+
bashScript(opts?.cwd ? `cd ${shellQuote(opts.cwd)} && ${cmd}` : cmd)
|
|
1138
|
+
];
|
|
1139
|
+
const res = await execa4("ssh", argv, {
|
|
1140
|
+
reject: false,
|
|
1141
|
+
timeout: opts?.attemptTimeoutMs ?? 12e4,
|
|
1142
|
+
env: { ...process.env, ...opts?.env ?? {} }
|
|
1143
|
+
});
|
|
1144
|
+
return {
|
|
1145
|
+
exitCode: typeof res.exitCode === "number" ? res.exitCode : 1,
|
|
1146
|
+
stdout: typeof res.stdout === "string" ? res.stdout : "",
|
|
1147
|
+
stderr: typeof res.stderr === "string" ? res.stderr : ""
|
|
1148
|
+
};
|
|
1149
|
+
},
|
|
1150
|
+
async uploadFile(h, localPath, remotePath) {
|
|
1151
|
+
const { target } = await ensureLiveTarget(h.sandboxId);
|
|
1152
|
+
const argv = [
|
|
1153
|
+
...sshOptArgs(target),
|
|
1154
|
+
localPath,
|
|
1155
|
+
`${target.user}@${target.host}:${remotePath}`
|
|
1156
|
+
];
|
|
1157
|
+
const res = await execa4("scp", argv, { reject: false, timeout: 3e5 });
|
|
1158
|
+
if (res.exitCode !== 0) {
|
|
1159
|
+
throw new Error(`hetzner: scp upload failed (exit ${String(res.exitCode)}): ${res.stderr || ""}`);
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
async downloadFile(h, remotePath, localPath) {
|
|
1163
|
+
const { target } = await ensureLiveTarget(h.sandboxId);
|
|
1164
|
+
const argv = [
|
|
1165
|
+
...sshOptArgs(target),
|
|
1166
|
+
`${target.user}@${target.host}:${remotePath}`,
|
|
1167
|
+
localPath
|
|
1168
|
+
];
|
|
1169
|
+
const res = await execa4("scp", argv, { reject: false, timeout: 3e5 });
|
|
1170
|
+
if (res.exitCode !== 0) {
|
|
1171
|
+
throw new Error(`hetzner: scp download failed (exit ${String(res.exitCode)}): ${res.stderr || ""}`);
|
|
1172
|
+
}
|
|
1173
|
+
},
|
|
1174
|
+
async listFiles(h, remoteDir) {
|
|
1175
|
+
const res = await this.exec(
|
|
1176
|
+
h,
|
|
1177
|
+
// -L for nicer dir-detection on symlinks; `--printf` is non-portable so
|
|
1178
|
+
// use a small awk wrap that prints `<name>\t<d|f>` per entry.
|
|
1179
|
+
`find ${shellQuote(remoteDir)} -mindepth 1 -maxdepth 1 -printf '%f\\t%y\\n'`
|
|
1180
|
+
);
|
|
1181
|
+
if (res.exitCode !== 0) return [];
|
|
1182
|
+
return res.stdout.split(/\r?\n/).filter((line) => line.length > 0).map((line) => {
|
|
1183
|
+
const [name, kind] = line.split(" ");
|
|
1184
|
+
return { name: name ?? line, isDir: kind === "d" };
|
|
1185
|
+
});
|
|
1186
|
+
},
|
|
1187
|
+
async previewUrl(h, port) {
|
|
1188
|
+
const { state, vpsIp } = await ensureLiveTarget(h.sandboxId);
|
|
1189
|
+
void state;
|
|
1190
|
+
void vpsIp;
|
|
1191
|
+
const localPort = await tunnels.forward(h.sandboxId, port);
|
|
1192
|
+
return { url: `http://127.0.0.1:${String(localPort)}` };
|
|
1193
|
+
},
|
|
1194
|
+
async signedPreviewUrl(h, port, _ttl) {
|
|
1195
|
+
void _ttl;
|
|
1196
|
+
return this.previewUrl(h, port);
|
|
1197
|
+
},
|
|
1198
|
+
async startInBoxPortless(h, opts) {
|
|
1199
|
+
const tlsFlag = opts.tls ? "" : "--no-tls";
|
|
1200
|
+
const startCmd = `sudo portless proxy start ${tlsFlag} -p ${String(opts.proxyPort)}`.replace(/\s+/g, " ");
|
|
1201
|
+
const aliasCmd = `sudo portless alias ${shellQuote(opts.boxName)} ${String(opts.webPort)}`;
|
|
1202
|
+
await this.exec(h, `${startCmd}; ${aliasCmd}`);
|
|
1203
|
+
},
|
|
1204
|
+
async attachArgv(h) {
|
|
1205
|
+
const { target } = await ensureLiveTarget(h.sandboxId);
|
|
1206
|
+
return [
|
|
1207
|
+
"ssh",
|
|
1208
|
+
...sshOptArgs(target),
|
|
1209
|
+
`${target.user}@${target.host}`
|
|
1210
|
+
];
|
|
1211
|
+
},
|
|
1212
|
+
async createSnapshot(h, name) {
|
|
1213
|
+
const id = Number.parseInt(h.sandboxId, 10);
|
|
1214
|
+
const c = client();
|
|
1215
|
+
const { image } = await withHetznerRetry(
|
|
1216
|
+
{ method: "createImage", retryOnAmbiguous: false, attemptTimeoutMs: 12e4 },
|
|
1217
|
+
() => c.createImage(id, {
|
|
1218
|
+
type: "snapshot",
|
|
1219
|
+
description: name,
|
|
1220
|
+
labels: { "agentbox.role": "ckpt", "agentbox.box": h.sandboxId }
|
|
1221
|
+
})
|
|
1222
|
+
);
|
|
1223
|
+
await pollUntil(
|
|
1224
|
+
`image ${String(image.id)} availability`,
|
|
1225
|
+
async () => {
|
|
1226
|
+
const img = await c.getImage(image.id);
|
|
1227
|
+
return img?.status === "available" ? img : null;
|
|
1228
|
+
},
|
|
1229
|
+
{ deadlineMs: SNAPSHOT_DEADLINE_MS2, intervalMs: 3e3, maxIntervalMs: 1e4 }
|
|
1230
|
+
);
|
|
1231
|
+
},
|
|
1232
|
+
async deleteSnapshot(name) {
|
|
1233
|
+
const c = client();
|
|
1234
|
+
const img = await findImageByDescription(c, name);
|
|
1235
|
+
if (!img) return;
|
|
1236
|
+
try {
|
|
1237
|
+
await c.deleteImage(img.id);
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
if (err instanceof HetznerApiError && (err.statusCode === 404 || err.code === "not_found")) return;
|
|
1240
|
+
throw err;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
async function pushHetznerAgentCredentials(target, log) {
|
|
1245
|
+
const specs = [
|
|
1246
|
+
{ kind: "claude", stage: stageClaudeCredentialsForUpload, dest: "/home/vscode/.agentbox-creds/claude" },
|
|
1247
|
+
{ kind: "codex", stage: stageCodexCredentialsForUpload, dest: "/home/vscode/.agentbox-creds/codex" },
|
|
1248
|
+
{ kind: "opencode", stage: stageOpencodeCredentialsForUpload, dest: "/home/vscode/.agentbox-creds/opencode" }
|
|
1249
|
+
];
|
|
1250
|
+
for (const spec of specs) {
|
|
1251
|
+
const staged = await spec.stage();
|
|
1252
|
+
for (const w of staged.warnings) log(`hetzner: [${spec.kind}-creds] ${w}`);
|
|
1253
|
+
try {
|
|
1254
|
+
if (!staged.tarballPath) {
|
|
1255
|
+
log(`hetzner: ${spec.kind}: no host credentials to push (skipping)`);
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
const remote = `/tmp/agentbox-${spec.kind}-creds.tar.gz`;
|
|
1259
|
+
const argv = [
|
|
1260
|
+
...sshOptArgs(target),
|
|
1261
|
+
staged.tarballPath,
|
|
1262
|
+
`${target.user}@${target.host}:${remote}`
|
|
1263
|
+
];
|
|
1264
|
+
const scpRes = await execa4("scp", argv, { reject: false, timeout: 12e4 });
|
|
1265
|
+
if (scpRes.exitCode !== 0) {
|
|
1266
|
+
throw new Error(
|
|
1267
|
+
`scp ${spec.kind} credentials failed (exit ${String(scpRes.exitCode)}): ${scpRes.stderr ?? ""}`
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
const extract = await execa4(
|
|
1271
|
+
"ssh",
|
|
1272
|
+
[
|
|
1273
|
+
...sshOptArgs(target),
|
|
1274
|
+
`${target.user}@${target.host}`,
|
|
1275
|
+
`sudo -u vscode mkdir -p ${spec.dest} && sudo -u vscode tar -xzf ${remote} -C ${spec.dest} --no-same-permissions --no-same-owner -m && rm -f ${remote}`
|
|
1276
|
+
],
|
|
1277
|
+
{ reject: false, timeout: 3e4 }
|
|
1278
|
+
);
|
|
1279
|
+
if (extract.exitCode !== 0) {
|
|
1280
|
+
throw new Error(
|
|
1281
|
+
`${spec.kind} credential extract failed (exit ${String(extract.exitCode)}): ${extract.stderr ?? ""}`
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
log(`hetzner: ${spec.kind}: credentials pushed`);
|
|
1285
|
+
} finally {
|
|
1286
|
+
await staged.cleanup();
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
var cloudProvider = createCloudProvider(hetznerBackend, {
|
|
1291
|
+
defaultResources: { cpu: 2, memory: 4, disk: 40 }
|
|
1292
|
+
});
|
|
1293
|
+
var hetznerProvider = {
|
|
1294
|
+
...cloudProvider,
|
|
1295
|
+
prepare: prepareHetznerProvider
|
|
1296
|
+
};
|
|
1297
|
+
export {
|
|
1298
|
+
DEFAULT_HCLOUD_ENDPOINT,
|
|
1299
|
+
HETZNER_DEFAULT_BOX_IMAGE_REF,
|
|
1300
|
+
HetznerApiError,
|
|
1301
|
+
RUNTIME_ASSETS,
|
|
1302
|
+
candidatesFor,
|
|
1303
|
+
createPerBoxFirewall,
|
|
1304
|
+
deletePerBoxFirewall,
|
|
1305
|
+
detectEgressIp,
|
|
1306
|
+
ensureHetznerBaseSnapshot,
|
|
1307
|
+
ensureHetznerCredentials,
|
|
1308
|
+
ensureHetznerEnvLoaded,
|
|
1309
|
+
generateBoxCloudInit,
|
|
1310
|
+
generatePrepareCloudInit,
|
|
1311
|
+
hetznerBackend,
|
|
1312
|
+
hetznerProvider,
|
|
1313
|
+
isAttemptTimeout,
|
|
1314
|
+
isRetriable,
|
|
1315
|
+
makeHetznerClient,
|
|
1316
|
+
maskKey,
|
|
1317
|
+
mintPrepareKey,
|
|
1318
|
+
mintSshKey,
|
|
1319
|
+
normalizeSourceCidr,
|
|
1320
|
+
pollUntil,
|
|
1321
|
+
prepareHetzner,
|
|
1322
|
+
prepareHetznerProvider,
|
|
1323
|
+
preparedStatePath,
|
|
1324
|
+
readHetznerCredStatus,
|
|
1325
|
+
readPreparedState,
|
|
1326
|
+
resolveRuntimeAssets,
|
|
1327
|
+
scpDownload,
|
|
1328
|
+
scpUpload,
|
|
1329
|
+
secretsPath,
|
|
1330
|
+
sshExec,
|
|
1331
|
+
sshOnlyInboundRule,
|
|
1332
|
+
sshOptArgs,
|
|
1333
|
+
syncFirewallSource,
|
|
1334
|
+
updatePreparedState,
|
|
1335
|
+
waitForSsh,
|
|
1336
|
+
withHetznerRetry,
|
|
1337
|
+
writePreparedState
|
|
1338
|
+
};
|
|
1339
|
+
//# sourceMappingURL=dist-QZGJIBT5.js.map
|