diff --git a/web/scripts/build-devbox-freestyle.ts b/web/scripts/build-devbox-freestyle.ts index 430594864622..564ee97edd38 100644 --- a/web/scripts/build-devbox-freestyle.ts +++ b/web/scripts/build-devbox-freestyle.ts @@ -103,6 +103,10 @@ import { CMUX_TUI_STATE_RESERVATION_MARKER_PATH, CMUX_TUI_STATE_RESERVATION_BYTES, DEVBOX_DESKTOP_INSTALLS, + DEVBOX_DNS_PATH, + DEVBOX_DNS_UNITS, + devboxDnsCheckCommand, + devboxDnsInstallCommand, devboxTerminfoInstallCommand, DEVBOX_INSTANCE_ID_COMMAND, bakeMetadata, @@ -633,6 +637,13 @@ try { "cmux-prompt-sync-unit", "python3 -m py_compile /usr/local/bin/cmux-prompt-sync && systemctl daemon-reload && systemctl enable cmux-prompt-sync && systemctl is-enabled cmux-prompt-sync", ); + // The DNS fallback (devbox-image-common.ts, DEVBOX_DNS_UNITS): from here on + // the builder itself resolves through it, so the rest of the bake proves it. + await put("cmux-dns", DEVBOX_DNS_PATH, 0o755); + for (const [target, unit] of Object.entries(DEVBOX_DNS_UNITS)) { + await vm.fs.writeFile(target, unit, { mode: 0o644 }); + } + await step("dns-fallback", `${devboxDnsInstallCommand} && ${devboxDnsCheckCommand}`); // Prove the daemon contract on the builder: the supervisor started the // daemon on its own, the session answers, and the listener is dual-stack. await step( diff --git a/web/scripts/devbox-image-common.ts b/web/scripts/devbox-image-common.ts index 211004e9644d..4b2b61767100 100644 --- a/web/scripts/devbox-image-common.ts +++ b/web/scripts/devbox-image-common.ts @@ -49,6 +49,7 @@ export const DEVBOX_TEMPLATE_FILES = [ "chrome-managed-policy.json", "cmux-bashrc", "cmux-devbox-boot", + "cmux-dns", "cmux-tui-state-mount", "cmux-motd", "cmux-prompt.bash", @@ -521,6 +522,7 @@ export function devboxSourceManifest( cuaDriver: devboxCuaDriverVersion(dockerfile), ghosttyVersion: devboxGhosttyVersion(dockerfile), files, + dns: { units: DEVBOX_DNS_UNITS, install: devboxDnsInstallCommand }, }; if (schema >= 2) { shell.dockerfileInstructions = createHash("sha256").update(normalizedDockerfileInstructions(dockerfile)).digest("hex"); @@ -1625,3 +1627,83 @@ export const DEVBOX_PRE_SNAPSHOT_SETTLE_SECONDS = 10; export function devboxSettleBeforeSnapshotCommand(): string { return `sync && sleep ${DEVBOX_PRE_SNAPSHOT_SETTLE_SECONDS} && sync && echo "settled $(cut -d' ' -f1-3 /proc/loadavg)"`; } + +/** + * The DNS fallback (Freestyle only; services/vms/images/devbox/cmux-dns): a + * stub on 127.0.0.2:53 that asks the platform's resolver first and falls back + * to its last good answer, then DNS-over-HTTPS, when the platform resolver + * answers SERVFAIL or nothing (cmux issue 26640). Freestyle rewrites + * /etc/resolv.conf on every TLS rule change, so a path unit re-points it at + * the stub each time. The container recipe has none: its runtime owns + * resolv.conf. + */ +export const DEVBOX_DNS_PATH = "/usr/local/bin/cmux-dns"; +export const DEVBOX_DNS_UNITS: Readonly> = { + "/etc/systemd/system/cmux-dns.service": [ + "[Unit]", + "Description=cmux DNS fallback stub (127.0.0.2:53)", + "Before=nss-lookup.target", + "Wants=nss-lookup.target", + "", + "[Service]", + "Type=notify", + // The distribution interpreter, not whichever python3 is first on PATH. + `ExecStart=/usr/bin/python3 ${DEVBOX_DNS_PATH} serve`, + "Restart=always", + "RestartSec=1", + "DynamicUser=yes", + "AmbientCapabilities=CAP_NET_BIND_SERVICE", + "CapabilityBoundingSet=CAP_NET_BIND_SERVICE", + "", + "[Install]", + "WantedBy=multi-user.target", + ].join("\n") + "\n", + "/etc/systemd/system/cmux-dns-guard.service": [ + "[Unit]", + "Description=Point resolv.conf at the cmux DNS fallback", + "After=cmux-dns.service", + "", + "[Service]", + "Type=oneshot", + `ExecStart=/usr/bin/python3 ${DEVBOX_DNS_PATH} guard`, + "", + "[Install]", + "WantedBy=multi-user.target", + ].join("\n") + "\n", + "/etc/systemd/system/cmux-dns-guard.path": [ + "[Unit]", + "Description=Re-point resolv.conf at the cmux DNS fallback whenever the platform rewrites it", + "", + "[Path]", + "PathChanged=/etc/resolv.conf", + "Unit=cmux-dns-guard.service", + "", + "[Install]", + "WantedBy=multi-user.target", + ].join("\n") + "\n", +}; + +/** Enables and starts the fallback once its files are in place, then waits until the stub answers. */ +export const devboxDnsInstallCommand = [ + `python3 -m py_compile ${DEVBOX_DNS_PATH}`, + "systemctl daemon-reload", + "systemctl enable --now cmux-dns.service cmux-dns-guard.path", + "systemctl enable cmux-dns-guard.service", + "systemctl start cmux-dns-guard.service", + // The stub itself must answer: libc would hide a dead one behind the + // platform resolvers listed after it. + `/usr/bin/python3 ${DEVBOX_DNS_PATH} probe`, +].join(" && "); + +/** The fallback is installed, answers on its own, and resolv.conf points at it first. */ +export const devboxDnsCheckCommand = [ + `test -x ${DEVBOX_DNS_PATH}`, + `python3 -m py_compile ${DEVBOX_DNS_PATH}`, + "systemctl is-active cmux-dns.service >/dev/null", + "systemctl is-active cmux-dns-guard.path >/dev/null", + "head -1 /etc/resolv.conf | grep -qx '# cmux-dns'", + "[ \"$(awk '$1 == \"nameserver\" { print $2; exit }' /etc/resolv.conf)\" = 127.0.0.2 ]", + `/usr/bin/python3 ${DEVBOX_DNS_PATH} probe github.com`, + "getent ahostsv4 github.com >/dev/null", + "echo dns-fallback-ok", +].join(" && "); diff --git a/web/scripts/verify-devbox-image.ts b/web/scripts/verify-devbox-image.ts index 83864cd523c5..c7cb00c8e08b 100644 --- a/web/scripts/verify-devbox-image.ts +++ b/web/scripts/verify-devbox-image.ts @@ -55,6 +55,7 @@ import { devboxTerminfoCheckCommand, devboxWaitForDaemonCommand, cmuxTuiWebsocketSmokeCommand, + devboxDnsCheckCommand, sha256File, } from "./devbox-image-common"; import { @@ -176,6 +177,7 @@ const DAEMON_CHECKS: readonly string[] = [ `test -s /etc/cmux/model-plane.env && grep -q "^export OPENAI_BASE_URL='https://" /etc/cmux/model-plane.env && ! grep -q crt_ /etc/cmux/model-plane.env && env -i HOME=/tmp/mp-verify bash -c '. /etc/cmux/agent-config.sh; printf %s "$OPENAI_BASE_URL"' | grep -q '^https://' && rm -rf /tmp/mp-verify && echo model-plane-env-baked`, "systemctl is-active cmux-tui-daemon >/dev/null && echo systemd-supervisor-active", "test -x /usr/local/bin/cmux-prompt-sync && python3 -m py_compile /usr/local/bin/cmux-prompt-sync && systemctl is-enabled cmux-prompt-sync >/dev/null && echo prompt-sync-contract-ok", + devboxDnsCheckCommand, cmuxTuiWebsocketSmokeCommand(), ]; diff --git a/web/services/vms/drivers/freestyle.ts b/web/services/vms/drivers/freestyle.ts index e765212d030f..69cc8570ee41 100644 --- a/web/services/vms/drivers/freestyle.ts +++ b/web/services/vms/drivers/freestyle.ts @@ -156,7 +156,8 @@ export type { FreestylePreconnectOptions } from "./freestyleWarmup"; // The coderouter model plane is edge-injected: the create carries an inline // `tls` rule for the coderouter host whose transform overwrites the signed // `x-cmux-authorization` plus compatibility bearer/VM headers on every request -// the guest makes there. The platform steers the host to its edge (/etc/hosts) and +// the guest makes there. The platform steers the host to its edge (its forwarding +// resolver answers it with the edge address; see the devbox README) and // installs its CA at boot; rules added after boot never reach a running // guest, so the rule must be inline. The baked env file holds only base // URLs and placeholder keys: no token is ever written into the guest, and diff --git a/web/services/vms/drivers/freestyleNetworkPolicy.ts b/web/services/vms/drivers/freestyleNetworkPolicy.ts index 430691f773ce..6288b0108fee 100644 --- a/web/services/vms/drivers/freestyleNetworkPolicy.ts +++ b/web/services/vms/drivers/freestyleNetworkPolicy.ts @@ -9,8 +9,11 @@ import { canonicalCidr, type NetworkRulePlan } from "../networkPolicy"; * - A VM with no egress rule reaches nothing, DNS included: the guest's * resolvers are public addresses. * - Firewall and TLS rule changes apply to a running VM within ~0.1 s. - * - A TLS rule with a `public` destination steers its exact name through the - * guest's /etc/hosts to the edge and needs no firewall grant. + * - A TLS rule with a `public` destination steers its exact name to the edge + * and needs no firewall grant. Steering was through the guest's /etc/hosts + * at the spike; by 2026-10-28 the platform instead points resolv.conf at a + * forwarding resolver on the edge address that answers steered names with + * the edge (see the devbox README, "DNS fallback"). * - A TLS rule with a `host` destination (the CodeRouter and reflection * aliases) does NOT carry that grant: the guest needs a firewall path to the * edge address on 443. {@link freestyleEdgeAddresses} supplies it. diff --git a/web/services/vms/images/devbox/README.md b/web/services/vms/images/devbox/README.md index a55e3f06f032..3f776809fcc4 100644 --- a/web/services/vms/images/devbox/README.md +++ b/web/services/vms/images/devbox/README.md @@ -323,6 +323,66 @@ alone. The container recipe has no identity step: `docker build` mounts `/etc/hostname` and `/etc/hosts` from the daemon and the runtime names each container, so the hostname there is the runtime's. +## DNS fallback (`cmux-dns`) + +Freestyle only. A machine with any TLS rule (every cmux machine: the +model-plane and CodeRouter edge rules) resolves through the platform's +forwarding resolver, `10.32.0.28`: the guest agent writes +`/etc/resolv.conf` (`nameserver 10.32.0.28`) and +`/etc/systemd/resolved.conf.d/61-freestyle-tls.conf`, and the platform +answers every port-53 packet the guest sends, whatever address it names +(a query to `8.8.8.8` comes back from the same forwarder). The forwarder +answers the names TLS rules steer with the edge address and forwards the +rest. It intermittently answers SERVFAIL (about 300 ms, then cached for a +second or two) or nothing at all, for every name, on every machine at the +same moment ([#26640](https://github.com/manaflow-ai/cmux/issues/26640)); +measured 2026-10-28, roughly 2% of queries over a few minutes. Pointing +resolv.conf elsewhere does not help, and the agent rewrites it on every TLS +rule change and on resume anyway. + +`cmux-dns serve` (`cmux-dns.service`, a `DynamicUser` with only +`CAP_NET_BIND_SERVICE`) listens on `127.0.0.2:53`, UDP and TCP, and per +query: + +1. asks the platform's resolvers (two tries of 1 s: the first two, IPv4 first, + or a lone one twice), so steered names keep resolving to the edge and + NXDOMAIN stays the platform's; +2. on SERVFAIL, REFUSED or silence, answers with the platform's last good + answer for that exact question, at most a day old. The cache starts over + whenever resolv.conf is written (a TLS rule, a resume, a clone's first + boot), so a stale answer never outlives a steering change; +3. while the provider's `/run/freestyle/tls-dns-servers` marker is nonempty, + returns SERVFAIL on a cache miss. The provider exports resolver IPs but no + steered-name list to the guest, so a public answer could bypass a required + TLS route or credential injection, even with full public egress. This + conservatively covers all names until the platform answers again; +4. when TLS steering is inactive, asks DNS-over-HTTPS at `1.1.1.1` then `8.8.8.8`, addressed by IP + on 443, which the interception does not touch, and accepts only NOERROR: + a public NXDOMAIN for a name only the platform knows comes back as + SERVFAIL, never as a missing name. A machine whose network policy blocks + those addresses gets SERVFAIL from this step, as it would without the stub. + +Each exchange runs under a wall-clock deadline and the whole chain fits in +3.5 s, under the stub's 4 s libc timeout. Replies for another question are +dropped; UDP replies over the client's size (512, or its EDNS size up to +1232) go out truncated so the client retries over TCP. + +`cmux-dns guard` (`cmux-dns-guard.path` on `PathChanged=/etc/resolv.conf`, +plus once at boot) rewrites a resolv.conf the platform wrote to +`nameserver 127.0.0.2` followed by two of the platform's resolvers (IPv4 +first), keeps its `search` line and options other than `timeout`/`attempts` +(set to `timeout:4 attempts:1`; the stub retries itself), and records every +resolver on a `# cmux-dns upstream` line the stub reads. A stopped stub +therefore costs one refused packet before libc asks the platform directly. +Between a platform rewrite and the guard (under a second) the machine +resolves the old way. The bake installs it after the prompt sync; systemd +waits for `READY=1` after both listeners bind, then checks that the stub itself +answers (`cmux-dns probe`; a libc lookup would pass +through the fallback resolvers), every later bake step resolves through it, +and `verify-devbox-image.ts` checks the same contract. The container recipe +has none: Docker owns its resolv.conf. Machines baked before it keep +resolving straight through the platform. + ## Terminal capabilities (`cmux-terminfo.src`) Daemon-spawned shells get the TERM the Mac exports (`xterm-256color`, or diff --git a/web/services/vms/images/devbox/cmux-dns b/web/services/vms/images/devbox/cmux-dns new file mode 100644 index 000000000000..8998fe9f1d3d --- /dev/null +++ b/web/services/vms/images/devbox/cmux-dns @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +"""Keep name resolution working when the platform resolver fails. + +A Freestyle machine with any TLS rule (every cmux machine has them) gets +`nameserver 10.32.0.28`, the platform's forwarding resolver, and the platform +answers every port-53 packet the guest sends, whatever address it names. That +resolver intermittently answers SERVFAIL, or nothing, for every name at once, +on every machine at the same moment (cmux issue 26640), and a changed +resolv.conf does not route around it. Freestyle also rewrites resolv.conf on +every TLS rule change and on resume. + +`serve` is a stub on 127.0.0.2:53 that asks the platform's resolvers first, so +names the platform steers to its edge keep resolving to the edge. When they +fail it answers from the last good platform answer for the question (at most +a day old, and only since the platform last wrote resolv.conf), then over +DNS-over-HTTPS to resolvers addressed by IP on 443 when TLS steering is +inactive. While steering is active, a cache miss fails closed: the guest +cannot distinguish steered names from public ones. It never turns a platform +failure into NXDOMAIN. + +`guard` (run by cmux-dns-guard.path whenever resolv.conf changes) points +resolv.conf at the stub and keeps the platform's resolvers after it, so a +stopped stub costs one refused packet, not resolution. +""" +import collections +import concurrent.futures +import http.client +import os +import socket +import socketserver +import ssl +import struct +import sys +import tempfile +import threading +import time + +LISTEN = "127.0.0.2" +RESOLV_CONF = "/etc/resolv.conf" +TLS_DNS_SERVERS = "/run/freestyle/tls-dns-servers" +MARKER = "# cmux-dns" +UPSTREAM_PREFIX = "# cmux-dns upstream " +# Freestyle's base resolvers (/etc/systemd/resolved.conf.d/60-freestyle-vms.conf). +DEFAULT_UPSTREAMS = ["2606:4700:4700::1111", "2001:4860:4860::8888", "8.8.8.8", "8.8.4.4"] +DOH_ENDPOINTS = [("1.1.1.1", "/dns-query"), ("8.8.8.8", "/dns-query")] +# One query's whole budget stays under resolv.conf's timeout for the stub, so +# libc never gives up on a question the stub is still answering. Two platform +# tries leave DoH at least 1.5 s however many resolvers the platform lists. +QUERY_BUDGET = 3.5 +UPSTREAM_TRIES = 2 +UPSTREAM_TIMEOUT = 1.0 +DOH_TIMEOUT = 1.5 +RESOLV_OPTIONS = "timeout:4 attempts:1" +CACHE_ENTRIES = 8192 +STALE_MAX_AGE = 24 * 3600 +UDP_ANSWER_LIMIT = 1232 +SERVFAIL, REFUSED = 2, 5 +OPT = 41 + + +def question_end(message): + """Offset just past the single question, or None for anything but one well-formed query.""" + if len(message) < 12 or message[2] & 0x80 or struct.unpack("!H", message[4:6])[0] != 1: + return None + offset = 12 + while True: + if offset >= len(message): + return None + length = message[offset] + if length == 0: + offset += 1 + break + if length & 0xC0: + return None + offset += 1 + length + return offset + 4 if offset + 4 <= len(message) else None + + +def question_key(message): + """The question as wire bytes (length-prefixed labels, lowercased), type and class.""" + end = question_end(message) + return None if end is None else message[12:end - 4].lower() + message[end - 4:end] + + +def udp_limit(query): + """The largest UDP reply the client accepts: 512, or its EDNS size up to 1232.""" + end = question_end(query) + if end is None or struct.unpack("!H", query[10:12])[0] == 0: + return 512 + # The OPT record a stub resolver sends: root name, type 41, class = UDP size. + if query[end:end + 3] == b"\x00\x00\x29" and len(query) >= end + 5: + return max(512, min(struct.unpack("!H", query[end + 3:end + 5])[0], UDP_ANSWER_LIMIT)) + return 512 + + +def rcode(message): + return message[3] & 0x0F if len(message) >= 12 else SERVFAIL + + +def with_id(message, query): + return query[:2] + message[2:] + + +def failure(query): + """SERVFAIL for a well-formed `query`: its header and question, with QR and RA set.""" + end = question_end(query) + flags = (query[2] & 0x79) | 0x80, 0x80 | SERVFAIL + return query[:2] + bytes(flags) + b"\x00\x01\x00\x00\x00\x00\x00\x00" + query[12:end] + + +def truncated(message, query): + """A reply too large for the client's UDP limit: its header with TC set, no records.""" + head = failure(query) + return head[:2] + bytes([message[2] | 0x02, message[3]]) + head[4:] + + +def usable(message): + return message is not None and len(message) >= 12 and rcode(message) not in (SERVFAIL, REFUSED) + + +def found(message): + """NOERROR: the name exists, with or without records of the asked type.""" + return usable(message) and rcode(message) == 0 + + +def answers(message, query): + """`message` replies to `query`'s question (resolvers echo it; anything else is discarded).""" + end = question_end(query) + return (end is not None and message is not None and len(message) >= end + and message[12:end - 4].lower() + message[end - 4:end] == question_key(query)) + + +def read_resolv_conf(path=RESOLV_CONF): + try: + with open(path) as stream: + return stream.read() + except OSError: + return "" + + +def nameservers(text): + found_servers = [] + for line in text.splitlines(): + fields = line.split() + if len(fields) >= 2 and fields[0] == "nameserver": + found_servers.append(fields[1]) + return found_servers + + +def upstreams_from(text): + """The platform's resolvers: the guard's record of them, else the file's own servers.""" + for line in text.splitlines(): + if line.startswith(UPSTREAM_PREFIX): + servers = line[len(UPSTREAM_PREFIX):].split() + if servers: + return servers + servers = [server for server in nameservers(text) if server != LISTEN] + return servers or list(DEFAULT_UPSTREAMS) + + +def guarded(text): + """resolv.conf pointing at the stub, or None when `text` needs no change.""" + if text.startswith(MARKER + "\n"): + return None + servers = [server for server in nameservers(text) if server != LISTEN] + if not servers: + return None + kept = [line for line in text.splitlines() if line.split()[:1] in (["search"], ["domain"])] + options = [ + option + for line in text.splitlines() if line.split()[:1] == ["options"] + for option in line.split()[1:] if not option.startswith(("timeout:", "attempts:")) + ] + # libc reads at most three nameservers: the stub plus two upstreams, IPv4 + # first because every machine routes IPv4 and not every one routes IPv6. + fallback = sorted(servers, key=lambda server: ":" in server)[:2] + lines = [ + MARKER, + "# The platform's resolvers follow the stub; cmux-dns rewrites this file", + "# whenever the platform does (see /usr/local/bin/cmux-dns).", + UPSTREAM_PREFIX + " ".join(servers), + *kept, + f"nameserver {LISTEN}", + *[f"nameserver {server}" for server in fallback], + " ".join(["options", RESOLV_OPTIONS, *options]), + ] + return "\n".join(lines) + "\n" + + +def guard(path=RESOLV_CONF): + for _ in range(5): + text = read_resolv_conf(path) + updated = guarded(text) + if updated is None: + return False + directory = os.path.dirname(path) or "." + fd, temporary = tempfile.mkstemp(prefix=".resolv.conf.cmux-", dir=directory) + try: + with os.fdopen(fd, "w") as stream: + stream.write(updated) + os.fchmod(stream.fileno(), 0o644) + # The platform may have written again since the read: guard that + # version instead of replacing it with a copy of the old one. + if read_resolv_conf(path) != text: + continue + os.replace(temporary, path) + return True + finally: + if os.path.lexists(temporary): + os.unlink(temporary) + return False + + +def resolv_conf_source(path=RESOLV_CONF): + """The platform's resolvers and a generation that changes whenever resolv.conf is written.""" + try: + status = os.stat(path) + generation = (status.st_ino, status.st_mtime_ns) + except OSError: + generation = None + return generation, upstreams_from(read_resolv_conf(path)) + + +def platform_steering(path=TLS_DNS_SERVERS): + """The provider publishes resolver IPs here while TLS steering is active. + + It does not publish the steered names. Public answers could bypass TLS + routing (including credential injection), so an unreadable marker also + fails closed. An absent or empty marker means steering is inactive. + """ + try: + with open(path) as stream: + return bool(stream.read().strip()) + except FileNotFoundError: + return False + except OSError: + return True + + +_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=64) + + +def bounded(function, timeout, *args): + """`function(*args)`, or None once `timeout` passes (the call may finish later, unseen).""" + future = _POOL.submit(function, *args) + try: + return future.result(timeout=max(timeout, 0.001)) + except concurrent.futures.TimeoutError: + future.cancel() + return None + + +class Resolver: + """Platform first, then the last good platform answer, then DNS-over-HTTPS.""" + + def __init__(self, upstreams=None, doh=None, clock=time.monotonic, exchange=None, doh_exchange=None, source=None, steering=platform_steering): + if source is None: + source = (lambda: (None, upstreams())) if upstreams else resolv_conf_source + self._source = source + self._steering = steering + self._doh = DOH_ENDPOINTS if doh is None else doh + self._clock = clock + self._exchange = exchange or exchange_dns + self._doh_exchange = doh_exchange or exchange_doh + self._cache = collections.OrderedDict() + self._cache_for = None + self._lock = threading.Lock() + + def _cached(self, generation, key, answer=None): + with self._lock: + # Every platform write (a TLS rule, a resume, a clone's first boot) + # starts over: a steered name may now answer differently. + if self._cache_for != generation: + self._cache.clear() + self._cache_for = generation + now = self._clock() + if answer is not None: + self._cache[key] = (now, answer) + self._cache.move_to_end(key) + while len(self._cache) > CACHE_ENTRIES: + self._cache.popitem(last=False) + return answer + hit = self._cache.get(key) + if hit is None or now - hit[0] > STALE_MAX_AGE: + return None + self._cache.move_to_end(key) + return hit[1] + + def _current(self, generation): + stamp, servers = self._source() + return generation == (stamp, tuple(servers)) + + def resolve(self, query, tcp=False): + key = question_key(query) + if key is None: + return None + deadline = self._clock() + QUERY_BUDGET + stamp, servers = self._source() + generation = (stamp, tuple(servers)) + # Match libc's fallback order: not every machine routes IPv6. + ordered = sorted(servers, key=lambda server: ":" in server) + attempts = (ordered * UPSTREAM_TRIES)[:UPSTREAM_TRIES] + for server in attempts: + remaining = deadline - self._clock() + if remaining <= 0: + break + timeout = min(UPSTREAM_TIMEOUT, remaining) + reply = bounded(self._exchange, timeout, server, query, timeout, tcp) + if usable(reply) and answers(reply, query): + if not self._current(generation): + return failure(query) + if reply[2] & 0x02 == 0: + self._cached(generation, key, reply) + return reply + stale = self._cached(generation, key) + if stale is not None: + return with_id(stale, query) if self._current(generation) else failure(query) + # The provider exports resolver IPs, but no guest-readable name set. + # Conservatively preserve every possible TLS route on a cold miss. + if self._steering(): + return failure(query) + for host, path in self._doh: + remaining = deadline - self._clock() + if remaining <= 0: + break + timeout = min(DOH_TIMEOUT, remaining) + reply = bounded(self._doh_exchange, timeout, host, path, query, timeout) + # Never NXDOMAIN: public resolvers do not know the names only the + # platform answers, and an outage must not look like a missing name. + if found(reply) and answers(reply, query): + # A TLS rule may have arrived while this public lookup ran. + if self._steering() or not self._current(generation): + return failure(query) + return with_id(reply, query) + return failure(query) + + +def exchange_dns(server, query, timeout, tcp): + family = socket.AF_INET6 if ":" in server else socket.AF_INET + try: + if tcp: + with socket.socket(family, socket.SOCK_STREAM) as stream: + stream.settimeout(timeout) + stream.connect((server, 53)) + stream.sendall(struct.pack("!H", len(query)) + query) + return read_framed(stream) + with socket.socket(family, socket.SOCK_DGRAM) as datagram: + datagram.settimeout(timeout) + datagram.connect((server, 53)) + datagram.send(query) + end = time.monotonic() + timeout + while True: + reply = datagram.recv(65535) + if reply[:2] == query[:2]: + return reply + datagram.settimeout(max(end - time.monotonic(), 0.001)) + except OSError: + return None + + +_TLS = ssl.create_default_context() + + +def exchange_doh(host, path, query, timeout): + """RFC 8484 POST; the certificate must cover the IP address itself.""" + connection = http.client.HTTPSConnection(host, 443, timeout=timeout, context=_TLS) + try: + connection.request("POST", path, body=b"\x00\x00" + query[2:], headers={ + "Content-Type": "application/dns-message", + "Accept": "application/dns-message", + }) + response = connection.getresponse() + body = response.read(65536) + return body if response.status == 200 else None + except (OSError, http.client.HTTPException): + return None + finally: + connection.close() + + +def read_exact(stream, count): + data = b"" + while len(data) < count: + chunk = stream.recv(count - len(data)) + if not chunk: + raise ConnectionError("short read") + data += chunk + return data + + +def read_framed(stream): + return read_exact(stream, struct.unpack("!H", read_exact(stream, 2))[0]) + + +def serve(resolver, host=LISTEN, port=53): + class Datagram(socketserver.BaseRequestHandler): + def handle(self): + query, sock = self.request + reply = resolver.resolve(query) + if reply is None: + return + if len(reply) > udp_limit(query): + reply = truncated(reply, query) + sock.sendto(reply, self.client_address) + + class Stream(socketserver.BaseRequestHandler): + def handle(self): + self.request.settimeout(10) + try: + while True: + query = read_framed(self.request) + reply = resolver.resolve(query, tcp=True) + if reply is None: + return + self.request.sendall(struct.pack("!H", len(reply)) + reply) + except (OSError, ConnectionError): + return + + class UDPServer(socketserver.ThreadingMixIn, socketserver.UDPServer): + daemon_threads = True + + class TCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + daemon_threads = True + allow_reuse_address = True + + udp = UDPServer((host, port), Datagram) + tcp = TCPServer((host, port), Stream) + threading.Thread(target=tcp.serve_forever, daemon=True).start() + return udp, tcp + + +def probe(name="github.com"): + """Exit 0 when the stub itself (not libc's fallback) answers NOERROR for `name`.""" + labels = b"".join(bytes([len(part)]) + part.encode() for part in name.split(".")) + query = struct.pack("!HHHHHH", os.getpid() & 0xFFFF, 0x0100, 1, 0, 0, 0) + labels + b"\x00\x00\x01\x00\x01" + reply = exchange_dns(LISTEN, query, QUERY_BUDGET + 0.5, False) + return 0 if found(reply) and answers(reply, query) else 1 + + +def notify_ready(): + """Tell systemd both DNS listeners are bound before it starts dependents.""" + path = os.environ.get("NOTIFY_SOCKET") + if not path: + return + if path.startswith("@"): + path = "\0" + path[1:] + with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) as notify: + notify.connect(path) + notify.send(b"READY=1") + + +def main(argv): + command = argv[1] if len(argv) > 1 else "" + if command == "guard": + print("resolv.conf points at cmux-dns" if guard() or read_resolv_conf().startswith(MARKER + "\n") else "resolv.conf unchanged") + return 0 + if command == "serve": + udp, _ = serve(Resolver()) + notify_ready() + udp.serve_forever() + return 0 + if command == "probe": + return probe(*argv[2:3]) + print("usage: cmux-dns serve|guard|probe [name]", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/web/services/vms/images/manifest.json b/web/services/vms/images/manifest.json index 0d9f925ac32a..a91b5f763751 100644 --- a/web/services/vms/images/manifest.json +++ b/web/services/vms/images/manifest.json @@ -23731,7 +23731,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "sm", "cpu": 2, @@ -23767,7 +23767,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "md", "cpu": 4, @@ -23803,7 +23803,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lg", "cpu": 8, @@ -23839,7 +23839,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lgx", "cpu": 12, @@ -23874,7 +23874,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "xl", "cpu": 16, @@ -23910,7 +23910,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "2xl", "cpu": 32, @@ -23946,7 +23946,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "sm", "cpu": 2, @@ -23954,7 +23954,7 @@ "storageMb": 16384, "freestyleBase": "freestyle/ubuntu-sm" }, - "defaultForLocalDev": true + "defaultForLocalDev": false }, { "provider": "freestyle", @@ -23983,7 +23983,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "md", "cpu": 4, @@ -24019,7 +24019,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lg", "cpu": 8, @@ -24055,7 +24055,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lgx", "cpu": 12, @@ -24090,7 +24090,7 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "xl", "cpu": 16, @@ -24126,6 +24126,437 @@ "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon a6d89d443f, identity bound to the instance id, no create-time bootstrap. Validated 2026-10-30 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f6cf988a57f44d64b6099fad5c1f4c1c, md=sh-a7bb4417dc7f463daca86ed17f5e4ef8, lg=sh-d55b743a5a2a4bb1a8ec180f6443013b, lgx=sh-f20273bba9f3491084bd98e39c9f8a69, xl=sh-0f6eda742f494329bff078eb4fbf578e, 2xl=sh-3470ac323a154a9fb14c190d338c2aff.", "cmuxTuiCommit": "a6d89d443f5f9ffb66c296d7a9680e307bbe6c58", "cmuxTuiSha256": "5599b2cd18d3587acfeea2f02d42f8032d725e15b1e23b55620405e61bbe9043", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-sm", + "imageId": "sh-8e30265bf89a483193c7ecda059d0d27", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-md", + "imageId": "sh-9489bb2e7b7742e7931aad1eaa6d4405", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-lg", + "imageId": "sh-4a6af0630f074a37ac329f666ea2eb7c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-lgx", + "imageId": "sh-0f76b6ef8a9b45c5b37d79ebfc55bb62", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-xl", + "imageId": "sh-37b38237562a42da982a3dd0c2af05f9", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-2xl", + "imageId": "sh-f89d6197d64a4b4b9a50552cbea47674", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-sm-base", + "imageId": "sh-8e30265bf89a483193c7ecda059d0d27", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": true + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-md-base", + "imageId": "sh-9489bb2e7b7742e7931aad1eaa6d4405", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-lg-base", + "imageId": "sh-4a6af0630f074a37ac329f666ea2eb7c", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-lgx-base", + "imageId": "sh-0f76b6ef8a9b45c5b37d79ebfc55bb62", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-xl-base", + "imageId": "sh-37b38237562a42da982a3dd0c2af05f9", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", + "defaultForKind": true, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-41579771241-1-2xl-base", + "imageId": "sh-f89d6197d64a4b4b9a50552cbea47674", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "8bd203bf55edb3db61eecda6415519bda1bf0c62", + "epoch": "2026-10-28-r1", + "devboxSource": { + "layers": "desktop", + "digest": "e4e1bf79bccbfe0a78d6aa7850f404878ef32485e348e68af1fb530769a89fac", + "schema": 2 + }, + "builtAt": "2026-11-03T04:53:02.774Z", + "builderScriptVersion": "d4c14de2e74f41dc671887ba6d62281c1f598f7615ba30589d54686946bdcf5c", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.299", + "@openai/codex": "0.162.1", + "opencode-ai": "1.19.0", + "@earendil-works/pi-coding-agent": "1.2.0", + "agent-browser": "0.39.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-10-28-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as cmux; cmux (uid 1000, NOPASSWD sudo) is the work user and the daemon's session user, so terminals are non-root; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 6da40f7e59, identity bound to the instance id, no create-time bootstrap. Validated 2026-11-03 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-8e30265bf89a483193c7ecda059d0d27, md=sh-9489bb2e7b7742e7931aad1eaa6d4405, lg=sh-4a6af0630f074a37ac329f666ea2eb7c, lgx=sh-0f76b6ef8a9b45c5b37d79ebfc55bb62, xl=sh-37b38237562a42da982a3dd0c2af05f9, 2xl=sh-f89d6197d64a4b4b9a50552cbea47674.", + "cmuxTuiCommit": "6da40f7e59b10b8063a91ef58f0d353b64fb405d", + "cmuxTuiSha256": "c9d1a28f4af8d1edaf2969e778e68b89b3a32764e301b3b8628145d99d50e37f", "defaultForKind": true, "size": { "name": "2xl", diff --git a/web/services/vms/networkPolicy.ts b/web/services/vms/networkPolicy.ts index 277ebc8a4f23..cab5311c6e33 100644 --- a/web/services/vms/networkPolicy.ts +++ b/web/services/vms/networkPolicy.ts @@ -10,8 +10,9 @@ * - `none`: only the hosts cmux itself needs. * * Ranges become provider firewall rules (IP layer). Domains become provider - * TLS egress rules: the provider steers the exact name to its edge through the - * guest's `/etc/hosts`, so a domain works with no IP-layer grant and no DNS. + * TLS egress rules: the provider's forwarding resolver answers the exact name + * with its edge address, so a domain works with no IP-layer grant and no open + * DNS (allowDns). * Domains are exact names on HTTPS/443 only; wildcards are not steerable yet. * * The policy is data owned by the control plane. The driver reconciles the diff --git a/web/tests/vm-devbox-dns.test.ts b/web/tests/vm-devbox-dns.test.ts new file mode 100644 index 000000000000..2aa8e84dee47 --- /dev/null +++ b/web/tests/vm-devbox-dns.test.ts @@ -0,0 +1,444 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { runChild } from "./helpers/run-child"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { DEVBOX_DNS_PATH, DEVBOX_DNS_UNITS, devboxDnsCheckCommand } from "../scripts/devbox-image-common"; + +// cmux issue 26640: the platform resolver (10.32.0.28) answers SERVFAIL or +// nothing for every name at once, and the platform intercepts every port-53 +// packet, so the guest stub must route around it without breaking the names +// only the platform answers. +const script = path.join(import.meta.dirname, "../services/vms/images/devbox/cmux-dns"); + +const directories: string[] = []; +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function fixture(): string { + const directory = mkdtempSync(path.join(tmpdir(), "cmux-dns-")); + directories.push(directory); + return directory; +} + +/** Runs `body` with `dns` (the stub module) and `q(name)`, `reply(query, rcode, answers)` helpers. */ +async function python(body: string, ...args: string[]) { + const result = await runChild("python3", ["-c", String.raw` +import importlib.machinery, importlib.util, struct, sys +sys.dont_write_bytecode = True +loader = importlib.machinery.SourceFileLoader("cmux_dns", sys.argv[1]) +spec = importlib.util.spec_from_loader("cmux_dns", loader) +dns = importlib.util.module_from_spec(spec); loader.exec_module(dns) +def q(name, qtype=1, ident=0x1234): + labels = b"".join(bytes([len(p)]) + p.encode() for p in name.split(".")) + return struct.pack("!HHHHHH", ident, 0x0100, 1, 0, 0, 0) + labels + b"\x00" + struct.pack("!HH", qtype, 1) +def reply(query, rcode, answers=0, tc=False): + return query[:2] + bytes([0x81 | (0x02 if tc else 0), 0x80 | rcode]) + query[4:6] + struct.pack("!H", answers) + query[8:] + b"\xc0\x0c\x00\x01\x00\x01\x00\x00\x00\x3c\x00\x04\x0a\x20\x00\x1c" * answers +calls = [] +` + body, script, ...args]); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + return result.stdout.trim().split("\n"); +} + +describe("cmux-dns resolver", () => { + test("the platform's answer wins while it works, so steered names keep resolving to the edge", async () => { + expect(await python(String.raw` +def exchange(server, query, timeout, tcp): + calls.append(server) + return reply(query, 0, 1) +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=exchange, doh_exchange=lambda *a: calls.append("doh")) +out = r.resolve(q("api.anthropic.com")) +print(dns.rcode(out), out[-4:].hex(), calls) +`)).toEqual(["0 0a20001c ['10.32.0.28']"]); + }); + + test("a platform SERVFAIL is retried, then answered from the last good platform answer", async () => { + expect(await python(String.raw` +state = {"down": False} +def exchange(server, query, timeout, tcp): + calls.append("platform") + return reply(query, 2) if state["down"] else reply(query, 0, 1) +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=exchange, doh_exchange=lambda *a: calls.append("doh")) +r.resolve(q("api.anthropic.com", ident=1)) +state["down"] = True +calls.clear() +out = r.resolve(q("api.anthropic.com", ident=7)) +print(dns.rcode(out), struct.unpack("!H", out[:2])[0], out[-4:].hex(), calls) +`)).toEqual(["0 7 0a20001c ['platform', 'platform']"]); + }); + + test("a name the platform never answered falls back to DNS-over-HTTPS", async () => { + expect(await python(String.raw` +def doh(host, path, query, timeout): + calls.append(host) + return reply(query, 0, 1) +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=lambda *a: None, doh_exchange=doh) +out = r.resolve(q("github.com", ident=9)) +print(dns.rcode(out), struct.unpack("!H", out[:2])[0], calls) +nodata = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=lambda *a: None, doh_exchange=lambda h, p, query, t: reply(query, 0, 0)) +print(dns.rcode(nodata.resolve(q("github.com", qtype=28)))) +`)).toEqual(["0 9 ['1.1.1.1']", "0"]); + }); + + test("a fallback NXDOMAIN or a total outage answers SERVFAIL, never a missing name", async () => { + expect(await python(String.raw` +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=lambda s, query, t, tcp: reply(query, 2), doh_exchange=lambda h, p, query, t: reply(query, 3)) +out = r.resolve(q("coderouter.cmux.internal")) +print(dns.rcode(out), out[3] & 0x80 != 0, out[12:] == q("coderouter.cmux.internal")[12:]) +dead = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=lambda *a: None, doh_exchange=lambda *a: None) +print(dns.rcode(dead.resolve(q("github.com")))) +`)).toEqual(["2 True True", "2"]); + }); + + test("TLS steering keeps cached edge answers and fails closed on cold misses", async () => { + const marker = path.join(fixture(), "tls-dns-servers"); + writeFileSync(marker, "10.32.0.28\n"); + expect(await python(String.raw` +import os +state = {"down": False} +def exchange(server, query, timeout, tcp): + return None if state["down"] else reply(query, 0, 1) +def doh(host, path, query, timeout): + calls.append("doh") + return reply(query, 0, 1) +r = dns.Resolver(upstreams=lambda: ["10.32.0.28"], exchange=exchange, doh_exchange=doh, steering=lambda: dns.platform_steering(sys.argv[2])) +r.resolve(q("api.anthropic.com")) +state["down"] = True +cached = r.resolve(q("api.anthropic.com")) +print(dns.rcode(cached), cached[-4:].hex(), calls) +print(dns.rcode(r.resolve(q("github.com"))), calls) +os.unlink(sys.argv[2]) +print(dns.rcode(r.resolve(q("github.com"))), calls) +`, marker)).toEqual(["0 0a20001c []", "2 []", "0 ['doh']"]); + }); + + test("an empty steering marker permits DoH and an unreadable marker fails closed", async () => { + const directory = fixture(); + const marker = path.join(directory, "tls-dns-servers"); + writeFileSync(marker, ""); + expect(await python(String.raw` +print(dns.platform_steering(sys.argv[2]), dns.platform_steering(sys.argv[3])) +`, marker, directory)).toEqual(["False True"]); + }); + + test("the platform's NXDOMAIN is an answer, not a failure", async () => { + expect(await python(String.raw` +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=lambda s, query, t, tcp: reply(query, 3), doh_exchange=lambda *a: calls.append("doh")) +print(dns.rcode(r.resolve(q("missing.example"))), calls) +`)).toEqual(["3 []"]); + }); + + test("tries IPv4 platform resolvers before IPv6 when IPv6 is unavailable", async () => { + expect(await python(String.raw` +def exchange(server, query, timeout, tcp): + calls.append(server) + return None if ":" in server else reply(query, 3) +r = dns.Resolver(steering=lambda: False, upstreams=lambda: dns.DEFAULT_UPSTREAMS, exchange=exchange, doh_exchange=lambda *a: calls.append("doh")) +print(dns.rcode(r.resolve(q("missing.example"))), calls) +`)).toEqual(["3 ['8.8.8.8']"]); + }); + + test("a new resolver set drops answers cached under the old one", async () => { + // A clone resumes with the snapshot's cache; when a TLS rule arrives the + // platform's answers for steered names change, and a stale one must not + // send traffic around the edge. + expect(await python(String.raw` +servers = ["8.8.8.8"] +state = {"down": False} +def exchange(server, query, timeout, tcp): + return None if state["down"] else reply(query, 0, 1) +r = dns.Resolver(steering=lambda: False, upstreams=lambda: servers, exchange=exchange, doh_exchange=lambda *a: None) +r.resolve(q("api.openai.com")) +servers[:] = ["10.32.0.28"] +state["down"] = True +print(dns.rcode(r.resolve(q("api.openai.com")))) +`)).toEqual(["2"]); + }); + + test("the whole fallback chain stays inside one query budget", async () => { + expect(await python(String.raw` +now = [0.0] +def exchange(server, query, timeout, tcp): + now[0] += timeout + calls.append(round(timeout, 2)) +def doh(host, path, query, timeout): + now[0] += timeout + calls.append((host, round(timeout, 2))) +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], clock=lambda: now[0], exchange=exchange, doh_exchange=doh) +r.resolve(q("github.com")) +print(calls, now[0] <= dns.QUERY_BUDGET) +`)).toEqual(["[1.0, 1.0, ('1.1.1.1', 1.5)] True"]); + }); + + test("serves real UDP and TCP clients and passes a truncated platform answer through", async () => { + expect(await python(String.raw` +import socket +def exchange(server, query, timeout, tcp): + return reply(query, 0, 1, tc=not tcp) +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=exchange, doh_exchange=lambda *a: None) +udp, tcp = dns.serve(r, host="127.0.0.1", port=0) +import threading; threading.Thread(target=udp.serve_forever, daemon=True).start() +c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); c.settimeout(5) +c.sendto(q("github.com"), udp.server_address); first = c.recv(4096) +print("udp", dns.rcode(first), first[2] & 0x02 != 0) +s = socket.create_connection(tcp.server_address, timeout=5) +s.sendall(struct.pack("!H", len(q("github.com"))) + q("github.com")); answer = dns.read_framed(s) +print("tcp", dns.rcode(answer), answer[2] & 0x02 != 0) +udp.shutdown(); tcp.shutdown() +`)).toEqual(["udp 0 True", "tcp 0 False"]); + }); + + test("service readiness signals that both DNS listeners can accept queries", async () => { + expect(await python(String.raw` +import os, socket, threading +notify = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) +notify.bind(sys.argv[2]); notify.settimeout(10) +os.environ["NOTIFY_SOCKET"] = sys.argv[2] +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=lambda s, query, t, tcp: reply(query, 0, 1)) +real_serve = dns.serve +servers = [] +def serve(resolver): + udp, tcp = real_serve(resolver, host="127.0.0.1", port=0) + servers.extend([udp, tcp]) + return udp, tcp +dns.serve = serve +dns.Resolver = lambda: r +thread = threading.Thread(target=dns.main, args=(["cmux-dns", "serve"],), daemon=True) +thread.start() +try: + print(notify.recv(100).decode()) + udp, tcp = servers + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client: + client.settimeout(10) + client.sendto(q("github.com"), udp.server_address) + print("udp", dns.rcode(client.recv(4096))) + with socket.create_connection(tcp.server_address, timeout=10) as client: + query = q("github.com") + client.sendall(struct.pack("!H", len(query)) + query) + print("tcp", dns.rcode(dns.read_framed(client))) +finally: + for server in servers: + server.shutdown(); server.server_close() + notify.close() +thread.join(timeout=10) +assert not thread.is_alive() +`, path.join(fixture(), "notify"))).toEqual(["READY=1", "udp 0", "tcp 0"]); + }, 35_000); +}); + +describe("cmux-dns resolver edge cases", () => { + test("question matching preserves binary record types and classes", async () => { + expect(await python(String.raw` +https = q("GitHub.COM", qtype=65) +other = q("github.com", qtype=97) +other_class = https[:-2] + b"\x00\x21" +print(dns.question_key(https) == dns.question_key(other)) +print(dns.answers(reply(other, 0, 1), https), dns.answers(reply(other_class, 0, 1), https)) +state = {"down": False} +def exchange(server, query, timeout, tcp): + return None if state["down"] else reply(query, 0, 1) +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=exchange, doh_exchange=lambda *a: None) +r.resolve(https) +state["down"] = True +print(dns.rcode(r.resolve(other))) +`)).toEqual(["False", "False False", "2"]); + }); + + test("names that differ only in label boundaries never share a cache entry", async () => { + // "github.com" as one label and github + com are different questions. + expect(await python(String.raw` +one_label = struct.pack("!HHHHHH", 1, 0x0100, 1, 0, 0, 0) + b"\x0agithub.com\x00\x00\x01\x00\x01" +print(dns.question_key(one_label) == dns.question_key(q("github.com")), dns.question_key(q("GitHub.COM")) == dns.question_key(q("github.com"))) +state = {"down": False} +def exchange(server, query, timeout, tcp): + return None if state["down"] else reply(query, 3) +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=exchange, doh_exchange=lambda h, p, query, t: reply(query, 0, 1)) +r.resolve(one_label) +state["down"] = True +print(dns.rcode(r.resolve(q("github.com")))) +`)).toEqual(["False True", "0"]); + }); + + test("a stale answer is served at most a day, and never across a platform rewrite", async () => { + // A TLS rule or a resume rewrites resolv.conf with the same resolver + // (10.32.0.28), yet a steered name may now answer differently. + expect(await python(String.raw` +now = [0.0] +stamp = [1] +state = {"down": False} +def exchange(server, query, timeout, tcp): + return None if state["down"] else reply(query, 0, 1) +r = dns.Resolver(steering=lambda: False, source=lambda: (stamp[0], ["10.32.0.28"]), clock=lambda: now[0], exchange=exchange, doh_exchange=lambda *a: None) +r.resolve(q("api.openai.com")) +state["down"] = True +print("same write", dns.rcode(r.resolve(q("api.openai.com")))) +now[0] += dns.STALE_MAX_AGE + 1 +print("a day later", dns.rcode(r.resolve(q("api.openai.com")))) +state["down"] = False; now[0] = 0.0; r.resolve(q("api.openai.com")); state["down"] = True +stamp[0] = 2 +print("after rewrite", dns.rcode(r.resolve(q("api.openai.com")))) +`)).toEqual(["same write 0", "a day later 2", "after rewrite 2"]); + }); + + test("a resolver rewrite during an exchange discards old platform, cache and DoH results", async () => { + expect(await python(String.raw` +for phase in ("cache", "platform", "doh"): + stamp = [1] + changing = [False] + steering = [False] + def exchange(server, query, timeout, tcp): + if changing[0] and phase != "doh": + stamp[0] = 2 + steering[0] = True + return None if phase == "doh" or (phase == "cache" and changing[0]) else reply(query, 0, 1) + def doh(host, path, query, timeout): + stamp[0] = 2 + steering[0] = True + return reply(query, 0, 1) + r = dns.Resolver(source=lambda: (stamp[0], ["8.8.8.8"]), exchange=exchange, doh_exchange=doh, steering=lambda: steering[0]) + if phase == "cache": + r.resolve(q("api.anthropic.com")) + changing[0] = True + print(phase, dns.rcode(r.resolve(q("api.anthropic.com")))) +`)).toEqual(["cache 2", "platform 2", "doh 2"]); + }); + + test("queued exchanges are cancelled when their caller times out", async () => { + expect(await python(String.raw` +import concurrent.futures, threading +started = threading.Event() +release = threading.Event() +def occupy(): + started.set() + release.wait() +with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + dns._POOL = pool + running = pool.submit(occupy) + assert started.wait(10) + try: + print(dns.bounded(lambda: calls.append("abandoned"), 0.001)) + finally: + release.set() + running.result(timeout=10) + pool.submit(lambda: None).result(timeout=10) +print(calls) +`)).toEqual(["None", "[]"]); + }); + + test("a blocked exchange cannot hold a query indefinitely", async () => { + expect(await python(String.raw` +import threading +release = threading.Event() +def hang(*args): + release.wait() +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=hang, doh_exchange=hang) +result = [] +done = threading.Event() +def resolve(): + try: + result.append(r.resolve(q("github.com"))) + finally: + done.set() +thread = threading.Thread(target=resolve, daemon=True) +thread.start() +completed = done.wait(15) # Failure watchdog; the injected clock test checks the budget. +release.set() +thread.join(timeout=15) +assert completed and not thread.is_alive() +print(dns.rcode(result[0])) +`)).toEqual(["2"]); + }, 35_000); + + test("a reply for another question is discarded", async () => { + expect(await python(String.raw` +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=lambda s, query, t, tcp: reply(q("evil.example", ident=0x1234), 0, 1), doh_exchange=lambda *a: None) +print(dns.rcode(r.resolve(q("github.com")))) +`)).toEqual(["2"]); + }); + + test("UDP replies respect the client's size: 512 without EDNS, its EDNS size up to 1232", async () => { + expect(await python(String.raw` +plain = q("github.com") +edns = plain[:10] + b"\x00\x01" + plain[12:] + b"\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00" +small = edns[:-11] + b"\x00\x00\x29\x02\x00\x00\x00\x00\x00\x00\x00" +print(dns.udp_limit(plain), dns.udp_limit(edns), dns.udp_limit(small)) +big = reply(plain, 0, 40) +cut = dns.truncated(big, plain) +print(len(big) > 512, dns.rcode(cut), cut[2] & 0x02 != 0, cut[12:] == plain[12:], len(cut) < 512) +`)).toEqual(["512 1232 512", "True 0 True True True"]); + }); + + test("a response or a query without exactly one question gets no answer", async () => { + expect(await python(String.raw` +r = dns.Resolver(steering=lambda: False, upstreams=lambda: ["10.32.0.28"], exchange=lambda *a: calls.append("platform"), doh_exchange=lambda *a: None) +none = struct.pack("!HHHHHH", 5, 0x0100, 0, 0, 0, 1) + b"\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00" +response = reply(q("github.com"), 0, 1) +print(r.resolve(none), r.resolve(response), calls) +`)).toEqual(["None None []"]); + }); +}); + +describe("cmux-dns guard", () => { + test("points the platform's resolv.conf at the stub and keeps its resolvers after it", async () => { + const directory = fixture(); + const conf = path.join(directory, "resolv.conf"); + writeFileSync(conf, "# freestyle-tls forwarding resolver\noptions timeout:1 attempts:2\nnameserver 10.32.0.28\n"); + expect(await python(String.raw` +print(dns.guard(sys.argv[2]), dns.guard(sys.argv[2])) +print(dns.upstreams_from(open(sys.argv[2]).read())) +`, conf)).toEqual(["True False", "['10.32.0.28']"]); + expect(readFileSync(conf, "utf8").split("\n").filter((line) => !line.startsWith("# ") || line.startsWith("# cmux-dns"))).toEqual([ + "# cmux-dns", + "# cmux-dns upstream 10.32.0.28", + "nameserver 127.0.0.2", + "nameserver 10.32.0.28", + "options timeout:4 attempts:1", + "", + ]); + }); + + test("records every base resolver, keeps libc's IPv4 fallback, and keeps other options", async () => { + // libc reads three nameservers; with the stub first, the two left must be + // ones every machine can route. + const directory = fixture(); + const conf = path.join(directory, "resolv.conf"); + writeFileSync(conf, "search corp.example\noptions timeout:1 attempts:2 edns0 trust-ad\nnameserver 2606:4700:4700::1111\nnameserver 2001:4860:4860::8888\nnameserver 8.8.8.8\nnameserver 8.8.4.4\n"); + expect(await python(String.raw` +dns.guard(sys.argv[2]) +text = open(sys.argv[2]).read() +print(dns.upstreams_from(text)) +print(dns.nameservers(text)) +print([line for line in text.splitlines() if line.startswith(("search", "options"))]) +`, conf)).toEqual([ + "['2606:4700:4700::1111', '2001:4860:4860::8888', '8.8.8.8', '8.8.4.4']", + "['127.0.0.2', '8.8.8.8', '8.8.4.4']", + "['search corp.example', 'options timeout:4 attempts:1 edns0 trust-ad']", + ]); + }); + + test("leaves a file with no nameserver alone", async () => { + // A platform write caught half-way has no nameserver yet; its close + // triggers the guard again. + const directory = fixture(); + const conf = path.join(directory, "resolv.conf"); + writeFileSync(conf, "options timeout:1\n"); + expect(await python("print(dns.guard(sys.argv[2]))", conf)).toEqual(["False"]); + expect(readFileSync(conf, "utf8")).toBe("options timeout:1\n"); + }); +}); + +describe("cmux-dns image contract", () => { + test("the units run the distribution python and re-guard on every resolv.conf change", () => { + expect(Object.keys(DEVBOX_DNS_UNITS).sort()).toEqual([ + "/etc/systemd/system/cmux-dns-guard.path", + "/etc/systemd/system/cmux-dns-guard.service", + "/etc/systemd/system/cmux-dns.service", + ]); + expect(DEVBOX_DNS_UNITS["/etc/systemd/system/cmux-dns.service"]).toContain(`ExecStart=/usr/bin/python3 ${DEVBOX_DNS_PATH} serve`); + expect(DEVBOX_DNS_UNITS["/etc/systemd/system/cmux-dns.service"]).toContain("Type=notify"); + expect(DEVBOX_DNS_UNITS["/etc/systemd/system/cmux-dns-guard.path"]).toContain("PathChanged=/etc/resolv.conf"); + expect(devboxDnsCheckCommand).toContain("127.0.0.2"); + }); +}); diff --git a/web/tests/vm-devbox-image.test.ts b/web/tests/vm-devbox-image.test.ts index d6639130c4fa..f5cf9cd90bd1 100644 --- a/web/tests/vm-devbox-image.test.ts +++ b/web/tests/vm-devbox-image.test.ts @@ -125,6 +125,7 @@ describe("devbox image template", () => { "cmux-claude", "cmux-codex", "cmux-devbox-boot", + "cmux-dns", "cmux-motd", "cmux-opencode", "cmux-prompt-sync", @@ -148,6 +149,7 @@ describe("devbox image template", () => { "cmux-claude", "cmux-codex", "cmux-devbox-boot", + "cmux-dns", "cmux-motd", "cmux-opencode", "cmux-prompt.bash",