controlclaw 0.0.0-stage → 0.9.4

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 (4) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +377 -2
  3. package/dist/index.js +2501 -0
  4. package/package.json +34 -4
package/dist/index.js ADDED
@@ -0,0 +1,2501 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { createInterface } from "readline";
5
+ import { parseArgs } from "util";
6
+
7
+ // src/commands.ts
8
+ import { randomBytes as randomBytes3 } from "crypto";
9
+ import { hostname } from "os";
10
+ import { join as join8 } from "path";
11
+
12
+ // src/agent.ts
13
+ import { join } from "path";
14
+ var UNIT = "controlclaw-agent.service";
15
+ var AGENT_PORT = 3100;
16
+ function agentPaths(sys, user) {
17
+ const system = sys.euid === 0;
18
+ const home = sys.env.HOME ?? user.home;
19
+ return {
20
+ bin: system ? "/usr/local/bin/controlclaw-vm-agent" : join(home, ".local/bin/controlclaw-vm-agent"),
21
+ config: system ? "/etc/controlclaw/agent.json" : join(home, ".config/controlclaw/agent.json"),
22
+ unit: system ? join("/etc/systemd/system", UNIT) : join(home, ".config/systemd/user", UNIT),
23
+ state: join(user.home, ".local/state/controlclaw"),
24
+ system
25
+ };
26
+ }
27
+ function agentConfig(o, state) {
28
+ const file = {
29
+ mode: "unmanaged",
30
+ listen: o.listen ?? `127.0.0.1:${AGENT_PORT}`,
31
+ state_dir: state,
32
+ ...o.agentId ? { agent_id: o.agentId } : {},
33
+ firewall: {
34
+ url: o.firewallUrl,
35
+ public_key: o.firewallKey,
36
+ // The firewall signs its tokens for the address it dials (`h`): checked once there is one.
37
+ ...o.listen && o.agentId ? { hostnames: [hostOf(o.listen)] } : {},
38
+ ...o.allowFrom?.length ? { allow_from: o.allowFrom } : {},
39
+ ...o.gate ? { gate: o.gate } : {}
40
+ },
41
+ openclaw: o.container ? { runs: "docker", container: o.container } : {}
42
+ };
43
+ return `${JSON.stringify(file, null, 2)}
44
+ `;
45
+ }
46
+ function hostOf(listen) {
47
+ return listen.slice(0, listen.lastIndexOf(":"));
48
+ }
49
+ function agentUnit(o, p) {
50
+ const lines = [
51
+ "# Written by controlclaw connect; removed by controlclaw disconnect.",
52
+ "[Unit]",
53
+ "Description=ControlClaw agent (unmanaged)",
54
+ `After=network-online.target${o.after ? ` ${o.after}` : ""}`,
55
+ "",
56
+ "[Service]"
57
+ ];
58
+ if (p.system) {
59
+ lines.push(`User=${o.user.name}`, `Environment=HOME=${o.user.home}`, `Environment=XDG_RUNTIME_DIR=/run/user/${o.user.uid}`);
60
+ }
61
+ lines.push(
62
+ `Environment=CC_AGENT_CONFIG=${p.config}`,
63
+ `ExecStart=${p.bin}`,
64
+ "Restart=always",
65
+ "RestartSec=5",
66
+ "",
67
+ "[Install]",
68
+ `WantedBy=${p.system ? "multi-user.target" : "default.target"}`,
69
+ ""
70
+ );
71
+ return lines.join("\n");
72
+ }
73
+ async function installAgent(sys, rec, o) {
74
+ const p = agentPaths(sys, o.user);
75
+ const ctl = p.system ? ["systemctl"] : ["systemctl", "--user"];
76
+ if (sys.realpath(o.binary) === sys.realpath(p.bin)) {
77
+ } else {
78
+ if (sys.exists(p.bin)) {
79
+ const aside = `${p.bin}.before-controlclaw`;
80
+ if (sys.exists(aside)) throw new Error(`${aside} exists already: put it back or remove it, then connect again`);
81
+ const mv = await sys.run(["mv", "-f", p.bin, aside]);
82
+ if (mv.code !== 0) throw new Error(`could not set ${p.bin} aside: ${mv.stderr.trim()}`);
83
+ rec.onUndo("put back the vm-agent binary that was there", [["mv", "-f", aside, p.bin]]);
84
+ }
85
+ rec.onUndo("remove the vm-agent", [["rm", "-f", p.bin]]);
86
+ const cp = await sys.run(["install", "-D", "-m", "0755", o.binary, p.bin]);
87
+ if (cp.code !== 0) throw new Error(`could not install the vm-agent: ${cp.stderr.trim()}`);
88
+ }
89
+ rec.onUndo("remove the agent's state", [["rm", "-rf", "--", p.state]], sys.euid === 0 && o.user.uid !== 0 ? { user: o.user } : {});
90
+ if (o.keypair) {
91
+ await rec.file(join(p.state, "vm_private_key.pem"), o.keypair.privatePem, { mode: 384, owner: o.user });
92
+ await rec.file(join(p.state, "vm_public_key.pem"), o.keypair.publicPem, { mode: 420, owner: o.user });
93
+ }
94
+ await rec.file(p.config, agentConfig(o, p.state));
95
+ rec.onUndo("reload systemd", [[...ctl, "daemon-reload"]]);
96
+ await rec.file(p.unit, agentUnit(o, p));
97
+ rec.onUndo("stop the vm-agent", [[...ctl, "disable", "--now", UNIT]]);
98
+ await sys.run([...ctl, "daemon-reload"]);
99
+ const up2 = await sys.run([...ctl, "enable", "--now", UNIT]);
100
+ if (up2.code !== 0) throw new Error(`could not start the vm-agent: ${up2.stderr.trim()}`);
101
+ return p;
102
+ }
103
+
104
+ // src/ca.ts
105
+ import { join as join2 } from "path";
106
+
107
+ // src/sys.ts
108
+ import { spawn } from "child_process";
109
+ import { chmodSync, chownSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmdirSync, rmSync, statSync, writeFileSync } from "fs";
110
+ import { dirname } from "path";
111
+ var DEFAULT_TIMEOUT = 12e4;
112
+ function asUser(sys, user, argv, env = {}) {
113
+ const vars = { ...env };
114
+ if (user) {
115
+ vars.HOME = user.home;
116
+ vars.XDG_RUNTIME_DIR = `/run/user/${user.uid}`;
117
+ }
118
+ const pairs = Object.entries(vars).map(([k, v]) => `${k}=${v}`);
119
+ const withEnv = pairs.length ? ["env", ...pairs, ...argv] : argv;
120
+ if (user && sys.euid === 0 && user.uid !== 0) return ["runuser", "-u", user.name, "--", ...withEnv];
121
+ return withEnv;
122
+ }
123
+ function realSys() {
124
+ const euid = process.geteuid?.() ?? -1;
125
+ const sys = {
126
+ euid,
127
+ env: process.env,
128
+ run(argv, opts = {}) {
129
+ const full = asUser(sys, opts.user, argv, opts.env);
130
+ return new Promise((resolve) => {
131
+ const [cmd, ...args] = full;
132
+ const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
133
+ let stdout = "";
134
+ let stderr = "";
135
+ const timer = setTimeout(() => child.kill("SIGKILL"), opts.timeoutMs ?? DEFAULT_TIMEOUT);
136
+ child.stdout.on("data", (d) => stdout += d.toString());
137
+ child.stderr.on("data", (d) => stderr += d.toString());
138
+ child.on("error", (e) => {
139
+ clearTimeout(timer);
140
+ resolve({ code: 127, stdout, stderr: stderr + e.message });
141
+ });
142
+ child.on("close", (code) => {
143
+ clearTimeout(timer);
144
+ resolve({ code: code ?? 1, stdout, stderr });
145
+ });
146
+ child.stdin.on("error", () => {
147
+ });
148
+ child.stdin.end(opts.input ?? "");
149
+ });
150
+ },
151
+ read(path) {
152
+ try {
153
+ return readFileSync(path, "utf8");
154
+ } catch {
155
+ return null;
156
+ }
157
+ },
158
+ write(path, data, opts = {}) {
159
+ const made = [];
160
+ for (let d = dirname(path); d !== "/" && !existsSync(d); d = dirname(d)) made.push(d);
161
+ mkdirSync(dirname(path), { recursive: true });
162
+ writeFileSync(path, data, { mode: opts.mode ?? 420 });
163
+ chmodSync(path, opts.mode ?? 420);
164
+ if (opts.owner && euid === 0) {
165
+ for (const d of made) chownSync(d, opts.owner.uid, opts.owner.gid);
166
+ chownSync(path, opts.owner.uid, opts.owner.gid);
167
+ }
168
+ },
169
+ remove(path) {
170
+ rmSync(path, { force: true, recursive: true });
171
+ },
172
+ stat(path) {
173
+ try {
174
+ const s = statSync(path);
175
+ return { mode: s.mode & 4095, uid: s.uid, gid: s.gid };
176
+ } catch {
177
+ return null;
178
+ }
179
+ },
180
+ lstat(path) {
181
+ try {
182
+ const s = lstatSync(path);
183
+ return { uid: s.uid, mode: s.mode & 4095, link: s.isSymbolicLink() };
184
+ } catch {
185
+ return null;
186
+ }
187
+ },
188
+ async readAs(user, path) {
189
+ const r = await sys.run(["sh", "-c", 'if [ -e "$1" ] || [ -L "$1" ]; then cat -- "$1"; else exit 4; fi', "sh", path], { user });
190
+ if (r.code === 4) return null;
191
+ if (r.code !== 0) {
192
+ throw new Error(`cannot read ${path} as ${user.name}, who owns its folder (${r.stderr.trim()}): give it to ${user.name} or remove it, then connect again`);
193
+ }
194
+ return r.stdout;
195
+ },
196
+ async writeAs(user, path, data, mode) {
197
+ const script = 'umask "$3"; mkdir -p -- "$(dirname -- "$1")" && cat > "$1" && if [ -n "$2" ]; then chmod "$2" -- "$1"; fi';
198
+ const argv = ["sh", "-c", script, "sh", path, mode === void 0 ? "" : mode.toString(8), mode === void 0 ? "022" : "077"];
199
+ const r = await sys.run(argv, { user, input: data });
200
+ if (r.code !== 0) {
201
+ throw new Error(`cannot write ${path} as ${user.name}, who owns its folder (${r.stderr.trim()}): give it to ${user.name} or remove it, then connect again`);
202
+ }
203
+ },
204
+ async removeAs(user, path, dirs) {
205
+ await sys.run(["sh", "-c", 'rm -f -- "$1"; shift; for d; do rmdir -- "$d" 2>/dev/null; done; true', "sh", path, ...dirs], { user });
206
+ },
207
+ realpath(path) {
208
+ try {
209
+ return realpathSync(path);
210
+ } catch {
211
+ return path;
212
+ }
213
+ },
214
+ removeEmptyDir(path) {
215
+ try {
216
+ rmdirSync(path);
217
+ } catch {
218
+ }
219
+ },
220
+ exists(path) {
221
+ return existsSync(path);
222
+ }
223
+ };
224
+ return sys;
225
+ }
226
+ function userByName(sys, name) {
227
+ return parsePasswd(sys.read("/etc/passwd") ?? "").find((u) => u.name === name) ?? null;
228
+ }
229
+ function userByUid(sys, uid) {
230
+ return parsePasswd(sys.read("/etc/passwd") ?? "").find((u) => u.uid === uid) ?? null;
231
+ }
232
+ function parsePasswd(raw) {
233
+ return raw.split("\n").map((l) => l.split(":")).filter((p) => p.length >= 6).map(([name, , uid, gid, , home]) => ({ name, uid: Number(uid), gid: Number(gid), home }));
234
+ }
235
+ async function which(sys, ...names) {
236
+ for (const n of names) {
237
+ const r = await sys.run(["sh", "-c", 'command -v "$1"', "sh", n]);
238
+ if (r.code === 0 && r.stdout.trim()) return r.stdout.trim();
239
+ }
240
+ return null;
241
+ }
242
+
243
+ // src/ca.ts
244
+ var SYSTEM_CA = "/usr/local/share/ca-certificates/controlclaw.crt";
245
+ var NSS_NAME = "ControlClaw firewall";
246
+ function checkPem(pem) {
247
+ const t = pem.trim();
248
+ if (!t.startsWith("-----BEGIN CERTIFICATE-----") || !t.endsWith("-----END CERTIFICATE-----")) {
249
+ return "the CA file is not a PEM certificate";
250
+ }
251
+ return null;
252
+ }
253
+ async function installSystemStore(sys, rec, pem) {
254
+ rec.onUndo("refresh the system CA store", [["update-ca-certificates"]]);
255
+ await rec.file(SYSTEM_CA, pem);
256
+ const r = await sys.run(["update-ca-certificates"]);
257
+ if (r.code !== 0) throw new Error(`update-ca-certificates failed: ${r.stderr.trim()}`);
258
+ }
259
+ async function installNodeEnv(sys, rec, install, user, caPath) {
260
+ const body = `# Written by controlclaw connect; removed by controlclaw disconnect.
261
+ [Service]
262
+ Environment=NODE_EXTRA_CA_CERTS=${caPath}
263
+ `;
264
+ const runs = install.runs;
265
+ if (runs.kind === "systemd-user" && user) {
266
+ const reload = ["systemctl", "--user", "daemon-reload"];
267
+ rec.onUndo("reload the user's systemd", [reload], { user });
268
+ await rec.file(join2(user.home, ".config/systemd/user", `${runs.unit}.d`, "controlclaw.conf"), body, { owner: user });
269
+ await sys.run(reload, { user });
270
+ return null;
271
+ }
272
+ if (runs.kind === "systemd-system") {
273
+ rec.onUndo("reload systemd", [["systemctl", "daemon-reload"]]);
274
+ await rec.file(join2("/etc/systemd/system", `${runs.unit}.d`, "controlclaw.conf"), body);
275
+ await sys.run(["systemctl", "daemon-reload"]);
276
+ return null;
277
+ }
278
+ return `OpenClaw does not run as a systemd unit: add NODE_EXTRA_CA_CERTS=${caPath} to how you start it, then restart it.`;
279
+ }
280
+ async function installNss(sys, rec, user, caPath) {
281
+ const certutil = await which(sys, "certutil");
282
+ if (!certutil) return 0;
283
+ let n = 0;
284
+ for (const db of [join2(user.home, ".pki/nssdb"), join2(user.home, ".local/share/pki/nssdb")]) {
285
+ if (!sys.exists(db)) continue;
286
+ rec.onUndo("remove the CA from Chrome", [[certutil, "-d", `sql:${db}`, "-D", "-n", NSS_NAME]], { user });
287
+ const r = await sys.run([certutil, "-d", `sql:${db}`, "-A", "-t", "C,,", "-n", NSS_NAME, "-i", caPath], { user });
288
+ if (r.code === 0) n++;
289
+ }
290
+ return n;
291
+ }
292
+
293
+ // src/capture/docker.ts
294
+ import { isIPv4 as isIPv42 } from "net";
295
+ import { join as join4 } from "path";
296
+
297
+ // src/probe.ts
298
+ import { Resolver } from "dns/promises";
299
+ import { lookup } from "dns/promises";
300
+ import { isIPv4 } from "net";
301
+ import { connect } from "net";
302
+ function splitHostPort(raw, port) {
303
+ const m = /^([A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?)(?::(\d{1,5}))?$/.exec(raw.trim());
304
+ const p = m?.[2] ? Number(m[2]) : port;
305
+ if (!m || p < 1 || p > 65535) throw new Error(`not a host:port (an IPv4 address or a hostname): ${raw}`);
306
+ return { host: m[1], port: p };
307
+ }
308
+ async function ipv4Of(host2) {
309
+ if (isIPv4(host2)) return host2;
310
+ const { address } = await lookup(host2, { family: 4 });
311
+ return address;
312
+ }
313
+ async function dnsAnswers(server, name = "example.com") {
314
+ const r = new Resolver({ timeout: 3e3, tries: 1 });
315
+ r.setServers([server]);
316
+ try {
317
+ const a = await r.resolve4(name);
318
+ return { name: "firewall DNS", ok: true, detail: `${server} answers (${name} \u2192 ${a[0]})` };
319
+ } catch (e) {
320
+ const code = e.code ?? String(e);
321
+ return { name: "firewall DNS", ok: false, detail: `${server} does not answer (${code})` };
322
+ }
323
+ }
324
+ function proxyAnswers(proxy2, target = "example.com:443") {
325
+ const { host: host2, port } = splitHostPort(proxy2, 8080);
326
+ return new Promise((resolve) => {
327
+ const done = (ok, detail) => {
328
+ sock.destroy();
329
+ resolve({ name: "firewall proxy", ok, detail });
330
+ };
331
+ const sock = connect({ host: host2, port, timeout: 5e3 });
332
+ sock.on("connect", () => sock.write(`CONNECT ${target} HTTP/1.1\r
333
+ Host: ${target}\r
334
+ \r
335
+ `));
336
+ sock.once("data", (d) => {
337
+ const line = d.toString().split("\r\n")[0] ?? "";
338
+ done(/^HTTP\/1\.[01] 200/.test(line), `${host2}:${port} answered "${line}"`);
339
+ });
340
+ sock.on("timeout", () => done(false, `${host2}:${port} did not answer`));
341
+ sock.on("error", (e) => done(false, `${host2}:${port}: ${e.message}`));
342
+ });
343
+ }
344
+
345
+ // src/groups.ts
346
+ var ESCAPE_GROUPS = ["docker", "lxd", "incus", "incus-admin", "libvirt", "podman"];
347
+ function escapeGroupMessage(user, groups) {
348
+ const removes = groups.map((g) => `gpasswd -d ${user.name} ${g}`).join("; ");
349
+ return `${user.name} is in the ${groups.join(", ")} group: it can start a container or VM that the daemon sends out, past host mode's rules. Remove it (${removes}), or use docker mode`;
350
+ }
351
+
352
+ // src/openclaw.ts
353
+ import { join as join3 } from "path";
354
+ var NOT_FOUND = {
355
+ cli: null,
356
+ version: null,
357
+ config_file: null,
358
+ state_dir: null,
359
+ runs: { kind: "not-found" },
360
+ gateway_port: 18789
361
+ };
362
+ async function findVmAgent(sys, given) {
363
+ for (const p of [given, sys.env.CC_VM_AGENT, "/usr/local/bin/controlclaw-vm-agent"]) {
364
+ if (p && sys.exists(p)) return p;
365
+ }
366
+ return which(sys, "controlclaw-vm-agent", "vm-agent");
367
+ }
368
+ async function findUser(sys, given) {
369
+ if (given) return userByName(sys, given);
370
+ const ps = await sys.run(["ps", "-eo", "uid=,pid=,args="]);
371
+ for (const line of ps.stdout.split("\n")) {
372
+ const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
373
+ if (!m || !isGatewayProcess(m[3])) continue;
374
+ const cgroup = sys.read(`/proc/${m[2]}/cgroup`) ?? "";
375
+ if (/docker|containerd|libpod/.test(cgroup)) continue;
376
+ const u = userByUid(sys, Number(m[1]));
377
+ if (u) return u;
378
+ }
379
+ const owners = parsePasswd(sys.read("/etc/passwd") ?? "").filter((u) => (u.uid === 0 || u.uid >= 1e3) && sys.exists(join3(u.home, ".openclaw/openclaw.json")));
380
+ if (owners.length === 1) return owners[0];
381
+ if (sys.env.SUDO_USER) return userByName(sys, sys.env.SUDO_USER);
382
+ return userByUid(sys, sys.euid);
383
+ }
384
+ function isGatewayProcess(args) {
385
+ const [exe = "", ...rest] = args.trim().split(/\s+/);
386
+ const prog = exe.split("/").pop();
387
+ if (prog === "openclaw-gateway") return true;
388
+ if (prog === "openclaw") return rest.includes("gateway");
389
+ if (prog === "bun" || /^node(js)?\d*$/.test(prog)) {
390
+ return rest.some((a) => /openclaw/.test(a)) && rest.includes("gateway");
391
+ }
392
+ return false;
393
+ }
394
+ function parseDiscover(raw) {
395
+ try {
396
+ const v = jsonFrom(raw);
397
+ return v && typeof v === "object" && v.runs ? v : null;
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+ async function findOpenClaw(sys, user, vmAgent) {
403
+ let discoverError;
404
+ if (vmAgent) {
405
+ const r = await discover(sys, vmAgent, user);
406
+ const install = r.code === 0 ? parseDiscover(r.stdout) : null;
407
+ if (install) return { install, user: install.runs.kind === "docker" ? null : user, via: "vm-agent" };
408
+ discoverError = (r.stderr || r.stdout).trim().split("\n").pop() || `exit ${r.code}`;
409
+ }
410
+ return { install: await lightLook(sys, user), user, via: "light", ...discoverError ? { discoverError } : {} };
411
+ }
412
+ async function discover(sys, vmAgent, user) {
413
+ const r = await sys.run([vmAgent, "discover"], { user: user ?? void 0, timeoutMs: 6e4 });
414
+ if (!(r.code === 126 || /permission denied/i.test(r.stderr)) || !user || sys.euid !== 0) return r;
415
+ const dir = (await sys.run(["mktemp", "-d", "/tmp/controlclaw-discover.XXXXXX"])).stdout.trim();
416
+ if (!dir) return r;
417
+ try {
418
+ await sys.run(["chmod", "0755", dir]);
419
+ const copy = join3(dir, "vm-agent");
420
+ if ((await sys.run(["install", "-m", "0755", vmAgent, copy])).code !== 0) return r;
421
+ return await sys.run([copy, "discover"], { user, timeoutMs: 6e4 });
422
+ } finally {
423
+ await sys.run(["rm", "-rf", dir]);
424
+ }
425
+ }
426
+ async function lightLook(sys, user) {
427
+ const home = user?.home ?? sys.env.HOME ?? "/root";
428
+ const places = [join3(home, ".openclaw/bin/openclaw"), join3(home, ".npm-global/bin/openclaw"), join3(home, ".local/bin/openclaw"), "/usr/local/bin/openclaw", "/usr/bin/openclaw"];
429
+ const cli = places.find((p) => sys.exists(p)) ?? null;
430
+ if (!cli) return NOT_FOUND;
431
+ const v = await sys.run([cli, "--version"], { user: user ?? void 0, timeoutMs: 3e4 });
432
+ const version = /(\d{4}\.\d+\.\d+)/.exec(v.stdout)?.[1] ?? null;
433
+ return { ...NOT_FOUND, cli, version, runs: { kind: "not-running" } };
434
+ }
435
+ function cliArgv(install, args) {
436
+ if (install.runs.kind === "docker") return ["docker", "exec", "-i", install.runs.container, "node", "dist/index.js", ...args];
437
+ return [install.cli ?? "openclaw", ...args];
438
+ }
439
+ function restartArgv(install) {
440
+ switch (install.runs.kind) {
441
+ case "systemd-system":
442
+ return ["systemctl", "restart", "--", install.runs.unit];
443
+ case "docker":
444
+ return ["docker", "restart", "--", install.runs.container];
445
+ case "pm2":
446
+ return ["pm2", "restart", install.runs.name];
447
+ // Not `openclaw gateway restart`: it refuses while the gateway restarts itself after a config
448
+ // change (found on 2026.10.9), and a unit restart is what re-reads the drop-in's environment.
449
+ case "systemd-user":
450
+ return ["systemctl", "--user", "restart", "--", install.runs.unit];
451
+ case "process":
452
+ return cliArgv(install, ["gateway", "restart"]);
453
+ default:
454
+ return null;
455
+ }
456
+ }
457
+ function restartUser(install, user) {
458
+ return ["systemd-user", "process", "pm2"].includes(install.runs.kind) ? user ?? void 0 : void 0;
459
+ }
460
+ function jsonFrom(out) {
461
+ return JSON.parse(out.slice(out.indexOf("{")));
462
+ }
463
+ async function restartOpenClaw(sys, install, user) {
464
+ const argv = restartArgv(install);
465
+ if (!argv) return null;
466
+ const r = await sys.run(argv, { user: restartUser(install, user), timeoutMs: 18e4 });
467
+ if (r.code === 0) return null;
468
+ return `OpenClaw did not restart (${(r.stderr || r.stdout).trim().split("\n").pop()}): restart it yourself so it reads the new settings.`;
469
+ }
470
+
471
+ // src/capture/apt.ts
472
+ async function aptInstall(sys, rec, pkg) {
473
+ rec.onUndo(`remove ${pkg}`, [["apt-get", "purge", "-y", "-q", pkg]]);
474
+ const env = { DEBIAN_FRONTEND: "noninteractive" };
475
+ await sys.run(["apt-get", "update", "-q"], { env, timeoutMs: 3e5 });
476
+ const r = await sys.run(["apt-get", "install", "-y", "-q", pkg], { env, timeoutMs: 3e5 });
477
+ if (r.code !== 0) throw new Error(`apt-get install ${pkg} failed: ${r.stderr.trim()}`);
478
+ }
479
+
480
+ // src/capture/host.ts
481
+ var DIR = "/etc/controlclaw/capture";
482
+ var TABLE = "controlclaw_capture";
483
+ var REDSOCKS_PORT = 12346;
484
+ var CAPTURE_UNIT = "controlclaw-capture.service";
485
+ var REDSOCKS_UNIT = "controlclaw-redsocks.service";
486
+ var RESOLVED_DROPIN = "/etc/systemd/resolved.conf.d/controlclaw.conf";
487
+ var NFTABLES_DROPIN = "/etc/systemd/system/nftables.service.d/controlclaw.conf";
488
+ var HEADER = "# Written by controlclaw connect; removed by controlclaw disconnect.";
489
+ var WG_IFACE = "cc-wg0";
490
+ var REMOVE_NFT = `table inet ${TABLE}
491
+ delete table inet ${TABLE}
492
+ `;
493
+ var CONNECTOR_RELAY_PORT = 3111;
494
+ var GATE_PORTS = [3900, 3940];
495
+ function captureNft(o) {
496
+ const { uid, dns } = o;
497
+ const ports = o.loopbackPorts.join(", ");
498
+ const resolve = o.resolveUid === null ? "" : ` # systemd-resolved asks only the firewall (not a DHCP server of some link, not DNS over TLS,
499
+ # not the link by LLMNR or mDNS).
500
+ meta skuid ${o.resolveUid} meta l4proto { tcp, udp } th dport { 53, 853 } ip daddr != ${dns} reject
501
+ meta skuid ${o.resolveUid} meta nfproto ipv6 meta l4proto { tcp, udp } th dport { 53, 853 } reject
502
+ meta skuid ${o.resolveUid} meta l4proto { tcp, udp } th dport { 5353, 5355 } reject
503
+ `;
504
+ const stub = o.resolveUid === null ? "" : " ip daddr { 127.0.0.53, 127.0.0.54 } meta l4proto { tcp, udp } th dport 53 accept\n";
505
+ const gatePorts = GATE_PORTS.join(", ");
506
+ const gateNat = o.gate ? ` meta skuid ${uid} ip daddr ${o.gate} tcp dport { ${gatePorts} } return
507
+ ` : "";
508
+ const gateOut = o.gate ? ` ip daddr ${o.gate} tcp dport { ${gatePorts} } accept
509
+ ` : "";
510
+ return `#!/usr/sbin/nft -f
511
+ ${HEADER}
512
+ # Traffic from uid ${uid} (OpenClaw): TCP to redsocks, DNS to the firewall (or resolved's stub, which
513
+ # asks only the firewall), the rest refused.
514
+ table inet ${TABLE}
515
+ delete table inet ${TABLE}
516
+ table inet ${TABLE} {
517
+ chain nat_out {
518
+ type nat hook output priority -100; policy accept;
519
+ meta skuid ${uid} meta nfproto ipv4 ip daddr != 127.0.0.0/8 meta l4proto { tcp, udp } th dport 53 dnat ip to ${dns}:53
520
+ meta skuid ${uid} ip daddr 127.0.0.0/8 return
521
+ ${gateNat} meta skuid ${uid} meta nfproto ipv4 meta l4proto tcp redirect to :${REDSOCKS_PORT}
522
+ }
523
+ chain out {
524
+ type filter hook output priority 0; policy accept;
525
+ ${resolve} # Only the uid's packets are judged; others, and the kernel's own (WireGuard's UDP, which has
526
+ # no socket owner and would match neither skuid test), go on.
527
+ meta skuid ${uid} jump uid_out
528
+ }
529
+ chain uid_out {
530
+ ct state established,related accept
531
+ # By address, not oif: a redirected packet still shows the outside interface here.
532
+ ip daddr 127.0.0.0/8 tcp dport { ${ports} } accept
533
+ ip6 daddr ::1 tcp dport { ${ports} } accept
534
+ meta nfproto ipv4 ip daddr ${dns} meta l4proto { tcp, udp } th dport 53 accept
535
+ ${gateOut}${stub} meta l4proto tcp reject with tcp reset
536
+ reject
537
+ }
538
+ ${tunnelChains(o)}}
539
+ `;
540
+ }
541
+ function tunnelChains(o) {
542
+ if (!o.tunnel) return "";
543
+ const { iface, address, firewall } = o.tunnel;
544
+ return ` chain tunnel_post {
545
+ type filter hook postrouting priority 0; policy accept;
546
+ ip daddr ${firewall} oifname != "${iface}" drop
547
+ }
548
+ chain tunnel_fwd {
549
+ type filter hook forward priority 0; policy accept;
550
+ # A container's traffic (Docker masquerades it on the way out) would reach the firewall as this
551
+ # agent's: nothing forwarded goes into the tunnel.
552
+ oifname "${iface}" drop
553
+ }
554
+ chain tunnel_snat {
555
+ type nat hook postrouting priority 100; policy accept;
556
+ # DNS DNATed into the tunnel keeps the source of the route it first took; the firewall takes
557
+ # only this machine's tunnel address from it.
558
+ oifname "${iface}" ip saddr != ${address} snat ip to ${address}
559
+ }
560
+ chain tunnel_in {
561
+ type filter hook input priority 0; policy accept;
562
+ ip daddr ${address} iifname != { "${iface}", "lo" } drop
563
+ }
564
+ `;
565
+ }
566
+ function loopbackPorts(gatewayPort, profilePorts2 = []) {
567
+ const top = Math.min(gatewayPort + (profilePorts2 === null ? 110 : 11), 65535);
568
+ const own = top > gatewayPort ? `${gatewayPort}-${top}` : String(gatewayPort);
569
+ const extra = [...new Set(profilePorts2 ?? [])].filter((p) => Number.isInteger(p) && p > 0 && p <= 65535 && (p < gatewayPort || p > top)).sort((a, b) => a - b);
570
+ return [String(REDSOCKS_PORT), "3100", String(CONNECTOR_RELAY_PORT), own, ...extra.map(String)];
571
+ }
572
+ function profilePorts(configText) {
573
+ if (configText === null) return [];
574
+ let cfg;
575
+ try {
576
+ cfg = JSON.parse(configText);
577
+ } catch {
578
+ return null;
579
+ }
580
+ const profiles = cfg?.browser?.profiles ?? {};
581
+ return Object.values(profiles).flatMap((p) => {
582
+ if (typeof p?.cdpPort === "number") return [p.cdpPort];
583
+ let u = null;
584
+ try {
585
+ u = typeof p?.cdpUrl === "string" ? new URL(p.cdpUrl) : null;
586
+ } catch {
587
+ u = null;
588
+ }
589
+ return u && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname) && u.port ? [Number(u.port)] : [];
590
+ });
591
+ }
592
+ function redsocksConf(proxyIp, proxyPort) {
593
+ return `// ${HEADER.slice(2)}
594
+ base {
595
+ log_debug = off;
596
+ log_info = on;
597
+ log = stderr;
598
+ daemon = off;
599
+ redirector = iptables;
600
+ redsocks_conn_max = 2000;
601
+ }
602
+
603
+ redsocks {
604
+ local_ip = 127.0.0.1;
605
+ local_port = ${REDSOCKS_PORT};
606
+ ip = ${proxyIp};
607
+ port = ${proxyPort};
608
+ type = http-connect;
609
+ }
610
+ `;
611
+ }
612
+ function redsocksUnit() {
613
+ return `${HEADER}
614
+ [Unit]
615
+ Description=ControlClaw: OpenClaw's TCP to the firewall's proxy
616
+ After=network.target
617
+
618
+ [Service]
619
+ # Its own uid, never OpenClaw's: its connections to the proxy are not caught again.
620
+ DynamicUser=yes
621
+ ExecStart=/usr/sbin/redsocks -c ${DIR}/redsocks.conf
622
+ Restart=always
623
+ RestartSec=2
624
+ LimitNOFILE=65536
625
+
626
+ [Install]
627
+ WantedBy=multi-user.target
628
+ `;
629
+ }
630
+ function captureUnit(nft) {
631
+ return `${HEADER}
632
+ [Unit]
633
+ Description=ControlClaw: capture rules for OpenClaw's user
634
+ DefaultDependencies=no
635
+ Before=network-pre.target shutdown.target
636
+ Wants=network-pre.target
637
+ # nftables.service flushes the whole ruleset: load after it at boot. Its drop-in
638
+ # (${NFTABLES_DROPIN}) loads ours again after each later start, restart or reload.
639
+ After=local-fs.target nftables.service
640
+ Conflicts=shutdown.target
641
+
642
+ [Service]
643
+ Type=oneshot
644
+ RemainAfterExit=yes
645
+ ExecStart=${nft} -f ${DIR}/capture.nft
646
+ ExecStop=${nft} -f ${DIR}/remove.nft
647
+
648
+ [Install]
649
+ WantedBy=multi-user.target
650
+ `;
651
+ }
652
+ function nftablesDropin(nft) {
653
+ return `${HEADER}
654
+ [Service]
655
+ ExecStartPost=-${nft} -f ${DIR}/capture.nft
656
+ ExecReload=-${nft} -f ${DIR}/capture.nft
657
+ ExecStopPost=-${nft} -f ${DIR}/capture.nft
658
+ `;
659
+ }
660
+ function resolvedDropin(dns) {
661
+ return `${HEADER}
662
+ [Resolve]
663
+ DNS=${dns}
664
+ Domains=~.
665
+ LLMNR=no
666
+ MulticastDNS=no
667
+ `;
668
+ }
669
+ async function requestAs(sys, user) {
670
+ const r = await sys.run(["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "15", "https://example.com/"], { user });
671
+ if (r.code === 127) return { name: `request as ${user.name}`, ok: true, detail: "skipped (no curl)" };
672
+ const code = r.stdout.trim();
673
+ const ok = r.code === 0 && code !== "000";
674
+ return { name: `request as ${user.name}`, ok, detail: ok ? `https://example.com answered ${code} through the firewall` : r.stderr.trim() || `curl exit ${r.code}` };
675
+ }
676
+ var host = {
677
+ name: "host",
678
+ protects: "limited: OpenClaw's user cannot reach the network except through the firewall, but anyone with root or sudo on this machine can remove the rules",
679
+ blockers(f) {
680
+ const out = [];
681
+ if (!f.linux) out.push("needs Linux");
682
+ if (!f.systemd) out.push("needs systemd");
683
+ if (!f.root) out.push("needs root (run it with sudo)");
684
+ if (!f.nft) out.push("needs nftables (apt-get install nftables)");
685
+ if (!f.redsocks && !f.apt) out.push("needs redsocks, and apt-get is not here to install it");
686
+ const runs = f.openclaw.install.runs.kind;
687
+ if (runs === "not-found") out.push("no OpenClaw found on this machine");
688
+ if (runs === "docker") out.push("OpenClaw runs in Docker: use --mode docker");
689
+ const u = f.openclaw.user;
690
+ if (u && u.uid !== 0 && f.escapeGroups.length) out.push(escapeGroupMessage(u, f.escapeGroups));
691
+ if (u && u.uid !== 0 && f.groupsUnknown) out.push(`cannot read the groups of ${u.name} (id -nG failed), so cannot tell whether it can start containers that go around the rules`);
692
+ if (f.resolved && f.resolveUid === null) {
693
+ out.push("systemd-resolved runs, but there is no systemd-resolve user to limit to the firewall's DNS: resolved could still ask any DNS server");
694
+ }
695
+ if (runs !== "not-found" && f.openclaw.user?.uid === 0) out.push("OpenClaw runs as root: the rules would catch every root process. Run OpenClaw as its own user");
696
+ return out;
697
+ },
698
+ async install(ctx) {
699
+ const { sys, rec, facts, user } = ctx;
700
+ if (!user) throw new Error("no OpenClaw user");
701
+ if (!ctx.firewall.dns) throw new Error("host mode needs the firewall's DNS address (--dns)");
702
+ const notes = [];
703
+ const proxy2 = splitHostPort(ctx.firewall.proxy, 8080);
704
+ const proxyIp = await ipv4Of(proxy2.host);
705
+ const dns = await ipv4Of(ctx.firewall.dns);
706
+ const nft = facts.nft ?? "/usr/sbin/nft";
707
+ if (!facts.redsocks) {
708
+ ctx.log("Installing redsocks");
709
+ await aptInstall(sys, rec, "redsocks");
710
+ await sys.run(["systemctl", "disable", "--now", "redsocks.service"]);
711
+ }
712
+ rec.onUndo("reload systemd", [["systemctl", "daemon-reload"]]);
713
+ await rec.file(`${DIR}/redsocks.conf`, redsocksConf(proxyIp, proxy2.port));
714
+ await rec.file(`/etc/systemd/system/${REDSOCKS_UNIT}`, redsocksUnit());
715
+ const nftText = captureNft({
716
+ uid: user.uid,
717
+ dns,
718
+ loopbackPorts: loopbackPorts(ctx.install.gateway_port, profilePorts(ctx.install.config_file ? sys.read(ctx.install.config_file) : null)),
719
+ resolveUid: facts.resolved ? facts.resolveUid : null,
720
+ ...ctx.tunnel ? { tunnel: { iface: WG_IFACE, address: ctx.tunnel.address, firewall: ctx.tunnel.firewall } } : {},
721
+ gate: proxyIp
722
+ });
723
+ await rec.file(`${DIR}/capture.nft`, nftText);
724
+ await rec.file(`${DIR}/remove.nft`, REMOVE_NFT);
725
+ await rec.file(`/etc/systemd/system/${CAPTURE_UNIT}`, captureUnit(nft));
726
+ await rec.file(NFTABLES_DROPIN, nftablesDropin(nft));
727
+ rec.onUndo("remove the capture rules", [["systemctl", "disable", "--now", CAPTURE_UNIT]]);
728
+ rec.onUndo("remove the capture rules", [[nft, "-f", `${DIR}/remove.nft`]]);
729
+ rec.onUndo("stop redsocks", [["systemctl", "disable", "--now", REDSOCKS_UNIT]]);
730
+ await sys.run(["systemctl", "daemon-reload"]);
731
+ for (const unit of [REDSOCKS_UNIT, CAPTURE_UNIT]) {
732
+ const r = await sys.run(["systemctl", "enable", "--now", unit]);
733
+ if (r.code !== 0) throw new Error(`could not start ${unit}: ${r.stderr.trim()}`);
734
+ }
735
+ await sys.run(["sleep", "2"]);
736
+ const rs = await sys.run(["systemctl", "show", "-p", "ActiveState,NRestarts", REDSOCKS_UNIT]);
737
+ const prop = Object.fromEntries(rs.stdout.split("\n").map((l) => l.trim().split("=", 2)));
738
+ if (prop.ActiveState !== "active" || prop.NRestarts !== "0") {
739
+ throw new Error(`${REDSOCKS_UNIT} does not stay up (journalctl -u ${REDSOCKS_UNIT})`);
740
+ }
741
+ ctx.log(`Capture rules loaded for ${user.name} (uid ${user.uid})`);
742
+ if (facts.resolved) {
743
+ rec.onUndo("restart systemd-resolved", [["systemctl", "restart", "systemd-resolved"]]);
744
+ await rec.file(RESOLVED_DROPIN, resolvedDropin(dns));
745
+ await sys.run(["systemctl", "restart", "systemd-resolved"]);
746
+ notes.push(`This machine's DNS now goes to the firewall (${dns}). If the firewall stops, DNS stops for every program here, not only OpenClaw.`);
747
+ }
748
+ await installSystemStore(sys, rec, ctx.caPem);
749
+ const envNote = await installNodeEnv(sys, rec, ctx.install, user, ctx.caPath);
750
+ if (envNote) notes.push(envNote);
751
+ const nss = await installNss(sys, rec, user, ctx.caPath);
752
+ if (nss) ctx.log(`CA added to ${nss} Chrome certificate store(s)`);
753
+ ctx.log("Restarting OpenClaw");
754
+ const restarted = await restartOpenClaw(sys, ctx.install, user);
755
+ if (restarted) notes.push(restarted);
756
+ return notes;
757
+ },
758
+ async status(sys, record) {
759
+ const checks = [];
760
+ const table = await sys.run(["nft", "list", "table", "inet", TABLE]);
761
+ checks.push({
762
+ name: "capture rules",
763
+ ok: table.code === 0,
764
+ detail: table.code === 0 ? `loaded for uid ${record.user?.uid}` : `not loaded: OpenClaw's traffic is not captured (sudo systemctl restart ${CAPTURE_UNIT})`
765
+ });
766
+ for (const unit of [REDSOCKS_UNIT, CAPTURE_UNIT]) {
767
+ const r = await sys.run(["systemctl", "is-active", unit]);
768
+ checks.push({ name: unit, ok: r.stdout.trim() === "active", detail: r.stdout.trim() || "unknown" });
769
+ }
770
+ if (record.firewall.dns) {
771
+ const d = await dnsAnswers(record.firewall.dns);
772
+ if (!d.ok) d.detail += ": DNS on this whole machine depends on it";
773
+ checks.push(d);
774
+ }
775
+ if (record.user) checks.push(await requestAs(sys, record.user));
776
+ return checks;
777
+ }
778
+ };
779
+
780
+ // src/capture/docker.ts
781
+ var DIR2 = "/etc/controlclaw/docker";
782
+ var OVERRIDE = `${DIR2}/compose.controlclaw.yml`;
783
+ var BUNDLE = `${DIR2}/ca-bundle.crt`;
784
+ var SUBNET = "172.31.250.0/24";
785
+ var GATE_IP = "172.31.250.2";
786
+ var GATEWAY_IP = "172.31.250.10";
787
+ var GATE_SERVICE = "controlclaw-gate";
788
+ var GATE_IMAGE = "alpine/socat:1.8.0.3";
789
+ var CONTAINER_CA = "/etc/controlclaw/ca.crt";
790
+ var PROBE = `fetch("https://example.com/").then(r => console.log("HTTP " + r.status), e => console.log("failed: " + (e.cause?.code ?? e.message)))`;
791
+ var SOCKET = /(docker|containerd|podman|crio|cri-dockerd)\.sock$|^\/run\/(docker|containerd|podman)\b|^\/var\/run\/(docker|containerd|podman)\b/;
792
+ var SOCKET_DIRS = ["/", "/run", "/var/run", "/var", "/run/user", "/var/run/user"];
793
+ var USER_RUNTIME = /^\/(var\/)?run\/user\/\d+(\/|$)/;
794
+ var NET_CAPS = ["ALL", "NET_ADMIN", "NET_RAW", "SYS_ADMIN"];
795
+ function escapesOf(c) {
796
+ const out = [];
797
+ for (const m of c.Mounts ?? []) {
798
+ const src = (m.Source ?? "").replace(/\/+$/, "") || "/";
799
+ if (SOCKET.test(src) || SOCKET.test(m.Destination ?? "") || SOCKET_DIRS.includes(src) || USER_RUNTIME.test(src)) {
800
+ out.push(`it mounts ${m.Source} (a container runtime can start containers that are not on the internal network)`);
801
+ }
802
+ }
803
+ if (c.HostConfig?.Privileged) out.push("it is privileged");
804
+ const caps = (c.HostConfig?.CapAdd ?? []).map((x) => x.toUpperCase().replace(/^CAP_/, "")).filter((x) => NET_CAPS.includes(x));
805
+ if (caps.length) out.push(`it has ${caps.join(", ")}`);
806
+ const net = c.HostConfig?.NetworkMode ?? "";
807
+ if (net === "host" || net.startsWith("container:")) out.push(`its network mode is ${net}`);
808
+ return out;
809
+ }
810
+ function parseInspect(raw) {
811
+ let c;
812
+ try {
813
+ c = JSON.parse(raw)[0];
814
+ } catch {
815
+ return null;
816
+ }
817
+ const l = c?.Config?.Labels ?? {};
818
+ const project = l["com.docker.compose.project"];
819
+ const workingDir = l["com.docker.compose.project.working_dir"];
820
+ const files = l["com.docker.compose.project.config_files"];
821
+ const service = l["com.docker.compose.service"];
822
+ if (!project || !workingDir || !files || !service) return null;
823
+ const ports = [];
824
+ for (const [key, binds] of Object.entries(c.HostConfig?.PortBindings ?? {})) {
825
+ const [port, proto] = key.split("/");
826
+ if (proto !== "tcp") continue;
827
+ for (const b of binds ?? []) {
828
+ if (!b.HostPort) continue;
829
+ ports.push({ host: b.HostIp ? `${b.HostIp}:${b.HostPort}` : b.HostPort, container: Number(port) });
830
+ }
831
+ }
832
+ return { project, workingDir, files: files.split(","), service, ports, escapes: escapesOf(c) };
833
+ }
834
+ function isLoopback(host2) {
835
+ return host2 === "localhost" || /^127\./.test(host2) || host2 === "::1";
836
+ }
837
+ function q(s) {
838
+ return JSON.stringify(s);
839
+ }
840
+ function gateScript(proxy2, dns, info) {
841
+ const relays = [`socat TCP-LISTEN:8080,fork,reuseaddr TCP:${proxy2.host}:${proxy2.port}`];
842
+ if (dns) {
843
+ relays.push(`socat UDP-LISTEN:53,fork,reuseaddr UDP:${dns}:53`, `socat TCP-LISTEN:53,fork,reuseaddr TCP:${dns}:53`);
844
+ }
845
+ for (const port of GATE_PORTS) relays.push(`socat TCP-LISTEN:${port},fork,reuseaddr TCP:${proxy2.host}:${port}`);
846
+ for (const p of info.ports) relays.push(`socat TCP-LISTEN:${p.container},fork,reuseaddr TCP:${GATEWAY_IP}:${p.container}`);
847
+ return `${relays.map((r) => `${r} &`).join(" ")} wait`;
848
+ }
849
+ function proxyEnv(proxyUrl, gate) {
850
+ const direct = ["localhost", "127.0.0.1", "::1", ...gate ? [gate] : []].join(",");
851
+ return [
852
+ ["OPENCLAW_PROXY_URL", proxyUrl],
853
+ ["HTTP_PROXY", proxyUrl],
854
+ ["HTTPS_PROXY", proxyUrl],
855
+ ["http_proxy", proxyUrl],
856
+ ["https_proxy", proxyUrl],
857
+ ["NO_PROXY", direct],
858
+ ["no_proxy", direct],
859
+ ["NODE_USE_ENV_PROXY", "1"],
860
+ ["NODE_EXTRA_CA_CERTS", CONTAINER_CA],
861
+ ["SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt"]
862
+ ];
863
+ }
864
+ function overrideYaml(info, proxy2, dns) {
865
+ const env = proxyEnv(`http://${GATE_IP}:8080`, GATE_IP);
866
+ const clash = info.ports.find((p) => p.container === 8080 || p.container === 53 || GATE_PORTS.includes(p.container));
867
+ if (clash) throw new Error(`the gateway publishes port ${clash.container}, which the gate uses for the firewall`);
868
+ const loop = [proxy2.host, dns ?? ""].find(isLoopback);
869
+ if (loop) {
870
+ throw new Error(`${loop} is the gate itself, not this machine: a firewall here must listen on the docker0 address (172.17.0.1), and --proxy and --dns name it`);
871
+ }
872
+ return [
873
+ "# Written by controlclaw connect; removed by controlclaw disconnect.",
874
+ "# OpenClaw's gateway on an internal network; the gate is its only way out.",
875
+ "services:",
876
+ ` ${info.service}:`,
877
+ " ports: !reset []",
878
+ " networks: !override",
879
+ " controlclaw:",
880
+ ` ipv4_address: ${GATEWAY_IP}`,
881
+ ` dns: [${q(GATE_IP)}]`,
882
+ " environment:",
883
+ ...env.map(([k, v]) => ` ${k}: ${q(v)}`),
884
+ " volumes:",
885
+ ` - ${q(`${CONTAINER_CA}:${CONTAINER_CA}:ro`)}`,
886
+ ` - ${q(`${BUNDLE}:/etc/ssl/certs/ca-certificates.crt:ro`)}`,
887
+ ` ${GATE_SERVICE}:`,
888
+ ` image: ${GATE_IMAGE}`,
889
+ " restart: unless-stopped",
890
+ ` entrypoint: ["/bin/sh", "-c"]`,
891
+ ` command: [${q(gateScript(proxy2, dns, info))}]`,
892
+ ...info.ports.length ? [" ports:", ...info.ports.map((p) => ` - ${q(`${p.host}:${p.container}`)}`)] : [],
893
+ " networks:",
894
+ " default: {}",
895
+ " controlclaw:",
896
+ ` ipv4_address: ${GATE_IP}`,
897
+ "networks:",
898
+ " controlclaw:",
899
+ " internal: true",
900
+ ...ISOLATED_GATEWAY,
901
+ " ipam:",
902
+ " config:",
903
+ ` - subnet: ${SUBNET}`,
904
+ ""
905
+ ].join("\n");
906
+ }
907
+ function composeFileValue(info) {
908
+ const rel = (f) => f.startsWith(`${info.workingDir}/`) ? f.slice(info.workingDir.length + 1) : f;
909
+ return [...info.files.map(rel), OVERRIDE].join(":");
910
+ }
911
+ function composeArgv(info, withOverride) {
912
+ const files = withOverride ? [...info.files, OVERRIDE] : info.files;
913
+ return ["docker", "compose", "-p", info.project, "--project-directory", info.workingDir, ...files.flatMap((f) => ["-f", f])];
914
+ }
915
+ async function subnetTaken(sys, ours, subnet = SUBNET) {
916
+ const ids = (await sys.run(["docker", "network", "ls", "-q"])).stdout.split("\n").filter(Boolean);
917
+ if (!ids.length) return null;
918
+ const r = await sys.run(["docker", "network", "inspect", ...ids]);
919
+ try {
920
+ const nets = JSON.parse(r.stdout);
921
+ return nets.find((n) => n.Name !== ours && (n.IPAM?.Config ?? []).some((c) => c.Subnet === subnet))?.Name ?? null;
922
+ } catch {
923
+ return null;
924
+ }
925
+ }
926
+ var ISOLATED_GATEWAY = [
927
+ // No IPv6 either: a daemon that turns it on for new networks would give the host an IPv6 address there.
928
+ " enable_ipv6: false",
929
+ " driver_opts:",
930
+ " com.docker.network.bridge.gateway_mode_ipv4: isolated"
931
+ ];
932
+ var DOCKER_MIN_MAJOR = 28;
933
+ function dockerMajor(version) {
934
+ return Number(/^(\d+)\./.exec(version ?? "")?.[1] ?? 0);
935
+ }
936
+ function dockerTooOld(version) {
937
+ if (dockerMajor(version) >= DOCKER_MIN_MAJOR) return null;
938
+ return `needs Docker ${DOCKER_MIN_MAJOR} or newer (found ${version || "none"}): an older one gives the host an address on the internal network, where its services would be a way out`;
939
+ }
940
+ async function gatewayIsolated(sys, network) {
941
+ const r = await sys.run(["docker", "network", "inspect", "-f", '{{index .Options "com.docker.network.bridge.gateway_mode_ipv4"}}', network]);
942
+ if (r.code !== 0) return { name: "host unreachable from it", ok: false, detail: `no network ${network} (not running?)` };
943
+ const mode = r.stdout.trim();
944
+ return {
945
+ name: "host unreachable from it",
946
+ ok: mode === "isolated",
947
+ detail: mode === "isolated" ? `${network} gives the host no address` : `${network} gives the host an address: its services on 0.0.0.0 are reachable (disconnect, then connect again)`
948
+ };
949
+ }
950
+ var docker = {
951
+ name: "docker",
952
+ protects: "strong: the gateway container has no route out; its only way to the internet is the firewall",
953
+ blockers(f) {
954
+ const out = [];
955
+ if (!f.root) out.push("needs root (run it with sudo)");
956
+ if (!f.docker?.ok) out.push("needs Docker");
957
+ else if (!f.docker.compose || !composeAtLeast(f.docker.compose, 2, 24, 4)) out.push("needs Docker Compose 2.24.4 or newer");
958
+ const old = f.docker?.ok ? dockerTooOld(f.docker.version) : null;
959
+ if (old) out.push(old);
960
+ if (f.openclaw.install.runs.kind !== "docker") out.push("OpenClaw does not run in a Docker container here");
961
+ return out;
962
+ },
963
+ async install(ctx) {
964
+ const { sys, rec } = ctx;
965
+ if (ctx.install.runs.kind !== "docker") throw new Error("OpenClaw does not run in Docker");
966
+ const container = ctx.install.runs.container;
967
+ const info = parseInspect((await sys.run(["docker", "inspect", "--", container])).stdout);
968
+ if (!info) throw new Error(`${container} was not started with Docker Compose: docker mode needs its compose file`);
969
+ if (info.escapes.length) {
970
+ throw new Error(`${container} could leave the internal network: ${info.escapes.join("; ")}. Remove that from its compose file first`);
971
+ }
972
+ const others = (await sys.run(["docker", "ps", "--filter", `label=com.docker.compose.project=${info.project}`, "--format", "{{.Names}}"])).stdout.split("\n").filter((n) => n && n !== container);
973
+ if (others.length) {
974
+ throw new Error(`docker mode takes the gateway off its network, and these services of ${info.project} run with it: ${others.join(", ")}`);
975
+ }
976
+ const taken = await subnetTaken(sys, `${info.project}_controlclaw`);
977
+ if (taken) throw new Error(`the Docker network ${taken} already uses ${SUBNET}`);
978
+ const proxy2 = splitHostPort(ctx.firewall.proxy, 8080);
979
+ const bundle = await sys.run(["docker", "exec", container, "cat", "/etc/ssl/certs/ca-certificates.crt"]);
980
+ if (bundle.code !== 0 || !bundle.stdout.includes("BEGIN CERTIFICATE")) {
981
+ throw new Error(`could not read the CA bundle in ${container}: is it running?`);
982
+ }
983
+ await rec.file(BUNDLE, `${bundle.stdout.trimEnd()}
984
+ ${ctx.caPem.trim()}
985
+ `);
986
+ await rec.file(OVERRIDE, overrideYaml(info, proxy2, ctx.firewall.dns));
987
+ rec.onUndo("put the gateway back on its own network", [
988
+ [...composeArgv(info, true), "rm", "-s", "-f", GATE_SERVICE],
989
+ [...composeArgv(info, false), "up", "-d", info.service],
990
+ ["docker", "network", "rm", `${info.project}_controlclaw`]
991
+ ]);
992
+ ctx.log(`Moving ${container} onto the internal network`);
993
+ const up2 = await sys.run([...composeArgv(info, true), "up", "-d", info.service, GATE_SERVICE], { timeoutMs: 6e5 });
994
+ if (up2.code !== 0) throw new Error(`docker compose up failed: ${up2.stderr.trim().split("\n").slice(-3).join(" ")}`);
995
+ const notes = [`OpenClaw's container now reaches the internet only through the firewall. Its ports are published by ${GATE_SERVICE}.`];
996
+ const env = join4(info.workingDir, ".env");
997
+ const before = sys.read(env);
998
+ if (before !== null && /^\s*(export\s+)?COMPOSE_(FILE|PATH_SEPARATOR)\s*=/m.test(before)) {
999
+ notes.push(`Your ${env} sets COMPOSE_FILE or COMPOSE_PATH_SEPARATOR, so it is left as it is: add ${OVERRIDE} to COMPOSE_FILE. Otherwise the next "docker compose up" there puts the gateway back on its open network (controlclaw status shows it).`);
1000
+ } else {
1001
+ const line = `COMPOSE_FILE=${composeFileValue(info)}`;
1002
+ await rec.file(env, `${before === null ? "" : `${before.trimEnd()}
1003
+ `}# Added by controlclaw connect; removed by controlclaw disconnect.
1004
+ ${line}
1005
+ `);
1006
+ notes.push(`"docker compose up" in ${info.workingDir} keeps the capture: ${env} now names the override in COMPOSE_FILE. With -f, add -f ${OVERRIDE}; without it the gateway goes back on its open network (controlclaw status shows it).`);
1007
+ }
1008
+ return notes;
1009
+ },
1010
+ async status(sys, record) {
1011
+ const checks = [];
1012
+ const c = record.container ?? "";
1013
+ const nets = await sys.run(["docker", "inspect", "-f", "{{json .NetworkSettings.Networks}}", "--", c]);
1014
+ let names = [];
1015
+ try {
1016
+ names = Object.keys(JSON.parse(nets.stdout));
1017
+ } catch {
1018
+ }
1019
+ const only = names.length === 1 && names[0].endsWith("_controlclaw");
1020
+ checks.push({ name: "gateway network", ok: only, detail: names.length ? `${c} is on ${names.join(", ")}` : `${c} is not running` });
1021
+ if (only) checks.push(await gatewayIsolated(sys, names[0]));
1022
+ const gate = await sys.run(["docker", "ps", "-q", "--filter", `label=com.docker.compose.service=${GATE_SERVICE}`]);
1023
+ checks.push({ name: GATE_SERVICE, ok: gate.stdout.trim() !== "", detail: gate.stdout.trim() ? "running" : "not running" });
1024
+ const probe = await sys.run(["docker", "exec", c, "node", "-e", PROBE], { timeoutMs: 3e4 });
1025
+ const answer = probe.stdout.trim();
1026
+ checks.push({ name: "request from the gateway", ok: /^HTTP \d+/.test(answer), detail: answer || "no answer" });
1027
+ if (record.firewall.dns && isIPv42(record.firewall.dns)) checks.push(await dnsAnswers(record.firewall.dns));
1028
+ return checks;
1029
+ }
1030
+ };
1031
+ function composeAtLeast(version, major, minor, patch) {
1032
+ const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(version);
1033
+ if (!m) return false;
1034
+ const have = [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)];
1035
+ const want = [major, minor, patch];
1036
+ for (let i = 0; i < 3; i++) if (have[i] !== want[i]) return have[i] > want[i];
1037
+ return true;
1038
+ }
1039
+
1040
+ // src/capture/proxy.ts
1041
+ function proxyPatch(proxy2) {
1042
+ return { proxy: { enabled: true, proxyUrl: `http://${proxy2}`, loopbackMode: "gateway-only" } };
1043
+ }
1044
+ function currentProxy(path, raw) {
1045
+ if (!path || raw === null) throw new Error(`cannot read OpenClaw's config file${path ? ` (${path})` : ""}`);
1046
+ let cfg;
1047
+ try {
1048
+ cfg = JSON.parse(raw);
1049
+ } catch {
1050
+ throw new Error(`${path} is not plain JSON, so its proxy setting cannot be saved: set proxy.proxyUrl by hand`);
1051
+ }
1052
+ return cfg.proxy;
1053
+ }
1054
+ var proxy = {
1055
+ name: "proxy",
1056
+ protects: "observability, plus enforcement for OpenClaw's own requests; shell commands and other programs go around it",
1057
+ blockers(f) {
1058
+ const runs = f.openclaw.install.runs.kind;
1059
+ if (runs === "not-found") return ["no OpenClaw found on this machine"];
1060
+ if (runs === "docker") return ["OpenClaw runs in Docker: use --mode docker"];
1061
+ return [];
1062
+ },
1063
+ async install(ctx) {
1064
+ const { sys, rec, user, install } = ctx;
1065
+ const notes = [];
1066
+ const run2 = (args, input) => sys.run(cliArgv(install, args), { user: user ?? void 0, input, timeoutMs: 12e4 });
1067
+ const existing = currentProxy(install.config_file, install.config_file ? sys.read(install.config_file) : null);
1068
+ if (existing && typeof existing === "object" && Object.keys(existing).length) {
1069
+ throw new Error("OpenClaw already has a proxy setting. Proxy mode would replace it: remove it first, or use host mode");
1070
+ }
1071
+ const ours = proxyPatch(ctx.firewall.proxy);
1072
+ rec.onUndo("remove OpenClaw's proxy setting", [cliArgv(install, ["config", "patch", "--stdin"])], {
1073
+ user: user ?? void 0,
1074
+ input: JSON.stringify({ proxy: null })
1075
+ });
1076
+ const patched = await run2(["config", "patch", "--stdin"], JSON.stringify(ours));
1077
+ if (patched.code !== 0) throw new Error(`openclaw config patch failed: ${(patched.stderr || patched.stdout).trim()}`);
1078
+ ctx.log(`OpenClaw's proxy set to http://${ctx.firewall.proxy}`);
1079
+ if (sys.euid === 0) await installSystemStore(sys, rec, ctx.caPem);
1080
+ if (install.runs.kind === "systemd-system" && sys.euid !== 0) {
1081
+ notes.push(`OpenClaw runs as a system unit: run connect with sudo, or add NODE_EXTRA_CA_CERTS=${ctx.caPath} to its unit yourself.`);
1082
+ } else {
1083
+ const envNote = await installNodeEnv(sys, rec, install, user, ctx.caPath);
1084
+ if (envNote) notes.push(envNote);
1085
+ }
1086
+ ctx.log("Restarting OpenClaw");
1087
+ const restarted = await restartOpenClaw(sys, install, user);
1088
+ if (restarted) notes.push(restarted);
1089
+ notes.push("Only OpenClaw's own requests go through the firewall. Shell commands and other programs on this machine do not.");
1090
+ return notes;
1091
+ },
1092
+ async status(sys, record) {
1093
+ const r = await sys.run([...record.cli, "proxy", "validate", "--json"], {
1094
+ user: record.user ?? void 0,
1095
+ env: { NODE_EXTRA_CA_CERTS: record.ca },
1096
+ timeoutMs: 9e4
1097
+ });
1098
+ return validateChecks(r.stdout, `http://${record.firewall.proxy}`);
1099
+ }
1100
+ };
1101
+ function validateChecks(stdout, want) {
1102
+ let v = {};
1103
+ try {
1104
+ v = jsonFrom(stdout);
1105
+ } catch {
1106
+ return [{ name: "OpenClaw proxy setting", ok: false, detail: "openclaw proxy validate gave no answer" }];
1107
+ }
1108
+ const url = v.config?.proxyUrl?.replace(/\/$/, "");
1109
+ const setting = {
1110
+ name: "OpenClaw proxy setting",
1111
+ ok: v.config?.enabled === true && url === want,
1112
+ detail: url ? `${v.config?.enabled ? "on" : "off"}, ${url}` : "not set"
1113
+ };
1114
+ const allowed = v.checks?.find((c) => c.kind === "allowed");
1115
+ const request = {
1116
+ name: "request through OpenClaw's proxy",
1117
+ ok: typeof allowed?.status === "number",
1118
+ detail: allowed ? `${allowed.url} ${allowed.status ?? allowed.error ?? "failed"}` : "not checked"
1119
+ };
1120
+ return [setting, request];
1121
+ }
1122
+
1123
+ // src/capture/remote.ts
1124
+ var WG_DIR = "/etc/wireguard";
1125
+ var WG_UNIT = `wg-quick@${WG_IFACE}.service`;
1126
+ var HANDSHAKE_TRIES = 15;
1127
+ function wgConf(t) {
1128
+ return `# Written by controlclaw connect; removed by controlclaw disconnect.
1129
+ [Interface]
1130
+ Address = ${t.address}/32
1131
+ PostUp = wg set %i private-key ${WG_DIR}/${WG_IFACE}.key
1132
+
1133
+ [Peer]
1134
+ PublicKey = ${t.firewallPublicKey}
1135
+ Endpoint = ${t.endpoint}
1136
+ # Only the firewall's tunnel address: the machine's other traffic keeps its own routes.
1137
+ AllowedIPs = ${t.firewall}/32
1138
+ PersistentKeepalive = 25
1139
+ `;
1140
+ }
1141
+ var remote = {
1142
+ name: "remote",
1143
+ protects: "like host mode on this machine (anyone with root or sudo can remove the rules); its traffic reaches the firewall over WireGuard from any network, and its tunnel address is its identity",
1144
+ blockers(f) {
1145
+ const out = host.blockers(f).map((b) => b.replace("use --mode docker", "use --mode docker with a firewall it can reach"));
1146
+ if (!f.wireguard && !f.apt) out.push("needs WireGuard (wireguard-tools), and apt-get is not here to install it");
1147
+ return out;
1148
+ },
1149
+ // The tunnel first: the vm-agent listens on its address, and the checks after install go through it.
1150
+ async prepare(ctx) {
1151
+ const { sys, rec, facts } = ctx;
1152
+ const t = ctx.tunnel;
1153
+ if (!t) throw new Error("remote mode needs a tunnel: pair with a code (not --ca)");
1154
+ if (!facts.wireguard) {
1155
+ ctx.log("Installing WireGuard");
1156
+ await aptInstall(sys, rec, "wireguard-tools");
1157
+ }
1158
+ await rec.file(`${WG_DIR}/${WG_IFACE}.key`, `${t.privateKey}
1159
+ `, { mode: 384 });
1160
+ await rec.file(`${WG_DIR}/${WG_IFACE}.conf`, wgConf(t), { mode: 384 });
1161
+ rec.onUndo("take down the tunnel", [["systemctl", "disable", "--now", WG_UNIT]]);
1162
+ const up2 = await sys.run(["systemctl", "enable", "--now", WG_UNIT]);
1163
+ if (up2.code !== 0) throw new Error(`could not bring up ${WG_IFACE}: ${up2.stderr.trim()} (journalctl -u ${WG_UNIT})`);
1164
+ for (let i = 0; i < HANDSHAKE_TRIES; i++) {
1165
+ await sys.run(["ping", "-c", "1", "-W", "1", t.firewall]);
1166
+ const h = await sys.run(["wg", "show", WG_IFACE, "latest-handshakes"]);
1167
+ if (Number(h.stdout.trim().split(/\s+/)[1] ?? 0) > 0) {
1168
+ ctx.log(`Tunnel ${WG_IFACE} up: ${t.address} to the firewall at ${t.firewall} (${t.endpoint})`);
1169
+ return;
1170
+ }
1171
+ await sys.run(["sleep", "2"]);
1172
+ }
1173
+ throw new Error(`the firewall does not answer on WireGuard at ${t.endpoint} (is UDP ${t.endpoint.split(":").pop()} open to this machine?)`);
1174
+ },
1175
+ install(ctx) {
1176
+ return host.install(ctx);
1177
+ },
1178
+ async status(sys, record) {
1179
+ const checks = [await tunnelCheck(sys)];
1180
+ return [...checks, ...await host.status(sys, record)];
1181
+ }
1182
+ };
1183
+ async function tunnelCheck(sys) {
1184
+ const r = await sys.run(["wg", "show", WG_IFACE, "latest-handshakes"]);
1185
+ if (r.code !== 0) return { name: "tunnel", ok: false, detail: `${WG_IFACE} is down: OpenClaw reaches nothing (sudo systemctl restart ${WG_UNIT})` };
1186
+ const at = Number(r.stdout.trim().split(/\s+/)[1] ?? 0);
1187
+ const ago = Math.round(Date.now() / 1e3 - at);
1188
+ if (!at) return { name: "tunnel", ok: false, detail: `${WG_IFACE} is up, but the firewall never answered (UDP to its endpoint blocked?)` };
1189
+ return { name: "tunnel", ok: ago < 180, detail: `${WG_IFACE} up, last handshake ${ago}s ago` };
1190
+ }
1191
+
1192
+ // src/local.ts
1193
+ import { randomBytes as randomBytes2 } from "crypto";
1194
+ import { join as join7 } from "path";
1195
+
1196
+ // src/release.ts
1197
+ import { createHash, createPublicKey, verify } from "crypto";
1198
+ import { chmodSync as chmodSync2, mkdtempSync, writeFileSync as writeFileSync2 } from "fs";
1199
+ import { tmpdir } from "os";
1200
+ import { dirname as dirname2, join as join5 } from "path";
1201
+ var RELEASE_URL = "https://github.com/madarco/controlclaw-oss/releases/download/v{version}";
1202
+ var RELEASE_PUBKEY = "RWQM3PqrimL9ko1EkO9cSOv1QVcfUCHBSrscQ29a8+Tflcrksl2f5d5h";
1203
+ var ED25519_SPKI = Buffer.from("302a300506032b6570032100", "hex");
1204
+ function verifyMinisign(pubkey, data, minisig) {
1205
+ const pk = Buffer.from(pubkey.trim(), "base64");
1206
+ if (pk.length !== 42 || pk.subarray(0, 2).toString() !== "Ed") throw new Error("not a minisign public key");
1207
+ const lines = minisig.split("\n");
1208
+ const sig = Buffer.from(lines[1] ?? "", "base64");
1209
+ if (sig.length !== 74 || sig.subarray(0, 2).toString() !== "ED") throw new Error("not a prehashed minisign signature");
1210
+ if (!sig.subarray(2, 10).equals(pk.subarray(2, 10))) throw new Error("signed with another key");
1211
+ const key = createPublicKey({ key: Buffer.concat([ED25519_SPKI, pk.subarray(10)]), format: "der", type: "spki" });
1212
+ const s = sig.subarray(10);
1213
+ if (!verify(null, createHash("blake2b512").update(data).digest(), key, s)) throw new Error("the file does not match its signature");
1214
+ const tc = lines[2] ?? "";
1215
+ if (!tc.startsWith("trusted comment: ")) throw new Error("no trusted comment");
1216
+ const global = Buffer.from(lines[3] ?? "", "base64");
1217
+ if (!verify(null, Buffer.concat([s, Buffer.from(tc.slice("trusted comment: ".length))]), key, global)) {
1218
+ throw new Error("the trusted comment does not match its signature");
1219
+ }
1220
+ return tc.slice("trusted comment: ".length);
1221
+ }
1222
+ async function releaseVmAgent(o = {}) {
1223
+ const version = o.version ?? (true ? "0.9.4" : "0.0.0-dev");
1224
+ const pubkey = o.pubkey ?? RELEASE_PUBKEY;
1225
+ const pattern = o.url ?? process.env.CONTROLCLAW_RELEASE_URL ?? RELEASE_URL;
1226
+ if (!pubkey || !pattern) return null;
1227
+ const arch = { x64: "x86_64", arm64: "aarch64" }[o.arch ?? process.arch];
1228
+ if (!arch) throw new Error(`The release has no vm-agent for ${o.arch ?? process.arch}.`);
1229
+ const base = pattern.replaceAll("{version}", version).replace(/\/$/, "");
1230
+ const get = async (name) => {
1231
+ const url = `${base}/${name}`;
1232
+ try {
1233
+ const res = await (o.fetch ?? fetch)(url, { signal: AbortSignal.timeout(12e4) });
1234
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1235
+ return Buffer.from(await res.arrayBuffer());
1236
+ } catch (e) {
1237
+ throw new Error(`Could not download ${url} (${e.message}).`, { cause: e });
1238
+ }
1239
+ };
1240
+ const sums = await get("SHA256SUMS");
1241
+ let comment;
1242
+ try {
1243
+ comment = verifyMinisign(pubkey, sums, (await get("SHA256SUMS.minisig")).toString());
1244
+ } catch (e) {
1245
+ throw new Error(`The release's SHA256SUMS is not signed by the release key: ${e.message.replace(/\.$/, "")}.`, { cause: e });
1246
+ }
1247
+ if (comment !== `controlclaw ${version}`) throw new Error(`The release's signature is for another version (${comment}).`);
1248
+ const asset = `vm-agent-${arch}-linux-musl`;
1249
+ const want = sums.toString().split("\n").find((l) => l.endsWith(` ${asset}`))?.slice(0, 64);
1250
+ if (!want) throw new Error(`The release's SHA256SUMS has no ${asset}.`);
1251
+ const bin = await get(asset);
1252
+ if (createHash("sha256").update(bin).digest("hex") !== want) throw new Error(`${asset} does not match the release's SHA256SUMS.`);
1253
+ const path = join5(mkdtempSync(join5(tmpdir(), "controlclaw-vm-agent-")), "vm-agent");
1254
+ writeFileSync2(path, bin);
1255
+ chmodSync2(path, 493);
1256
+ return path;
1257
+ }
1258
+ async function vmAgentOrRelease(found, say) {
1259
+ if (found) return { path: found, downloaded: null };
1260
+ let path;
1261
+ try {
1262
+ path = await releaseVmAgent();
1263
+ } catch (e) {
1264
+ say.err(`${e.message} Pass --vm-agent <path>.`);
1265
+ return null;
1266
+ }
1267
+ if (!path) {
1268
+ say.err("No vm-agent binary found. Pass --vm-agent <path> (this build has no release to download it from).");
1269
+ return null;
1270
+ }
1271
+ say.out(` Downloaded and checked the vm-agent of this release: ${path}`);
1272
+ return { path, downloaded: dirname2(path) };
1273
+ }
1274
+
1275
+ // src/pair.ts
1276
+ import { createHash as createHash2, createHmac, generateKeyPairSync, randomBytes, timingSafeEqual, X509Certificate } from "crypto";
1277
+ import { createSocket } from "dgram";
1278
+ import { networkInterfaces } from "os";
1279
+ import { isIPv4 as isIPv43 } from "net";
1280
+ var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1281
+ var REQUEST_TAG = "cc-pair-v1 request\n";
1282
+ var RESPONSE_TAG = "cc-pair-v1 response\n";
1283
+ function normalizeCode(code) {
1284
+ const s = code.replace(/[-\s]/g, "").toUpperCase().replace(/O/g, "0").replace(/[IL]/g, "1");
1285
+ return s.length === 12 && [...s].every((c) => ALPHABET.includes(c)) ? s : null;
1286
+ }
1287
+ function hmacHex(code, tag, text) {
1288
+ return createHmac("sha256", code).update(tag).update(text).digest("hex");
1289
+ }
1290
+ function wgKeypair() {
1291
+ const { privateKey, publicKey } = generateKeyPairSync("x25519", {
1292
+ privateKeyEncoding: { type: "pkcs8", format: "der" },
1293
+ publicKeyEncoding: { type: "spki", format: "der" }
1294
+ });
1295
+ return { privateKey: privateKey.subarray(-32).toString("base64"), publicKey: publicKey.subarray(-32).toString("base64") };
1296
+ }
1297
+ function agentKeypair() {
1298
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519", {
1299
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
1300
+ publicKeyEncoding: { type: "spki", format: "pem" }
1301
+ });
1302
+ return { privatePem: privateKey, publicPem: publicKey };
1303
+ }
1304
+ var httpPost = async (url, body) => {
1305
+ const r = await fetch(url, {
1306
+ method: "POST",
1307
+ headers: { "content-type": "application/json" },
1308
+ body: JSON.stringify(body),
1309
+ signal: AbortSignal.timeout(15e3)
1310
+ });
1311
+ return { status: r.status, json: await r.json().catch(() => null) };
1312
+ };
1313
+ function pairUrl(firewall) {
1314
+ const base = new URL(firewall);
1315
+ if (!base.pathname.endsWith("/")) base.pathname += "/";
1316
+ return new URL("pair", base).toString();
1317
+ }
1318
+ async function pair(firewall, code, req, post = httpPost) {
1319
+ const key = normalizeCode(code);
1320
+ if (!key) throw new Error("the code is 12 letters and digits, like 4F7K-92QD-XM3P");
1321
+ const nonce = randomBytes(32).toString("hex");
1322
+ if (!req.source) throw new Error("this machine's own address toward the firewall is not known");
1323
+ const unpairSha = req.unpairSecret ? createHash2("sha256").update(req.unpairSecret).digest("hex") : null;
1324
+ const text = JSON.stringify({ v: 1, nonce, agent_public_key: req.publicKey, name: req.name ?? null, source: req.source, unpair_sha256: unpairSha, wg_public_key: req.wgPublicKey ?? null, agent_listen: req.agentListen ?? null });
1325
+ const url = pairUrl(firewall);
1326
+ let r;
1327
+ try {
1328
+ r = await post(url, { request: text, proof: hmacHex(key, REQUEST_TAG, text) });
1329
+ } catch (e) {
1330
+ throw new Error(`could not reach ${url}: ${e.message}`, { cause: e });
1331
+ }
1332
+ const body = r.json ?? {};
1333
+ if (r.status !== 200) throw new Error(`the firewall refused: ${typeof body.error === "string" ? body.error : `HTTP ${r.status}`}`);
1334
+ if (typeof body.pairing !== "string" || typeof body.mac !== "string") throw new Error("the firewall's answer is not a pairing");
1335
+ const want = Buffer.from(hmacHex(key, RESPONSE_TAG, body.pairing), "hex");
1336
+ const got = Buffer.from(body.mac, "hex");
1337
+ if (got.length !== want.length || !timingSafeEqual(got, want)) {
1338
+ throw new Error("the answer was not made with this code: it did not come from your firewall. Nothing was installed.");
1339
+ }
1340
+ const p = JSON.parse(body.pairing);
1341
+ if (p.nonce !== nonce) throw new Error("the answer is not for this request (another nonce). Nothing was installed.");
1342
+ const str = (k) => typeof p[k] === "string" ? p[k] : null;
1343
+ const agentId = str("agent_id");
1344
+ const fwKey = str("firewall_public_key");
1345
+ const caPem = str("ca_pem");
1346
+ if (!agentId || !fwKey?.includes("BEGIN PUBLIC KEY") || !caPem) throw new Error("the firewall's answer misses its key, its CA or the agent id");
1347
+ return {
1348
+ agentId,
1349
+ name: str("name") ?? agentId,
1350
+ address: str("address") ?? "",
1351
+ firewallPublicKey: fwKey.trim(),
1352
+ caPem,
1353
+ proxy: str("proxy"),
1354
+ dns: str("dns"),
1355
+ replaced: Array.isArray(p.replaced) ? p.replaced.filter((x) => typeof x === "string") : [],
1356
+ wg: tunnelOf(p.wg),
1357
+ // Only an IPv4 address goes into the vm-agent's config; anything else is dropped (then the
1358
+ // address connect dialled is used).
1359
+ callsFrom: isIPv43(str("calls_from") ?? "") ? str("calls_from") : null
1360
+ };
1361
+ }
1362
+ var WG_KEY = /^[A-Za-z0-9+/]{43}=$/;
1363
+ var IPV4 = /^(\d{1,3}\.){3}\d{1,3}$/;
1364
+ function tunnelOf(v) {
1365
+ if (!v || typeof v !== "object") return null;
1366
+ const w = v;
1367
+ const ok = typeof w.firewall_public_key === "string" && WG_KEY.test(w.firewall_public_key) && typeof w.endpoint === "string" && /^[A-Za-z0-9.-]+:\d{1,5}$/.test(w.endpoint) && typeof w.address === "string" && IPV4.test(w.address) && typeof w.firewall_address === "string" && IPV4.test(w.firewall_address);
1368
+ if (!ok) throw new Error("the firewall's WireGuard settings are not valid");
1369
+ return { firewallPublicKey: w.firewall_public_key, endpoint: w.endpoint, address: w.address, firewallAddress: w.firewall_address };
1370
+ }
1371
+ async function unpair(firewall, agentId, secret, post = httpPost) {
1372
+ const url = new URL("unpair", pairUrl(firewall)).toString();
1373
+ try {
1374
+ const r = await post(url, { agent_id: agentId, secret });
1375
+ if (r.status === 200) return null;
1376
+ const e = r.json?.error;
1377
+ return typeof e === "string" ? e : `HTTP ${r.status}`;
1378
+ } catch (e) {
1379
+ return `could not reach ${url}: ${e.message}`;
1380
+ }
1381
+ }
1382
+ function sourceToward(ip) {
1383
+ return new Promise((resolve, reject) => {
1384
+ const s = createSocket("udp4");
1385
+ s.on("error", (e) => {
1386
+ s.close();
1387
+ reject(e);
1388
+ });
1389
+ s.connect(9, ip, () => {
1390
+ const { address } = s.address();
1391
+ s.close();
1392
+ resolve(address);
1393
+ });
1394
+ });
1395
+ }
1396
+ function onThisMachine(ip, source, ifaces = networkInterfaces()) {
1397
+ const all = Object.entries(ifaces).flatMap(([name, list]) => (list ?? []).filter((a) => a.family === "IPv4").map((a) => ({ name, address: a.address })));
1398
+ if (all.some((a) => a.address === ip)) return true;
1399
+ const via = all.find((a) => a.address === source);
1400
+ return !!via && /^(docker\d*|br-[0-9a-f]+)$/.test(via.name);
1401
+ }
1402
+ function caFingerprint(pem) {
1403
+ try {
1404
+ return new X509Certificate(pem).fingerprint256;
1405
+ } catch {
1406
+ return "(not a certificate)";
1407
+ }
1408
+ }
1409
+
1410
+ // src/state.ts
1411
+ import { dirname as dirname3, join as join6 } from "path";
1412
+ function stateDir(sys) {
1413
+ if (sys.euid === 0) return "/etc/controlclaw";
1414
+ const base = sys.env.XDG_CONFIG_HOME || join6(sys.env.HOME ?? "/tmp", ".config");
1415
+ return join6(base, "controlclaw");
1416
+ }
1417
+ function recordPath(sys) {
1418
+ return join6(stateDir(sys), "connect.json");
1419
+ }
1420
+ var ROOT_RECORD = "/etc/controlclaw/connect.json";
1421
+ function loadRecord(sys) {
1422
+ const raw = sys.read(recordPath(sys));
1423
+ if (!raw) return null;
1424
+ try {
1425
+ const r = JSON.parse(raw);
1426
+ return r.version === 1 ? r : null;
1427
+ } catch {
1428
+ return null;
1429
+ }
1430
+ }
1431
+ function pathOwner(sys, path) {
1432
+ const parts = path.split("/").filter(Boolean);
1433
+ for (let i = 1; i <= parts.length; i++) {
1434
+ const p = `/${parts.slice(0, i).join("/")}`;
1435
+ const l = sys.lstat(p);
1436
+ if (!l) return 0;
1437
+ const last = i === parts.length;
1438
+ if (l.uid !== 0 && (!last || l.link)) return l.uid;
1439
+ if (!last && !l.link && l.mode & 18) throw new SharedFolder(p, l.mode);
1440
+ }
1441
+ return 0;
1442
+ }
1443
+ var SharedFolder = class extends Error {
1444
+ constructor(dir, mode) {
1445
+ super(`${dir} can be written by other users (mode ${mode.toString(8)}): connect does not write through it. Remove their write access (chmod go-w ${dir}), then connect again`);
1446
+ this.dir = dir;
1447
+ }
1448
+ };
1449
+ var Recorder = class {
1450
+ constructor(sys, record) {
1451
+ this.sys = sys;
1452
+ this.record = record;
1453
+ }
1454
+ save() {
1455
+ this.sys.write(recordPath(this.sys), `${JSON.stringify(this.record, null, 2)}
1456
+ `, { mode: 384 });
1457
+ }
1458
+ /**
1459
+ * Writes `path`, remembering what was there (content, mode, owner). A second write to the same
1460
+ * path keeps the first. A file that was there keeps its mode and owner unless `opts` says else.
1461
+ *
1462
+ * As root, a path that a user owns a part of (its home, a project folder) is written as that
1463
+ * user: root following a link the user planted there would write wherever it points.
1464
+ */
1465
+ async file(path, data, opts = {}) {
1466
+ const uid = this.sys.euid === 0 ? pathOwner(this.sys, path) : 0;
1467
+ if (uid !== 0) {
1468
+ const user = userByUid(this.sys, uid);
1469
+ if (!user) throw new Error(`${path} is owned by uid ${uid}, which has no user here: not written`);
1470
+ if (!this.record.steps.some((s) => s.kind === "file" && s.path === path)) {
1471
+ const dirs = [];
1472
+ for (let d = dirname3(path); d !== "/" && !this.sys.exists(d); d = dirname3(d)) dirs.push(d);
1473
+ const previous = await this.sys.readAs(user, path);
1474
+ this.record.steps.push({ kind: "file", path, previous, user, ...dirs.length ? { dirs } : {} });
1475
+ this.save();
1476
+ }
1477
+ await this.sys.writeAs(user, path, data, opts.mode);
1478
+ return;
1479
+ }
1480
+ const was = this.sys.stat(path) ?? void 0;
1481
+ if (!this.record.steps.some((s) => s.kind === "file" && s.path === path)) {
1482
+ const dirs = [];
1483
+ for (let d = dirname3(path); d !== "/" && !this.sys.exists(d); d = dirname3(d)) dirs.push(d);
1484
+ const previous = this.sys.read(path);
1485
+ this.record.steps.push({ kind: "file", path, previous, ...previous !== null && was ? { was } : {}, ...dirs.length ? { dirs } : {} });
1486
+ this.save();
1487
+ }
1488
+ this.sys.write(path, data, { mode: opts.mode ?? was?.mode, owner: opts.owner ?? was });
1489
+ }
1490
+ /** Records how to undo what is about to be done. */
1491
+ onUndo(what, argv, opts = {}) {
1492
+ this.record.steps.push({ kind: "run", what, argv, ...opts });
1493
+ this.save();
1494
+ }
1495
+ start() {
1496
+ this.save();
1497
+ }
1498
+ };
1499
+ async function undoAll(sys, record, log) {
1500
+ const left = [];
1501
+ for (const step of [...record.steps].reverse()) {
1502
+ if (step.kind === "file") {
1503
+ try {
1504
+ if (step.user) {
1505
+ if (step.previous === null) await sys.removeAs(step.user, step.path, step.dirs ?? []);
1506
+ else await sys.writeAs(step.user, step.path, step.previous);
1507
+ continue;
1508
+ }
1509
+ if (step.previous === null) sys.remove(step.path);
1510
+ else sys.write(step.path, step.previous, { mode: step.was?.mode, owner: step.was });
1511
+ for (const d of step.dirs ?? []) sys.removeEmptyDir(d);
1512
+ } catch (e) {
1513
+ left.unshift(step);
1514
+ log(` could not put back ${step.path}: ${e.message}`);
1515
+ }
1516
+ continue;
1517
+ }
1518
+ const failedArgv = [];
1519
+ for (const argv of step.argv) {
1520
+ const r = await sys.run(argv, { user: step.user, input: step.input });
1521
+ if (r.code !== 0) {
1522
+ failedArgv.push(argv);
1523
+ log(` could not ${step.what}: ${(r.stderr || r.stdout).trim().split("\n").pop() ?? ""}`);
1524
+ }
1525
+ }
1526
+ if (failedArgv.length) left.unshift({ ...step, argv: failedArgv });
1527
+ }
1528
+ if (left.length) {
1529
+ sys.write(recordPath(sys), `${JSON.stringify({ ...record, steps: left }, null, 2)}
1530
+ `, { mode: 384 });
1531
+ } else {
1532
+ sys.remove(recordPath(sys));
1533
+ sys.removeEmptyDir(stateDir(sys));
1534
+ }
1535
+ return left.length;
1536
+ }
1537
+
1538
+ // src/local.ts
1539
+ var PROJECT = "controlclaw-local";
1540
+ var DEFAULT_DIR = "/opt/controlclaw-local";
1541
+ var DEFAULT_OPENCLAW_IMAGE = "ghcr.io/openclaw/openclaw:2026.10.9";
1542
+ var AGENTS_SUBNET = "172.31.251.0/24";
1543
+ var OUTSIDE_SUBNET = "172.31.252.0/24";
1544
+ var FW_AGENTS_IP = "172.31.251.2";
1545
+ var OPENCLAW_IP = "172.31.251.10";
1546
+ var FW_OUTSIDE_IP = "172.31.252.2";
1547
+ var HOST_OUTSIDE_IP = "172.31.252.1";
1548
+ var GATEWAY_PORT = 18789;
1549
+ var AGENT_LISTEN = `${HOST_OUTSIDE_IP}:${AGENT_PORT}`;
1550
+ var OPENCLAW_CONTAINER = `${PROJECT}-openclaw-1`;
1551
+ var NODE_UID = 1e3;
1552
+ var GATE_ADDR = "127.0.0.1";
1553
+ var GATE_RELAY = "/etc/controlclaw/gate-relay.mjs";
1554
+ function gateRelayScript() {
1555
+ return [
1556
+ "// Written by controlclaw up --local: the firewall's gates on OpenClaw's loopback.",
1557
+ 'import net from "node:net";',
1558
+ "const say = (m) => process.stderr.write(`[controlclaw gate-relay] ${m}\\n`);",
1559
+ 'process.on("uncaughtException", (e) => say(`error: ${e?.message ?? e}`));',
1560
+ `for (const port of ${JSON.stringify(GATE_PORTS)}) {`,
1561
+ " const server = net.createServer((c) => {",
1562
+ ` const u = net.connect(port, ${q(FW_AGENTS_IP)});`,
1563
+ " const end = () => { c.destroy(); u.destroy(); };",
1564
+ ' u.on("error", (e) => { say(`${port}: the firewall: ${e.code ?? e.message}`); end(); });',
1565
+ ' c.on("error", end); c.on("close", end); u.on("close", end);',
1566
+ " c.pipe(u).pipe(c);",
1567
+ " });",
1568
+ ` const listen = () => server.listen(port, ${q(GATE_ADDR)});`,
1569
+ ' server.on("error", (e) => { say(`${port}: cannot listen (${e.code ?? e.message}); trying again in 5 s`); setTimeout(listen, 5000); });',
1570
+ ' server.on("listening", () => say(`${port}: listening`));',
1571
+ " listen();",
1572
+ "}",
1573
+ ""
1574
+ ].join("\n");
1575
+ }
1576
+ function localCompose(o) {
1577
+ const proxy2 = `http://${FW_AGENTS_IP}:8080`;
1578
+ const oc = (k, v) => ` ${k}: ${q(v)}`;
1579
+ return [
1580
+ "# Written by controlclaw up --local; removed by controlclaw disconnect (the data stays).",
1581
+ `name: ${PROJECT}`,
1582
+ "services:",
1583
+ " firewall:",
1584
+ ` image: ${q(o.firewallImage)}`,
1585
+ " restart: unless-stopped",
1586
+ " environment:",
1587
+ // A private address: the firewall starts its DNS for the agents.
1588
+ oc("CC_LISTEN", FW_AGENTS_IP),
1589
+ // The panel and pairing on the outside network only: OpenClaw, on the agents network, cannot
1590
+ // reach them. Published on this machine's loopback (below).
1591
+ oc("LOCAL_PANEL_BIND", `${FW_OUTSIDE_IP}:8800`),
1592
+ oc("LOCAL_PANEL_INSECURE", "1"),
1593
+ oc("PAIR_BIND", `${FW_OUTSIDE_IP}:8801`),
1594
+ oc("PAIR_PROXY_ADDR", `${FW_AGENTS_IP}:8080`),
1595
+ oc("PAIR_DNS_ADDR", FW_AGENTS_IP),
1596
+ oc("CC_FORWARD", `${GATEWAY_PORT}=${OPENCLAW_IP}:${GATEWAY_PORT}`),
1597
+ // The relay, the pages and webhooks on the outside network too; the connector gates on the
1598
+ // agents network, where OpenClaw dials them.
1599
+ oc("CC_FORWARD_BIND", FW_OUTSIDE_IP),
1600
+ oc("INGRESS_BIND", FW_OUTSIDE_IP),
1601
+ oc("CONNECTOR_GATE_BIND", FW_AGENTS_IP),
1602
+ " volumes:",
1603
+ " - firewall:/opt/controlclaw",
1604
+ " tmpfs:",
1605
+ " - /run/mitm/config:uid=10001,gid=10001,mode=0700,size=16m",
1606
+ " networks:",
1607
+ " agents:",
1608
+ ` ipv4_address: ${FW_AGENTS_IP}`,
1609
+ " outside:",
1610
+ ` ipv4_address: ${FW_OUTSIDE_IP}`,
1611
+ " ports:",
1612
+ ` - ${q(`127.0.0.1:${GATEWAY_PORT}:${GATEWAY_PORT}`)} # OpenClaw's gateway and Control UI`,
1613
+ ` - ${q("127.0.0.1:8800:8800")} # the firewall's panel`,
1614
+ ` - ${q("127.0.0.1:8801:8801")} # pairing`,
1615
+ ` - ${q("127.0.0.1:8790:8790")} # the firewall's pages and webhooks`,
1616
+ " openclaw:",
1617
+ ` image: ${q(o.openclawImage)}`,
1618
+ " restart: unless-stopped",
1619
+ " init: true",
1620
+ " depends_on: [firewall]",
1621
+ // The gates' relay, then the gateway: both in this container, so a restart restarts both.
1622
+ ` command: ["sh", "-c", ${q(`node ${GATE_RELAY} & exec node dist/index.js gateway --bind lan --port ${GATEWAY_PORT}`)}]`,
1623
+ " env_file: [.env]",
1624
+ " environment:",
1625
+ oc("HOME", "/home/node"),
1626
+ oc("OPENCLAW_HOME", "/home/node"),
1627
+ oc("OPENCLAW_STATE_DIR", "/home/node/.openclaw"),
1628
+ oc("OPENCLAW_CONFIG_PATH", "/home/node/.openclaw/openclaw.json"),
1629
+ oc("OPENCLAW_CONFIG_DIR", "/home/node/.openclaw"),
1630
+ oc("OPENCLAW_WORKSPACE_DIR", "/home/node/.openclaw/workspace"),
1631
+ oc("OPENCLAW_GATEWAY_PORT", String(GATEWAY_PORT)),
1632
+ ...proxyEnv(proxy2, FW_AGENTS_IP).map(([k, v]) => oc(k, v)),
1633
+ " volumes:",
1634
+ ` - ${q(`${join7(o.dir, "openclaw")}:/home/node/.openclaw`)}`,
1635
+ ` - ${q(`${join7(o.dir, "openclaw-secrets")}:/home/node/.config/openclaw`)}`,
1636
+ ` - ${q(`${join7(o.dir, "ca.crt")}:/etc/controlclaw/ca.crt:ro`)}`,
1637
+ ` - ${q(`${join7(o.dir, "gate-relay.mjs")}:${GATE_RELAY}:ro`)}`,
1638
+ ` - ${q(`${join7(o.dir, "ca-bundle.crt")}:/etc/ssl/certs/ca-certificates.crt:ro`)}`,
1639
+ ` dns: [${q(FW_AGENTS_IP)}]`,
1640
+ " cap_drop: [NET_RAW, NET_ADMIN]",
1641
+ ' security_opt: ["no-new-privileges:true"]',
1642
+ " networks:",
1643
+ " agents:",
1644
+ ` ipv4_address: ${OPENCLAW_IP}`,
1645
+ " healthcheck:",
1646
+ ' test: ["CMD", "node", "dist/docker-healthcheck.js"]',
1647
+ " interval: 15s",
1648
+ " start_period: 60s",
1649
+ "networks:",
1650
+ " agents:",
1651
+ " internal: true",
1652
+ ...ISOLATED_GATEWAY,
1653
+ " ipam:",
1654
+ ` config: [{ subnet: ${q(AGENTS_SUBNET)} }]`,
1655
+ " outside:",
1656
+ " ipam:",
1657
+ ` config: [{ subnet: ${q(OUTSIDE_SUBNET)} }]`,
1658
+ "volumes:",
1659
+ " firewall:",
1660
+ ""
1661
+ ].join("\n");
1662
+ }
1663
+ var compose = (dir, ...args) => ["docker", "compose", "-p", PROJECT, "--project-directory", dir, "-f", join7(dir, "compose.yaml"), ...args];
1664
+ async function hasImage(sys, image) {
1665
+ return (await sys.run(["docker", "image", "inspect", "--format", "{{.Id}}", image])).code === 0;
1666
+ }
1667
+ async function waitFor(sys, what, tries, test) {
1668
+ for (let i = 0; i < tries; i++) {
1669
+ if (await test()) return;
1670
+ await sys.run(["sleep", "2"]);
1671
+ }
1672
+ throw new Error(`${what} did not come up`);
1673
+ }
1674
+ async function up(sys, io, o, net = realNet) {
1675
+ const tmp = { dir: null };
1676
+ try {
1677
+ return await upWith(sys, io, o, net, tmp);
1678
+ } finally {
1679
+ if (tmp.dir) sys.remove(tmp.dir);
1680
+ }
1681
+ }
1682
+ async function upWith(sys, io, o, net, tmp) {
1683
+ if (sys.euid !== 0) {
1684
+ io.err("up --local needs root (sudo): it runs Docker and installs the vm-agent for the container.");
1685
+ return 1;
1686
+ }
1687
+ if (!o.firewallImage) {
1688
+ io.err("up --local needs --firewall-image <image>: there is no published image yet. Build one with standalone/docker/build.sh (controlclaw-firewall:dev-amd64), or docker load its tar.");
1689
+ return 2;
1690
+ }
1691
+ const existing = loadRecord(sys);
1692
+ if (existing) {
1693
+ io.err(`Already connected (${existing.mode}). Run controlclaw disconnect first.`);
1694
+ return 1;
1695
+ }
1696
+ const dir = o.dir ?? DEFAULT_DIR;
1697
+ const ocImage = o.openclawImage ?? DEFAULT_OPENCLAW_IMAGE;
1698
+ const v = await sys.run(["docker", "compose", "version", "--short"]);
1699
+ if (v.code !== 0 || !composeAtLeast(v.stdout.trim(), 2, 24, 4)) {
1700
+ io.err(`up --local needs Docker with Compose 2.24.4 or newer (found: ${v.stdout.trim() || "none"}).`);
1701
+ return 1;
1702
+ }
1703
+ const server = await sys.run(["docker", "version", "--format", "{{.Server.Version}}"]);
1704
+ if (server.code !== 0) {
1705
+ io.err(`Docker does not answer: ${server.stderr.trim()}`);
1706
+ return 1;
1707
+ }
1708
+ const old = dockerTooOld(server.stdout.trim());
1709
+ if (old) {
1710
+ io.err(`up --local ${old}.`);
1711
+ return 1;
1712
+ }
1713
+ if (!await hasImage(sys, o.firewallImage)) {
1714
+ io.err(`No image ${o.firewallImage} here: build it (standalone/docker/build.sh) or docker load it first.`);
1715
+ return 1;
1716
+ }
1717
+ for (const [net_, subnet] of [[`${PROJECT}_agents`, AGENTS_SUBNET], [`${PROJECT}_outside`, OUTSIDE_SUBNET]]) {
1718
+ const taken = await subnetTaken(sys, net_, subnet);
1719
+ if (taken) {
1720
+ io.err(`Docker network ${taken} already uses ${subnet}, which up --local needs.`);
1721
+ return 1;
1722
+ }
1723
+ }
1724
+ const agent = await vmAgentOrRelease(await findVmAgent(sys, o.vmAgent), io);
1725
+ if (!agent) return 1;
1726
+ tmp.dir = agent.downloaded;
1727
+ const vmAgent = agent.path;
1728
+ const root = userByUid(sys, 0) ?? { name: "root", uid: 0, gid: 0, home: "/root" };
1729
+ const record = {
1730
+ version: 1,
1731
+ mode: "local",
1732
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1733
+ user: null,
1734
+ firewall: { url: "http://127.0.0.1:8801/", proxy: `${FW_AGENTS_IP}:8080`, dns: FW_AGENTS_IP },
1735
+ container: OPENCLAW_CONTAINER,
1736
+ cli: ["docker", "exec", "-i", OPENCLAW_CONTAINER, "node", "dist/index.js"],
1737
+ ca: join7(dir, "ca.crt"),
1738
+ steps: []
1739
+ };
1740
+ const rec = new Recorder(sys, record);
1741
+ rec.start();
1742
+ const log = (l) => io.out(` ${l}`);
1743
+ let paired = false;
1744
+ try {
1745
+ if (!await hasImage(sys, ocImage)) {
1746
+ io.out(`Pulling ${ocImage}`);
1747
+ const p2 = await sys.run(["docker", "pull", "-q", ocImage], { timeoutMs: 9e5 });
1748
+ if (p2.code !== 0) throw new Error(`docker pull ${ocImage}: ${p2.stderr.trim()}`);
1749
+ }
1750
+ io.out(`Writing ${dir}`);
1751
+ const token = randomBytes2(24).toString("hex");
1752
+ await rec.file(join7(dir, "compose.yaml"), localCompose({ firewallImage: o.firewallImage, openclawImage: ocImage, dir }));
1753
+ await rec.file(join7(dir, ".env"), `# Written by controlclaw up --local.
1754
+ OPENCLAW_GATEWAY_TOKEN=${token}
1755
+ `, { mode: 384 });
1756
+ for (const d of ["openclaw", "openclaw/workspace", "openclaw-secrets"]) {
1757
+ const made2 = await sys.run(["install", "-d", "-m", "0700", "-o", String(NODE_UID), "-g", String(NODE_UID), join7(dir, d)]);
1758
+ if (made2.code !== 0) throw new Error(`could not make ${join7(dir, d)}: ${made2.stderr.trim()}`);
1759
+ }
1760
+ io.out("Starting the firewall");
1761
+ rec.onUndo("stop the containers (their data stays)", [compose(dir, "down")]);
1762
+ const fw = await sys.run(compose(dir, "up", "-d", "firewall"), { timeoutMs: 3e5 });
1763
+ if (fw.code !== 0) throw new Error(`docker compose up firewall: ${fw.stderr.trim()}`);
1764
+ await waitFor(sys, "the firewall", 45, async () => {
1765
+ const r = await sys.run(compose(dir, "exec", "-T", "-u", "controlclaw", "firewall", "sh", "-c", "test -s /opt/controlclaw/state/local.db && test -s /opt/controlclaw/keys/vm_public_key.pem"));
1766
+ return r.code === 0;
1767
+ });
1768
+ io.out("Pairing OpenClaw with it");
1769
+ const name = o.name ?? "openclaw";
1770
+ const made = await sys.run(compose(dir, "exec", "-T", "-u", "controlclaw", "firewall", "mitm-agent", "pair-code", "--name", name, "--address", OPENCLAW_IP, "--replace"));
1771
+ const code = made.stdout.trim().split("\n")[0] ?? "";
1772
+ if (made.code !== 0 || !code) throw new Error(`mitm-agent pair-code: ${made.stderr.trim() || "no code"}`);
1773
+ const keypair = agentKeypair();
1774
+ const unpairSecret = randomBytes2(32).toString("hex");
1775
+ let p = null;
1776
+ for (let i = 0; !p; i++) {
1777
+ try {
1778
+ p = await pair(record.firewall.url, code, { publicKey: keypair.publicPem, name, source: "127.0.0.1", unpairSecret, agentListen: AGENT_LISTEN }, net.post);
1779
+ } catch (e) {
1780
+ if (i >= 15 || !e.message.startsWith("could not reach")) throw e;
1781
+ await sys.run(["sleep", "2"]);
1782
+ }
1783
+ }
1784
+ paired = true;
1785
+ record.firewall.agentId = p.agentId;
1786
+ record.firewall.unpair = unpairSecret;
1787
+ log(`agent ${p.agentId} (${p.name}), known to the firewall by ${p.address}`);
1788
+ log(`the firewall's CA: SHA-256 ${caFingerprint(p.caPem)}`);
1789
+ const bundle = await sys.run(["docker", "run", "--rm", "--network", "none", "--entrypoint", "cat", ocImage, "/etc/ssl/certs/ca-certificates.crt"]);
1790
+ if (bundle.code !== 0) throw new Error(`could not read ${ocImage}'s CA bundle: ${bundle.stderr.trim()}`);
1791
+ await rec.file(join7(dir, "ca.crt"), `${p.caPem.trim()}
1792
+ `);
1793
+ await rec.file(join7(dir, "gate-relay.mjs"), gateRelayScript());
1794
+ await rec.file(join7(dir, "ca-bundle.crt"), `${bundle.stdout.trimEnd()}
1795
+ ${p.caPem.trim()}
1796
+ `);
1797
+ if (!sys.exists(join7(dir, "openclaw/openclaw.json"))) {
1798
+ io.out("Setting up OpenClaw");
1799
+ const onboard = await sys.run(
1800
+ compose(
1801
+ dir,
1802
+ "run",
1803
+ "--rm",
1804
+ "--no-deps",
1805
+ "-T",
1806
+ "--entrypoint",
1807
+ "node",
1808
+ "openclaw",
1809
+ "dist/index.js",
1810
+ "onboard",
1811
+ "--non-interactive",
1812
+ "--accept-risk",
1813
+ "--skip-health",
1814
+ "--mode",
1815
+ "local",
1816
+ "--auth-choice",
1817
+ "skip",
1818
+ "--gateway-auth",
1819
+ "token",
1820
+ "--gateway-token-ref-env",
1821
+ "OPENCLAW_GATEWAY_TOKEN",
1822
+ "--skip-channels",
1823
+ "--no-install-daemon"
1824
+ ),
1825
+ { timeoutMs: 3e5 }
1826
+ );
1827
+ if (onboard.code !== 0) throw new Error(`openclaw onboard: ${(onboard.stderr || onboard.stdout).trim().split("\n").pop()}`);
1828
+ }
1829
+ const origins = { gateway: { controlUi: { allowedOrigins: [`http://127.0.0.1:${GATEWAY_PORT}`, `http://localhost:${GATEWAY_PORT}`] } } };
1830
+ const patched = await sys.run(compose(dir, "run", "--rm", "--no-deps", "-T", "--entrypoint", "node", "openclaw", "dist/index.js", "config", "patch", "--stdin"), {
1831
+ input: JSON.stringify(origins),
1832
+ timeoutMs: 3e5
1833
+ });
1834
+ if (patched.code !== 0) throw new Error(`openclaw config patch: ${(patched.stderr || patched.stdout).trim().split("\n").pop()}`);
1835
+ io.out("Starting OpenClaw");
1836
+ const oc = await sys.run(compose(dir, "up", "-d", "openclaw"), { timeoutMs: 3e5 });
1837
+ if (oc.code !== 0) throw new Error(`docker compose up openclaw: ${oc.stderr.trim()}`);
1838
+ await waitFor(sys, "OpenClaw's gateway", 120, async () => {
1839
+ const r = await sys.run(["docker", "inspect", "-f", "{{.State.Health.Status}}", OPENCLAW_CONTAINER]);
1840
+ return r.stdout.trim() === "healthy";
1841
+ });
1842
+ io.out("Installing the vm-agent");
1843
+ const paths = await installAgent(sys, rec, {
1844
+ binary: vmAgent,
1845
+ user: root,
1846
+ firewallUrl: record.firewall.url,
1847
+ firewallKey: p.firewallPublicKey,
1848
+ container: OPENCLAW_CONTAINER,
1849
+ agentId: p.agentId,
1850
+ keypair,
1851
+ // On the host's side of the `outside` network, where the firewall's container reaches it;
1852
+ // OpenClaw, on the internal network only, cannot.
1853
+ listen: AGENT_LISTEN,
1854
+ allowFrom: [FW_OUTSIDE_IP],
1855
+ gate: GATE_ADDR,
1856
+ after: "docker.service"
1857
+ });
1858
+ log(`${paths.bin}, config ${paths.config}`);
1859
+ io.out("");
1860
+ for (const c of await local.status(sys, record)) io.out(`${c.ok ? "\u2713" : "\u2717"} ${c.name}: ${c.detail}`);
1861
+ const logs = await sys.run(compose(dir, "logs", "--no-color", "firewall"));
1862
+ const setup = /setup token \(POST \/api\/setup\): ([0-9a-f]+)/.exec(logs.stdout)?.[1];
1863
+ io.out("");
1864
+ io.out(`OpenClaw: http://127.0.0.1:${GATEWAY_PORT}/#token=${token}`);
1865
+ io.out(`Firewall: http://127.0.0.1:8800${setup ? ` (first sign-in: the setup token ${setup})` : ""}`);
1866
+ io.out(" From another machine: ssh -L 8800:127.0.0.1:8800 -L 18789:127.0.0.1:18789 <this machine>");
1867
+ if (setup) io.out("! The firewall blocks everything until you add rules in its panel (Internet access): allow your model provider first.");
1868
+ io.out(`Undo it with: controlclaw disconnect (the data in ${dir} and the firewall's volume stay)`);
1869
+ return 0;
1870
+ } catch (e) {
1871
+ io.err(`up --local failed: ${e.message}`);
1872
+ io.err("Undoing what was done");
1873
+ if (paired) await unpair(record.firewall.url, record.firewall.agentId ?? "", record.firewall.unpair ?? "", net.post);
1874
+ await undoAll(sys, record, io.err);
1875
+ return 1;
1876
+ }
1877
+ }
1878
+ var local = {
1879
+ name: "local",
1880
+ protects: "strong: OpenClaw runs in a container whose only way out is the firewall container (controlclaw up --local)",
1881
+ blockers(f) {
1882
+ const out = [];
1883
+ if (!f.root) out.push("needs root (run it with sudo)");
1884
+ if (!f.docker?.ok) out.push("needs Docker");
1885
+ else if (!f.docker.compose || !composeAtLeast(f.docker.compose, 2, 24, 4)) out.push("needs Compose 2.24.4 or newer");
1886
+ out.push("made by controlclaw up --local, not connect");
1887
+ return out;
1888
+ },
1889
+ async install() {
1890
+ throw new Error("use controlclaw up --local");
1891
+ },
1892
+ async status(sys) {
1893
+ const checks = [];
1894
+ for (const svc of ["firewall", "openclaw"]) {
1895
+ const r = await sys.run(["docker", "inspect", "-f", "{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}", `${PROJECT}-${svc}-1`]);
1896
+ const s = r.stdout.trim();
1897
+ checks.push({ name: svc, ok: r.code === 0 && s.startsWith("running"), detail: s || "not running" });
1898
+ }
1899
+ const nets = await sys.run(["docker", "inspect", "-f", "{{json .NetworkSettings.Networks}}", OPENCLAW_CONTAINER]);
1900
+ let names = [];
1901
+ try {
1902
+ names = Object.keys(JSON.parse(nets.stdout));
1903
+ } catch {
1904
+ }
1905
+ checks.push({ name: "OpenClaw's network", ok: names.length === 1 && names[0] === `${PROJECT}_agents`, detail: names.join(", ") || "none" });
1906
+ checks.push(await gatewayIsolated(sys, `${PROJECT}_agents`));
1907
+ const probe = await sys.run(["docker", "exec", OPENCLAW_CONTAINER, "node", "-e", PROBE], { timeoutMs: 3e4 });
1908
+ const answer = probe.stdout.trim();
1909
+ checks.push({ name: "request from OpenClaw", ok: /^HTTP \d+/.test(answer), detail: answer || "no answer" });
1910
+ return checks;
1911
+ }
1912
+ };
1913
+
1914
+ // src/doctor.ts
1915
+ var MODES = [docker, host, proxy, remote, local];
1916
+ async function gatherFacts(sys, opts = {}) {
1917
+ const osRelease = sys.read("/etc/os-release") ?? "";
1918
+ const os = /^PRETTY_NAME="?([^"\n]*)"?/m.exec(osRelease)?.[1] ?? null;
1919
+ const [nft, apt, chrome, certutil, wireguard, vmAgent, user] = await Promise.all([
1920
+ which(sys, "nft").then((p) => p ?? (sys.exists("/usr/sbin/nft") ? "/usr/sbin/nft" : null)),
1921
+ which(sys, "apt-get"),
1922
+ which(sys, "google-chrome", "google-chrome-stable", "chromium", "chromium-browser"),
1923
+ which(sys, "certutil"),
1924
+ which(sys, "wg"),
1925
+ findVmAgent(sys, opts.vmAgent),
1926
+ findUser(sys, opts.user)
1927
+ ]);
1928
+ const [dockerV, composeV, resolved, openclaw] = await Promise.all([
1929
+ sys.run(["docker", "version", "--format", "{{.Server.Version}}"], { timeoutMs: 15e3 }),
1930
+ sys.run(["docker", "compose", "version", "--short"], { timeoutMs: 15e3 }),
1931
+ sys.run(["systemctl", "is-active", "systemd-resolved"], { timeoutMs: 15e3 }),
1932
+ findOpenClaw(sys, user, vmAgent)
1933
+ ]);
1934
+ const hasDocker = dockerV.code !== 127;
1935
+ const id = openclaw.user ? await sys.run(["id", "-nG", openclaw.user.name]) : null;
1936
+ const groups = id?.code === 0 ? id.stdout.split(/\s+/) : [];
1937
+ return {
1938
+ os,
1939
+ linux: process.platform === "linux",
1940
+ systemd: sys.exists("/run/systemd/system"),
1941
+ root: sys.euid === 0,
1942
+ nft,
1943
+ docker: hasDocker ? { ok: dockerV.code === 0, version: dockerV.stdout.trim() || null, compose: composeV.code === 0 ? composeV.stdout.trim() : null } : null,
1944
+ redsocks: sys.exists("/usr/sbin/redsocks"),
1945
+ apt: apt !== null,
1946
+ resolved: resolved.stdout.trim() === "active",
1947
+ nameservers: (sys.read("/etc/resolv.conf") ?? "").split("\n").flatMap((l) => /^nameserver\s+(\S+)/.exec(l)?.[1] ?? []),
1948
+ chrome,
1949
+ certutil,
1950
+ wireguard,
1951
+ vmAgent,
1952
+ openclaw,
1953
+ userSudo: groups.some((g) => ["sudo", "admin", "wheel"].includes(g)),
1954
+ escapeGroups: groups.filter((g) => ESCAPE_GROUPS.includes(g)),
1955
+ groupsUnknown: id !== null && id.code !== 0,
1956
+ resolveUid: userByName(sys, "systemd-resolve")?.uid ?? null
1957
+ };
1958
+ }
1959
+ function verdicts(f) {
1960
+ return MODES.map((m) => {
1961
+ const blockers = m.blockers(f);
1962
+ return { name: m.name, ok: blockers.length === 0, protects: m.protects, blockers };
1963
+ });
1964
+ }
1965
+ function warnings(f) {
1966
+ const out = [];
1967
+ const u = f.openclaw.user;
1968
+ if (u?.uid === 0 && f.openclaw.install.runs.kind !== "docker") {
1969
+ out.push("OpenClaw runs as root. Host mode needs it under its own user; proxy mode works but cannot stop shell commands.");
1970
+ }
1971
+ if (u && u.uid !== 0 && f.userSudo) out.push(`${u.name} can use sudo, so in host mode OpenClaw (or its agent) can remove the rules.`);
1972
+ if (u && u.uid !== 0 && f.escapeGroups.length) out.push(escapeGroupMessage(u, f.escapeGroups));
1973
+ if (f.resolved) {
1974
+ out.push("Host mode points this machine's DNS at the firewall. If the firewall stops, DNS stops for every program here, not only OpenClaw.");
1975
+ }
1976
+ if (!f.vmAgent) out.push("No vm-agent binary found: connect needs one (--vm-agent <path>).");
1977
+ return out;
1978
+ }
1979
+ function runsText(r, user) {
1980
+ const who = user ? `, user ${user}` : "";
1981
+ switch (r.kind) {
1982
+ case "systemd-user":
1983
+ return `systemd user unit ${r.unit}${who}`;
1984
+ case "systemd-system":
1985
+ return `systemd unit ${r.unit}${who}`;
1986
+ case "docker":
1987
+ return `Docker container ${r.container}`;
1988
+ case "pm2":
1989
+ return `pm2 process ${r.name}${who}`;
1990
+ case "process":
1991
+ return `a plain process${r.pid ? ` (pid ${r.pid})` : ""}${who}`;
1992
+ case "not-running":
1993
+ return `installed, not running${who}`;
1994
+ case "not-found":
1995
+ return "not found";
1996
+ }
1997
+ }
1998
+ function renderDoctor(f) {
1999
+ const row = (k, v) => ` ${k.padEnd(11)} ${v}`;
2000
+ const i = f.openclaw.install;
2001
+ const lines = [
2002
+ "This machine",
2003
+ row("OS", `${f.os ?? "unknown"}${f.systemd ? " (systemd)" : ""}`),
2004
+ row("Root", f.root ? "yes" : "no"),
2005
+ row("nftables", f.nft ?? "not found"),
2006
+ row("Docker", f.docker ? f.docker.ok ? `${f.docker.version}, Compose ${f.docker.compose ?? "missing"}` : "installed, but this user cannot use it" : "not found"),
2007
+ row("redsocks", f.redsocks ? "installed" : f.apt ? "not installed (apt-get can install it)" : "not installed"),
2008
+ row("DNS", `${f.resolved ? "systemd-resolved" : "resolv.conf"} (${f.nameservers.join(", ") || "no nameserver"})`),
2009
+ row("Chrome", f.chrome ? `${f.chrome}${f.certutil ? "" : " (no certutil: the CA cannot be added to it)"}` : "not found"),
2010
+ row("WireGuard", f.wireguard ?? "not found"),
2011
+ row("vm-agent", f.vmAgent ?? "not found"),
2012
+ "",
2013
+ "OpenClaw"
2014
+ ];
2015
+ if (i.runs.kind === "not-found") {
2016
+ lines.push(row("Found", "no"));
2017
+ } else {
2018
+ lines.push(
2019
+ row("CLI", `${i.runs.kind === "docker" ? cliArgv(i, []).join(" ") : i.cli ?? "?"}${i.version ? ` (${i.version})` : ""}`),
2020
+ row("Config", i.config_file ?? "unknown"),
2021
+ row("Runs as", runsText(i.runs, f.openclaw.user?.name ?? null)),
2022
+ row("Gateway", `port ${i.gateway_port}`)
2023
+ );
2024
+ if (f.openclaw.via === "light") lines.push(row("", "(a quick look: install the vm-agent for the full one)"));
2025
+ }
2026
+ lines.push("", "Modes");
2027
+ for (const v of verdicts(f)) {
2028
+ lines.push(` ${v.ok ? "\u2713" : "\u2717"} ${v.name.padEnd(7)} ${v.ok ? v.protects : v.blockers.join("; ")}`);
2029
+ }
2030
+ const w = warnings(f);
2031
+ if (w.length) lines.push("", "Warnings", ...w.map((x) => ` ! ${x}`));
2032
+ return lines.join("\n");
2033
+ }
2034
+ function doctorJson(f) {
2035
+ return { facts: f, modes: verdicts(f), warnings: warnings(f) };
2036
+ }
2037
+
2038
+ // src/commands.ts
2039
+ async function doctor(sys, io, opts) {
2040
+ const f = await gatherFacts(sys, opts);
2041
+ io.out(opts.json ? JSON.stringify(doctorJson(f), null, 2) : renderDoctor(f));
2042
+ return 0;
2043
+ }
2044
+ async function readCode(sys, io, o) {
2045
+ if (o.codeFile) {
2046
+ const text = sys.read(o.codeFile);
2047
+ if (text === null) {
2048
+ io.err(`--code-file: cannot read ${o.codeFile}`);
2049
+ return null;
2050
+ }
2051
+ if ((sys.stat(o.codeFile)?.mode ?? 0) & 63) io.out(`! ${o.codeFile} can be read by other users: keep it 0600, and delete it after.`);
2052
+ return text.trim();
2053
+ }
2054
+ if (o.code && o.code !== "-") {
2055
+ io.err("Not on the command line: every user of this machine can read it while connect runs. Leave out --code and type the code when asked, or use --code - (stdin) or --code-file <file>.");
2056
+ return null;
2057
+ }
2058
+ const typed = io.ask ? await io.ask(o.code === "-" ? "" : "Pairing code from the firewall (mitm-agent pair-code): ") : null;
2059
+ if (!typed?.trim()) {
2060
+ io.err("No pairing code: type it when asked, or pass --code - (stdin) or --code-file <file>.");
2061
+ return null;
2062
+ }
2063
+ return typed.trim();
2064
+ }
2065
+ function readKey(sys, v) {
2066
+ if (v.trim().startsWith("-----BEGIN")) return v.trim();
2067
+ const pem = sys.read(v);
2068
+ if (!pem || !pem.includes("-----BEGIN PUBLIC KEY-----")) throw new Error(`--firewall-key: ${v} is not a PEM public key`);
2069
+ return pem.trim();
2070
+ }
2071
+ var realNet = { post: httpPost, source: sourceToward, ipv4: ipv4Of, sameMachine: onThisMachine };
2072
+ async function connect2(sys, io, o, net = realNet) {
2073
+ const tmp = { dir: null };
2074
+ try {
2075
+ return await connectWith(sys, io, o, net, tmp);
2076
+ } finally {
2077
+ if (tmp.dir) sys.remove(tmp.dir);
2078
+ }
2079
+ }
2080
+ async function connectWith(sys, io, o, net, tmp) {
2081
+ const mode = MODES.find((m) => m.name === o.mode);
2082
+ if (!mode) {
2083
+ io.err("--mode must be docker, host, proxy or remote.");
2084
+ return 2;
2085
+ }
2086
+ if (!o.firewall) {
2087
+ io.err("connect needs --firewall <url>: the firewall's pairing address (or, by hand, its URL with --ca and --firewall-key).");
2088
+ return 2;
2089
+ }
2090
+ const pairing = !o.ca;
2091
+ if (mode.name === "remote" && !pairing) {
2092
+ io.err("Remote mode pairs with a code (the firewall gives this machine its tunnel): leave out --ca and --firewall-key.");
2093
+ return 2;
2094
+ }
2095
+ if (mode.name === "remote" && (o.proxy || o.dns)) {
2096
+ io.err("Remote mode uses the firewall's proxy and DNS inside the tunnel: leave out --proxy and --dns.");
2097
+ return 2;
2098
+ }
2099
+ if (pairing && o.firewallKey) {
2100
+ io.err("--firewall-key goes with --ca. Without them, connect pairs with a code, which brings both.");
2101
+ return 2;
2102
+ }
2103
+ if (!pairing && (o.code || o.codeFile)) {
2104
+ io.err("A pairing code brings the firewall's CA and key: leave out --ca and --firewall-key.");
2105
+ return 2;
2106
+ }
2107
+ const existing = loadRecord(sys);
2108
+ if (existing) {
2109
+ io.err(`Already connected (${existing.mode} mode). Run controlclaw disconnect first (--force gives up steps that keep failing).`);
2110
+ return 1;
2111
+ }
2112
+ let url;
2113
+ try {
2114
+ url = new URL(o.firewall);
2115
+ } catch {
2116
+ io.err(`--firewall: not a URL: ${o.firewall}`);
2117
+ return 2;
2118
+ }
2119
+ let caPem = null;
2120
+ let key = null;
2121
+ let code = null;
2122
+ if (pairing) {
2123
+ code = await readCode(sys, io, o);
2124
+ if (code === null) return 2;
2125
+ if (!normalizeCode(code)) {
2126
+ io.err("The pairing code is 12 letters and digits, like 4F7K-92QD-XM3P.");
2127
+ return 2;
2128
+ }
2129
+ } else {
2130
+ caPem = sys.read(o.ca ?? "");
2131
+ const bad = caPem === null ? `cannot read ${o.ca}` : checkPem(caPem);
2132
+ if (bad || !caPem) {
2133
+ io.err(`--ca: ${bad}`);
2134
+ return 2;
2135
+ }
2136
+ try {
2137
+ if (o.firewallKey) key = readKey(sys, o.firewallKey);
2138
+ } catch (e) {
2139
+ io.err(e.message);
2140
+ return 2;
2141
+ }
2142
+ }
2143
+ io.out("Checking this machine");
2144
+ let facts = await gatherFacts(sys, { user: o.user, vmAgent: o.vmAgent });
2145
+ const agent = await vmAgentOrRelease(facts.vmAgent, io);
2146
+ if (!agent) return 1;
2147
+ const binary = agent.path;
2148
+ if (agent.downloaded) {
2149
+ tmp.dir = agent.downloaded;
2150
+ facts = await gatherFacts(sys, { user: o.user, vmAgent: binary });
2151
+ }
2152
+ if (facts.openclaw.discoverError) {
2153
+ io.err(`The vm-agent could not look at this OpenClaw (vm-agent discover: ${facts.openclaw.discoverError}).`);
2154
+ return 1;
2155
+ }
2156
+ const blockers = mode.blockers(facts);
2157
+ if (blockers.length) {
2158
+ io.err(`This machine cannot do ${mode.name} mode:`);
2159
+ for (const b of blockers) io.err(` - ${b}`);
2160
+ return 1;
2161
+ }
2162
+ const install = facts.openclaw.install;
2163
+ const user = facts.openclaw.user;
2164
+ const container = install.runs.kind === "docker" ? install.runs.container : void 0;
2165
+ const agentUser = user ?? userByUid(sys, sys.euid);
2166
+ if (!agentUser) {
2167
+ io.err("Cannot tell which user to run the vm-agent as. Pass --user.");
2168
+ return 1;
2169
+ }
2170
+ let paired = null;
2171
+ let keypair;
2172
+ let wgKeys;
2173
+ const unpairSecret = randomBytes3(32).toString("hex");
2174
+ let reach;
2175
+ let sameHost = true;
2176
+ if (code !== null) {
2177
+ keypair = agentKeypair();
2178
+ wgKeys = mode.name === "remote" ? wgKeypair() : void 0;
2179
+ try {
2180
+ const ip = await net.ipv4(url.hostname);
2181
+ const source = await net.source(ip);
2182
+ const base = new URL(url.toString());
2183
+ if (base.protocol === "http:") base.hostname = ip;
2184
+ io.out(`Pairing with ${pairUrl(base.toString())}, from ${source}`);
2185
+ sameHost = (net.sameMachine ?? onThisMachine)(ip, source);
2186
+ const agentListen = !wgKeys && sameHost ? `${source}:${AGENT_PORT}` : void 0;
2187
+ paired = await pair(base.toString(), code, { publicKey: keypair.publicPem, name: o.name ?? hostname(), source, unpairSecret, wgPublicKey: wgKeys?.publicKey, agentListen }, net.post);
2188
+ reach = paired.wg ? { listen: `${paired.wg.address}:${AGENT_PORT}`, allowFrom: [paired.wg.firewallAddress] } : agentListen ? { listen: agentListen, allowFrom: [paired.callsFrom ?? ip] } : void 0;
2189
+ } catch (e) {
2190
+ io.err(`Pairing failed: ${e.message}`);
2191
+ return 1;
2192
+ }
2193
+ if (wgKeys && !paired.wg) {
2194
+ io.err("Pairing failed: the firewall gave no tunnel (does it run WireGuard? install.sh --wireguard)");
2195
+ return 1;
2196
+ }
2197
+ caPem = paired.caPem;
2198
+ key = paired.firewallPublicKey;
2199
+ io.out(` agent ${paired.agentId} (${paired.name}), known to the firewall by ${paired.address}`);
2200
+ io.out(` the firewall's CA: SHA-256 ${caFingerprint(caPem)}`);
2201
+ if (paired.replaced.length) {
2202
+ io.out(` ! the firewall had ${paired.replaced.join(", ")} at ${paired.address}: this machine replaces it (the proxy tells agents apart by address only)`);
2203
+ }
2204
+ }
2205
+ const badCa = caPem === null ? "missing" : checkPem(caPem);
2206
+ if (badCa || caPem === null) {
2207
+ io.err(`The firewall's CA: ${badCa}`);
2208
+ return 1;
2209
+ }
2210
+ let proxyAddr;
2211
+ let dns = null;
2212
+ try {
2213
+ const p = splitHostPort(o.proxy ?? paired?.proxy ?? url.hostname, 8080);
2214
+ proxyAddr = `${p.host}:${p.port}`;
2215
+ if (mode.name !== "proxy") dns = await ipv4Of(o.dns ?? paired?.dns ?? url.hostname);
2216
+ } catch (e) {
2217
+ io.err(e.message);
2218
+ return 2;
2219
+ }
2220
+ const record = {
2221
+ version: 1,
2222
+ mode: mode.name,
2223
+ at: (/* @__PURE__ */ new Date()).toISOString(),
2224
+ user,
2225
+ firewall: { url: url.toString(), proxy: proxyAddr, dns, ...paired ? { agentId: paired.agentId, unpair: unpairSecret } : {} },
2226
+ container,
2227
+ cli: cliArgv(install, []),
2228
+ ca: mode.name === "docker" ? CONTAINER_CA : join8(stateDir(sys), "ca.crt"),
2229
+ steps: []
2230
+ };
2231
+ const rec = new Recorder(sys, record);
2232
+ rec.start();
2233
+ const log = (l) => io.out(` ${l}`);
2234
+ try {
2235
+ const restart = restartArgv(install);
2236
+ if (restart && mode.name !== "docker") {
2237
+ rec.onUndo("restart OpenClaw", [restart], { user: restartUser(install, user) });
2238
+ }
2239
+ const caPath = record.ca;
2240
+ await rec.file(caPath, `${caPem.trim()}
2241
+ `);
2242
+ const tunnel = paired?.wg && wgKeys ? { address: paired.wg.address, firewall: paired.wg.firewallAddress, firewallPublicKey: paired.wg.firewallPublicKey, endpoint: paired.wg.endpoint, privateKey: wgKeys.privateKey } : void 0;
2243
+ const ctx = { sys, rec, facts, install, user, firewall: record.firewall, caPem, caPath, log, ...tunnel ? { tunnel } : {} };
2244
+ if (mode.prepare) {
2245
+ io.out(`Preparing ${mode.name} mode`);
2246
+ await mode.prepare(ctx);
2247
+ }
2248
+ io.out("Installing the vm-agent");
2249
+ const paths = await installAgent(sys, rec, {
2250
+ binary,
2251
+ user: agentUser,
2252
+ firewallUrl: url.toString(),
2253
+ firewallKey: key,
2254
+ container,
2255
+ agentId: paired?.agentId,
2256
+ keypair,
2257
+ ...reach ?? {},
2258
+ // Where OpenClaw reaches the firewall's gates: the gate container in docker mode, else the
2259
+ // proxy's address (the tunnel's in remote mode). Not in proxy mode: everything OpenClaw sends
2260
+ // goes through the proxy there, which never connects to the firewall itself.
2261
+ ...mode.name === "proxy" ? {} : { gate: mode.name === "docker" ? GATE_IP : await ipv4Of(splitHostPort(proxyAddr, 8080).host) },
2262
+ // Remote: once the tunnel is up.
2263
+ ...paired?.wg ? { after: WG_UNIT } : {}
2264
+ });
2265
+ log(`${paths.bin}, config ${paths.config}`);
2266
+ if (!key) log("No --firewall-key: the agent answers nothing until it has the firewall's key (pair with --code).");
2267
+ io.out(`Setting up ${mode.name} mode`);
2268
+ const notes = await mode.install(ctx);
2269
+ io.out("");
2270
+ for (const c of [...await mode.status(sys, record), await proxyAnswers(proxyAddr)]) io.out(`${c.ok ? "\u2713" : "\u2717"} ${c.name}: ${c.detail}`);
2271
+ for (const n of notes) io.out(`! ${n}`);
2272
+ if (paired && !paired.wg && !sameHost) {
2273
+ io.out("! The firewall is on another machine: it does not call this agent's vm-agent (that would be plain HTTP across the network), so it cannot set bots, models or MCP servers here or stop OpenClaw. Use --mode remote for those.");
2274
+ }
2275
+ io.out("");
2276
+ io.out(`Connected in ${mode.name} mode. Protection: ${mode.protects}.`);
2277
+ io.out("Undo it all with: controlclaw disconnect");
2278
+ return 0;
2279
+ } catch (e) {
2280
+ io.err(`Connect failed: ${e.message}`);
2281
+ io.err("Undoing what was done");
2282
+ await undoAll(sys, record, io.err);
2283
+ if (paired) {
2284
+ await unpairFrom(record.firewall, io, net.post);
2285
+ io.err("The code is spent: make a new one.");
2286
+ }
2287
+ return 1;
2288
+ }
2289
+ }
2290
+ function needsSudo(sys, io, what) {
2291
+ if (sys.euid === 0 || !sys.exists(ROOT_RECORD)) return false;
2292
+ io.err(`This machine was connected with sudo. Run: sudo controlclaw ${what}`);
2293
+ return true;
2294
+ }
2295
+ async function unpairFrom(fw, io, post) {
2296
+ if (!fw.agentId) return;
2297
+ const r = fw.unpair ? await unpair(fw.url, fw.agentId, fw.unpair, post) : "no secret was kept";
2298
+ if (r === null) io.out(` The firewall forgot this machine (${fw.agentId}).`);
2299
+ else io.out(` ! The firewall still lists this machine as ${fw.agentId} (${r}); remove it there if it is not coming back.`);
2300
+ }
2301
+ async function disconnect(sys, io, opts = {}, post = httpPost) {
2302
+ const record = loadRecord(sys);
2303
+ if (!record && needsSudo(sys, io, "disconnect")) return 1;
2304
+ if (!record) {
2305
+ io.out("Not connected.");
2306
+ return 0;
2307
+ }
2308
+ io.out(`Removing ${record.mode} mode, the CA and the vm-agent`);
2309
+ await unpairFrom(record.firewall, io, post);
2310
+ const failed = await undoAll(sys, record, io.err);
2311
+ if (!failed) {
2312
+ io.out("Done. This machine is as it was before connect.");
2313
+ return 0;
2314
+ }
2315
+ if (opts.force) {
2316
+ sys.remove(recordPath(sys));
2317
+ sys.removeEmptyDir(stateDir(sys));
2318
+ io.out(`Done. ${failed} step(s) failed (above) and were given up; check them by hand.`);
2319
+ return 1;
2320
+ }
2321
+ io.out(`Done, with ${failed} step(s) that failed (above). They are kept: run disconnect again once fixed, or disconnect --force to give them up.`);
2322
+ return 1;
2323
+ }
2324
+ async function status(sys, io, opts) {
2325
+ const record = loadRecord(sys);
2326
+ if (!record && needsSudo(sys, io, "status")) return 1;
2327
+ if (!record) {
2328
+ io.out(opts.json ? JSON.stringify({ connected: false }) : "Not connected.");
2329
+ return 0;
2330
+ }
2331
+ const mode = MODES.find((m) => m.name === record.mode);
2332
+ const checks = await mode.status(sys, record);
2333
+ const agentUser = record.user ?? userByUid(sys, sys.euid);
2334
+ if (agentUser) {
2335
+ const p = agentPaths(sys, agentUser);
2336
+ const r = await sys.run([...p.system ? ["systemctl"] : ["systemctl", "--user"], "is-active", UNIT]);
2337
+ checks.push({ name: "vm-agent", ok: r.stdout.trim() === "active", detail: r.stdout.trim() || "unknown" });
2338
+ }
2339
+ if (record.mode !== "local") {
2340
+ checks.push(await proxyAnswers(record.firewall.proxy));
2341
+ if (record.firewall.dns && !checks.some((c) => c.name === "firewall DNS")) checks.push(await dnsAnswers(record.firewall.dns));
2342
+ }
2343
+ const ok = checks.every((c) => c.ok);
2344
+ if (opts.json) {
2345
+ io.out(JSON.stringify({ connected: true, mode: record.mode, since: record.at, firewall: record.firewall, ok, checks }, null, 2));
2346
+ } else {
2347
+ io.out(`Connected in ${record.mode} mode since ${record.at}, firewall ${record.firewall.url}${record.firewall.agentId ? `, as ${record.firewall.agentId}` : ""}`);
2348
+ for (const c of checks) io.out(` ${c.ok ? "\u2713" : "\u2717"} ${c.name}: ${c.detail}`);
2349
+ }
2350
+ return ok ? 0 : 1;
2351
+ }
2352
+
2353
+ // src/cli.ts
2354
+ var VERSION = true ? "0.9.4" : "0.0.0-dev";
2355
+ var USAGE = `controlclaw ${VERSION}
2356
+
2357
+ Connect a machine that runs OpenClaw to a ControlClaw firewall.
2358
+
2359
+ controlclaw doctor [--json]
2360
+ What this machine has, which modes it can do, and how much each protects.
2361
+
2362
+ controlclaw connect --mode docker|host|proxy|remote --firewall <url> [--code - | --code-file <file>]
2363
+ [--name <agent name>] [--proxy <host:port>] [--dns <ip>]
2364
+ [--user <name>] [--vm-agent <path>]
2365
+ Pair with the firewall using a one-time code from it (mitm-agent pair-code), then install
2366
+ the vm-agent, the firewall's CA and the capture for the mode. --firewall is the firewall's
2367
+ pairing address, such as http://fw.example.com:8801. connect asks for the code; --code -
2368
+ reads it from stdin, --code-file from a file. --code <code> is refused: other users can read it.
2369
+
2370
+ controlclaw connect --mode ... --firewall <url> --ca <file> [--firewall-key <pem>] ...
2371
+ The same without pairing: the firewall's CA and key by hand.
2372
+ --proxy defaults to what pairing says, else the firewall's host, port 8080; --dns likewise.
2373
+
2374
+ controlclaw up --local --firewall-image <image> [--openclaw-image <image>] [--dir <folder>]
2375
+ [--name <agent name>] [--vm-agent <path>]
2376
+ The firewall and OpenClaw as two containers on this machine, OpenClaw's only way out through
2377
+ the firewall, paired; prints the addresses of OpenClaw and of the firewall's panel.
2378
+
2379
+ controlclaw disconnect [--force]
2380
+ Remove everything connect installed and put back what it changed. Steps that fail are kept
2381
+ for another try; --force gives them up.
2382
+
2383
+ controlclaw status [--json]
2384
+ Whether the capture, the vm-agent and the firewall are up.
2385
+
2386
+ Docker and host mode need root (sudo). Proxy mode works without it.
2387
+ `;
2388
+ async function run(argv, io, sys = realSys()) {
2389
+ const [command, ...rest] = argv;
2390
+ let values;
2391
+ try {
2392
+ ({ values } = parseArgs({
2393
+ args: rest,
2394
+ strict: true,
2395
+ options: {
2396
+ json: { type: "boolean" },
2397
+ force: { type: "boolean" },
2398
+ mode: { type: "string" },
2399
+ firewall: { type: "string" },
2400
+ ca: { type: "string" },
2401
+ "firewall-key": { type: "string" },
2402
+ proxy: { type: "string" },
2403
+ dns: { type: "string" },
2404
+ user: { type: "string" },
2405
+ "vm-agent": { type: "string" },
2406
+ code: { type: "string" },
2407
+ "code-file": { type: "string" },
2408
+ name: { type: "string" },
2409
+ local: { type: "boolean" },
2410
+ "firewall-image": { type: "string" },
2411
+ "openclaw-image": { type: "string" },
2412
+ dir: { type: "string" }
2413
+ }
2414
+ }));
2415
+ } catch (e) {
2416
+ io.err(e.message);
2417
+ return 2;
2418
+ }
2419
+ const s = (k) => typeof values[k] === "string" ? values[k] : void 0;
2420
+ switch (command) {
2421
+ case "doctor":
2422
+ return doctor(sys, io, { json: values.json === true, user: s("user"), vmAgent: s("vm-agent") });
2423
+ case "connect":
2424
+ return connect2(sys, io, {
2425
+ mode: s("mode"),
2426
+ firewall: s("firewall"),
2427
+ ca: s("ca"),
2428
+ firewallKey: s("firewall-key"),
2429
+ proxy: s("proxy"),
2430
+ dns: s("dns"),
2431
+ user: s("user"),
2432
+ vmAgent: s("vm-agent"),
2433
+ code: s("code"),
2434
+ codeFile: s("code-file"),
2435
+ name: s("name")
2436
+ });
2437
+ case "up":
2438
+ if (values.local !== true) {
2439
+ io.err("up --local is the only shape for now.");
2440
+ return 2;
2441
+ }
2442
+ return up(sys, io, {
2443
+ firewallImage: s("firewall-image"),
2444
+ openclawImage: s("openclaw-image"),
2445
+ dir: s("dir"),
2446
+ name: s("name"),
2447
+ vmAgent: s("vm-agent")
2448
+ });
2449
+ case "disconnect":
2450
+ return disconnect(sys, io, { force: values.force === true });
2451
+ case "status":
2452
+ return status(sys, io, { json: values.json === true });
2453
+ case "--version":
2454
+ case "version":
2455
+ io.out(VERSION);
2456
+ return 0;
2457
+ case void 0:
2458
+ case "help":
2459
+ case "--help":
2460
+ case "-h":
2461
+ io.out(USAGE);
2462
+ return command === void 0 ? 2 : 0;
2463
+ default:
2464
+ io.err(`Unknown command: ${command}
2465
+ `);
2466
+ io.err(USAGE);
2467
+ return 2;
2468
+ }
2469
+ }
2470
+ function terminalIo() {
2471
+ return { out: (l) => console.log(l), err: (l) => console.error(l), ask };
2472
+ }
2473
+ function ask(prompt) {
2474
+ return new Promise((resolve) => {
2475
+ const tty = process.stdin.isTTY === true;
2476
+ const rl = createInterface({ input: process.stdin, output: tty ? process.stderr : void 0, terminal: tty });
2477
+ if (tty) {
2478
+ process.stderr.write(prompt);
2479
+ rl._writeToOutput = () => {
2480
+ };
2481
+ }
2482
+ let done = false;
2483
+ rl.once("line", (line) => {
2484
+ done = true;
2485
+ if (tty) process.stderr.write("\n");
2486
+ rl.close();
2487
+ resolve(line);
2488
+ });
2489
+ rl.once("close", () => {
2490
+ if (!done) resolve(null);
2491
+ });
2492
+ rl.once("SIGINT", () => {
2493
+ if (tty) process.stderr.write("\n");
2494
+ rl.close();
2495
+ process.exit(130);
2496
+ });
2497
+ });
2498
+ }
2499
+
2500
+ // src/index.ts
2501
+ process.exitCode = await run(process.argv.slice(2), terminalIo());