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