| 1 | #!/usr/bin/env python3 |
| 2 | import glob |
| 3 | import hashlib |
| 4 | import json |
| 5 | import os |
| 6 | from pathlib import Path |
| 7 | import secrets |
| 8 | import shutil |
| 9 | import subprocess |
| 10 | import sys |
| 11 | import urllib.request |
| 12 | import xml.etree.ElementTree as ET |
| 13 | |
| 14 | |
| 15 | data = json.load(sys.stdin) |
| 16 | root = Path(data["hostRoot"]) |
| 17 | if root.name != "openclaw": |
| 18 | raise ValueError("OpenClaw's dedicated VM is production-only") |
| 19 | source = Path(__file__).parent |
| 20 | vm = root / "vm" |
| 21 | vm.mkdir(mode=0o700, exist_ok=True) |
| 22 | cfg = root / "cfg" |
| 23 | cfg.mkdir(mode=0o700, exist_ok=True) |
| 24 | key = json.loads(subprocess.check_output( |
| 25 | ["nomad", "var", "get", "-out=json", "nomad/jobs/local-ai"], |
| 26 | text=True, stderr=subprocess.DEVNULL, |
| 27 | ))["Items"]["api_key"] |
| 28 | if not key.isalnum(): |
| 29 | raise ValueError("Invalid model API credential") |
| 30 | nginx = source.joinpath("nginx.conf").read_text().replace("MODEL_API_KEY", key) |
| 31 | pending = cfg / ".nginx.conf.pending" |
| 32 | pending.write_text(nginx) |
| 33 | pending.chmod(0o600) |
| 34 | os.chown(pending, data["uid"], data["uid"]) |
| 35 | pending.replace(cfg / "nginx.conf") |
| 36 | os.chown(cfg, data["uid"], data["uid"]) |
| 37 | |
| 38 | if not (vm / "ssh_key").exists(): |
| 39 | subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(vm / "ssh_key")], check=True) |
| 40 | |
| 41 | catalog = json.loads(source.parent.joinpath("local-ai/catalog.json").read_text())["models"][0] |
| 42 | config = json.loads(source.joinpath("openclaw.json").read_text()) |
| 43 | # Browser control needs its own password under trusted-proxy auth. |
| 44 | previous = json.loads((vm / "openclaw.json").read_text()) if (vm / "openclaw.json").exists() else {} |
| 45 | config["gateway"]["auth"]["password"] = previous.get("gateway", {}).get("auth", {}).get("password") or secrets.token_hex(24) |
| 46 | model = config["models"]["providers"]["snowglobe"]["models"][0] |
| 47 | model.update(id=catalog["slug"], name=catalog["display_name"], contextWindow=catalog["context_window"]) |
| 48 | config["agents"]["defaults"]["model"]["primary"] = "snowglobe/" + catalog["slug"] |
| 49 | (vm / "openclaw.json").write_text(json.dumps(config, indent=2) + "\n") |
| 50 | |
| 51 | if not (vm / "disk.qcow2").exists(): |
| 52 | image = vm / "ubuntu.img" |
| 53 | expected = "6a81c37564db9b1ee84e141922625e1d7c5b389b99bb3c572e0243607d5bb4d2" |
| 54 | if not image.exists(): |
| 55 | urllib.request.urlretrieve( |
| 56 | "https://cloud-images.ubuntu.com/noble/20260926/noble-server-cloudimg-amd64.img", image, |
| 57 | ) |
| 58 | with image.open("rb") as stream: |
| 59 | if hashlib.file_digest(stream, "sha256").hexdigest() != expected: |
| 60 | raise ValueError("Ubuntu image checksum mismatch") |
| 61 | subprocess.run(["qemu-img", "convert", "-O", "qcow2", str(image), str(vm / ".disk.pending")], check=True) |
| 62 | subprocess.run(["qemu-img", "resize", str(vm / ".disk.pending"), "32G"], check=True) |
| 63 | |
| 64 | writes = [ |
| 65 | {"path": "/etc/apt/apt.conf.d/90proxy", "content": 'Acquire::http::Proxy "http://10.0.2.100:3128";\nAcquire::https::Proxy "http://10.0.2.100:3128";\n'}, |
| 66 | {"path": "/etc/systemd/system/docker.service.d/proxy.conf", "content": "[Service]\nEnvironment=HTTP_PROXY=http://10.0.2.100:3128 HTTPS_PROXY=http://10.0.2.100:3128 NO_PROXY=localhost,127.0.0.1,10.0.2.101\n"}, |
| 67 | {"path": "/opt/openclaw/openclaw.json", "content": json.dumps(config, indent=2), "permissions": "0600"}, |
| 68 | {"path": "/opt/openclaw/Dockerfile", "content": source.joinpath("Dockerfile.guest").read_text()}, |
| 69 | {"path": "/opt/openclaw/resolv.conf", "content": source.joinpath("resolv.conf").read_text()}, |
| 70 | {"path": "/etc/systemd/system/openclaw.service", "content": source.joinpath("openclaw.service").read_text()}, |
| 71 | {"path": "/usr/local/sbin/openclaw-install", "content": source.joinpath("install.sh").read_text(), "permissions": "0755"}, |
| 72 | {"path": "/etc/systemd/system/openclaw-install.service", "content": source.joinpath("openclaw-install.service").read_text()}, |
| 73 | ] |
| 74 | cloud = { |
| 75 | "hostname": "openclaw", "manage_etc_hosts": True, "ssh_pwauth": False, |
| 76 | "disable_root": True, "users": [{"name": "ocadmin", "lock_passwd": True, |
| 77 | "shell": "/bin/bash", "sudo": "ALL=(ALL) NOPASSWD:ALL", |
| 78 | "ssh_authorized_keys": [(vm / "ssh_key.pub").read_text().strip()]}], |
| 79 | "write_files": writes, |
| 80 | "bootcmd": [["systemctl", "mask", "--now", "snapd.service", "snapd.socket", "snapd.seeded.service", "snapd.autoimport.service"]], |
| 81 | "runcmd": [["systemctl", "daemon-reload"], ["systemctl", "enable", "--now", "openclaw-install"]], |
| 82 | } |
| 83 | (vm / "user-data").write_text("#cloud-config\n" + json.dumps(cloud)) |
| 84 | (vm / "meta-data").write_text("instance-id: openclaw-trial\nlocal-hostname: openclaw\n") |
| 85 | iso = shutil.which("genisoimage") or sorted(glob.glob("/nix/store/*-cdrkit-*/bin/genisoimage"))[0] |
| 86 | subprocess.run([iso, "-quiet", "-output", str(vm / "seed.iso"), "-volid", "cidata", "-joliet", "-rock", str(vm / "user-data"), str(vm / "meta-data")], check=True) |
| 87 | (vm / ".disk.pending").replace(vm / "disk.qcow2") |
| 88 | image.unlink() |
| 89 | |
| 90 | qemu = "http://libvirt.org/schemas/domain/qemu/1.0" |
| 91 | units = Path("/etc/systemd/system.control") |
| 92 | units.mkdir(exist_ok=True) |
| 93 | shutil.copyfile(source / "machine-openclaw.slice", units / "machine-openclaw.slice") |
| 94 | dependency = units / "libvirtd.service.d" |
| 95 | dependency.mkdir(exist_ok=True) |
| 96 | (dependency / "openclaw.conf").write_text("[Unit]\nRequires=machine-openclaw.slice\nAfter=machine-openclaw.slice\n") |
| 97 | subprocess.run(["systemctl", "daemon-reload"], check=True) |
| 98 | subprocess.run(["systemctl", "start", "machine-openclaw.slice"], check=True) |
| 99 | ET.register_namespace("qemu", qemu) |
| 100 | domain = ET.fromstring(f"""<domain type="kvm"> |
| 101 | <name>openclaw</name><memory unit="MiB">6144</memory><vcpu>4</vcpu> |
| 102 | <resource><partition>/machine/openclaw</partition></resource> |
| 103 | <os><type arch="x86_64" machine="q35">hvm</type><boot dev="hd"/></os> |
| 104 | <features><acpi/><apic/></features><cpu mode="host-passthrough"/> |
| 105 | <on_poweroff>destroy</on_poweroff><on_reboot>restart</on_reboot><on_crash>restart</on_crash> |
| 106 | <devices> |
| 107 | <disk type="file" device="disk"><driver name="qemu" type="qcow2"/><source file="{vm}/disk.qcow2"/><target dev="vda" bus="virtio"/></disk> |
| 108 | <disk type="file" device="cdrom"><driver name="qemu" type="raw"/><source file="{vm}/seed.iso"/><target dev="sda" bus="sata"/><readonly/></disk> |
| 109 | <serial type="pty"><target port="0"/></serial> |
| 110 | <memballoon model="virtio"/><rng model="virtio"><backend model="random">/dev/urandom</backend></rng> |
| 111 | </devices> |
| 112 | </domain>""") |
| 113 | existing = subprocess.run(["virsh", "domuuid", "openclaw"], capture_output=True, text=True) |
| 114 | if existing.returncode == 0: |
| 115 | ET.SubElement(domain, "uuid").text = existing.stdout.strip() |
| 116 | args = ET.SubElement(domain, "{" + qemu + "}commandline") |
| 117 | capabilities = ET.SubElement(domain, "{" + qemu + "}capabilities") |
| 118 | # Libvirt's spawn/setsid filters block the fixed per-connection proxy commands. |
| 119 | ET.SubElement(capabilities, "{" + qemu + "}del", capability="seccomp-sandbox") |
| 120 | for value in [ |
| 121 | "-sandbox", "on,obsolete=deny,elevateprivileges=allow,spawn=allow,resourcecontrol=deny", |
| 122 | "-netdev", "user,id=ocnet,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:18890-:18789,hostfwd=tcp:127.0.0.1:18892-:22,guestfwd=tcp:10.0.2.100:3128-cmd:/run/current-system/sw/bin/nc 127.0.0.1 18882,guestfwd=tcp:10.0.2.101:8080-cmd:/run/current-system/sw/bin/nc 127.0.0.1 18881,guestfwd=tcp:10.0.2.102:53-cmd:/run/current-system/sw/bin/nc 1.1.1.1 53", |
| 123 | "-device", "virtio-net-pci,netdev=ocnet,bus=pcie.0,addr=0x10", |
| 124 | ]: |
| 125 | ET.SubElement(args, "{" + qemu + "}arg", value=value) |
| 126 | (vm / "domain.xml").write_text(ET.tostring(domain, encoding="unicode")) |
| 127 | subprocess.run(["virsh", "define", str(vm / "domain.xml")], check=True) |
| 128 | subprocess.run(["virsh", "autostart", "openclaw"], check=True) |
| 129 | if subprocess.check_output(["virsh", "domstate", "openclaw"], text=True).strip() != "running": |
| 130 | subprocess.run(["virsh", "start", "openclaw"], check=True) |