import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { parse } from "yaml"; import { cloudHomeConfiguration } from "../server/cloud-home.ts"; import { cloudHomeChildEnvironments } from "../server/cloud-home-start.ts"; // The published images: the self-hosted server (the default target) and the // Cloud home. A pull downloads every layer whose digest changed, and a layer's // digest changes when anything beneath it does. const dockerfile = readFileSync(new URL("../Dockerfile", import.meta.url), "utf8"); const workflow = parse(readFileSync(new URL("../.github/workflows/docker.yml", import.meta.url), "utf8")); interface Step { op: string; args: string } interface Stage { name: string; from: string; platform?: string; steps: Step[] } function stages(text: string): Stage[] { const all: Stage[] = []; let pending = ""; for (const line of text.split("\n")) { if (/^\s*#/.test(line) || (!pending && !line.trim())) continue; pending += line.endsWith("\\") ? `${line.slice(0, -1)} ` : line; if (line.endsWith("\\")) continue; const [, op, args] = /^\s*(\S+)\s*([\s\S]*)$/.exec(pending)!; pending = ""; if (op.toUpperCase() === "FROM") { const [, platform, from, name] = /^(?:--platform=(\S+)\s+)?(\S+)(?:\s+AS\s+(\S+))?$/i.exec(args.trim())!; all.push({ name: name ?? `#${all.length}`, from, platform, steps: [] }); } else all.at(-1)?.steps.push({ op: op.toUpperCase(), args: args.trim() }); } return all; } const all = stages(dockerfile); /** Every instruction a target's image is built from, its parents' first. */ function chain(target: string): Step[] { const stage = all.find(s => s.name === target); if (!stage) throw new Error(`the Dockerfile has no ${target} stage`); return [...(all.some(s => s.name === stage.from) ? chain(stage.from) : []), ...stage.steps]; } const shipped = () => all.filter(s => s.name !== "build" && s.name !== "edge" && s.name !== "tunnel").flatMap(s => s.steps); const npmInstall = (step: Step) => step.op === "RUN" && /\bnpm install\b/.test(step.args); const browserRun = () => chain("runtime").find(step => step.op === "RUN" && step.args.includes("agent-browser install"))!; const grokRun = (steps: Step[]) => steps.findIndex(step => step.op === "RUN" && step.args.includes("https://x.ai/cli/install.sh")); // The keys step parses Chrome for Testing's JSON with jq, as on GitHub's runners. const jq = process.platform === "win32" ? "" : spawnSync("sh", ["-c", "command -v jq"], { encoding: "utf8" }).stdout.trim(); const temporaryDirectories: string[] = []; afterEach(() => { for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); describe("container images", () => { it("keeps the self-hosted server the default target, and Cloud home a target of the same file", () => { expect(all.at(-1)!.name).toBe("server"); expect(all.find(s => s.name === "cloud-home")!.from).toBe("runtime"); expect(all.find(s => s.name === "server")!.from).toBe("runtime"); // the build arg docker-compose.yml passes keeps its name and empty default expect(chain("server")).toContainEqual({ op: "ARG", args: 'ENGINES=""' }); }); it("puts the browser and the engines beneath the per-commit app files, so a code change leaves them as they were", () => { for (const target of ["server", "cloud-home"]) { const steps = chain(target); const heavy = steps.flatMap((step, i) => (npmInstall(step) ? [i] : [])); const app = steps.flatMap((step, i) => (step.op === "COPY" && !/--from=(?:edge|tunnel) /.test(step.args) ? [i] : [])); // agent-browser + Chrome, then (Cloud home only) pnpm in the tools layer, then the engines, then the app files // (and the Cloud home's Caddyfile) expect(heavy, target).toHaveLength(target === "cloud-home" ? 3 : 2); const fromBuild = steps.flatMap((step, i) => (step.op === "COPY" && step.args.includes("--from=build") ? [i] : [])); expect(fromBuild, target).toHaveLength(4); expect(Math.max(...heavy), target).toBeLessThan(Math.min(...app)); // The Data engine's native tree (DuckDB, resvg) changes on a version bump, // not per commit, so it is copied before the per-commit app files. const native = steps.findIndex(step => step.op === "COPY" && step.args.includes("/dist-native/duckdb/")); expect(native, target).toBeGreaterThan(-1); expect(native, target).toBeLessThan(Math.min(...fromBuild.filter(i => i !== native))); } }); it("leaves no npm or compile cache in a layer: each install removes its own in the same step", () => { const installs = shipped().filter(npmInstall); expect(installs.length).toBeGreaterThanOrEqual(3); for (const { args } of installs) { const cache = /--cache (\S+)/.exec(args)?.[1] ?? /\bHOME=(\S+) npm install\b/.exec(args)?.[1]; expect(cache, args).toMatch(/^\/tmp\//); // Every `rm -rf` in the step counts: the Cloud home's tools layer clears apt's lists before its pnpm install. const removed = [...args.matchAll(/\brm -rf ([^;&|]+)/g)].flatMap((match) => match[1]!.trim().split(/\s+/)); expect(removed, args).toEqual(expect.arrayContaining([cache, "/tmp/node-compile-cache"])); } }); it("ships Caddy once, without its file capability, and the app files once, root's without a rewrite", () => { for (const { args } of shipped()) expect(args).not.toMatch(/\bch(own|mod) -R\b/); // modes are fixed where the files are built, in a stage that is not shipped const build = all.find(s => s.name === "build")!.steps.find(step => step.op === "RUN" && step.args.startsWith("pnpm build:server"))!; expect(build.args).toMatch(/ && chmod -R go-w dist dist-server skills$/); // a plain copy to a fresh inode drops the privileged-port capability and the 1001 owner expect(all.find(s => s.name === "edge")!.steps).toContainEqual({ op: "RUN", args: "cp /usr/bin/caddy /caddy" }); const home = chain("cloud-home"); expect(home.filter(step => step.args.includes("/usr/local/bin/caddy") && step.op === "COPY")).toEqual([ { op: "COPY", args: "--from=edge /caddy /usr/local/bin/caddy" }, ]); // pinned, so beneath the engines: an engine release does not re-pull it expect(home.findIndex(step => step.args.startsWith("--from=edge "))).toBeLessThan(home.findLastIndex(npmInstall)); expect(home.filter(step => step.op === "RUN" && /\b(cp|mv)\b[^&]*caddy/.test(step.args))).toEqual([]); // The tunnel's connector: the release scripts/prepare-cloudflared.mjs pins and checks, once, beneath the engines. expect(all.find(s => s.name === "tunnel")!.steps.find(step => step.op === "RUN")!.args).toMatch(/^node \/stage\/prepare-cloudflared\.mjs --current --root \/stage /); expect(home.filter(step => step.args.includes("/usr/local/bin/cloudflared") && step.op === "COPY")).toEqual([ { op: "COPY", args: "--from=tunnel /cloudflared /usr/local/bin/cloudflared" }, ]); expect(home.findIndex(step => step.args.startsWith("--from=tunnel "))).toBeLessThan(home.findLastIndex(npmInstall)); expect(home.filter(step => step.args.includes("--from=build")).map(step => step.args)).toEqual([ "/src/dist-native/duckdb/target ./duckdb", "/src/dist-server ./dist-server", "/src/dist ./dist", "/src/skills ./skills", ].map(path => `--from=build ${path}`)); expect(chain("server").filter(step => step.args.includes("--from=build")).every(step => step.args.startsWith("--from=build --chown=maus:maus "))).toBe(true); }); it("installs the pinned Grok in Cloud home only, beneath the engines and the app, from a home the same step removes", () => { const home = chain("cloud-home"); const grok = grokRun(home); expect(grok).toBeGreaterThan(-1); expect(home.slice(0, grok)).toContainEqual({ op: "ARG", args: "GROK_VERSION=1.0.41" }); // pinned, so an engine release or a code change does not re-pull it expect(grok).toBeLessThan(home.findLastIndex(npmInstall)); expect(grok).toBeGreaterThan(home.indexOf(browserRun())); expect(grok).toBeLessThan(home.findIndex(step => step.args.includes("--from=build"))); const { args } = home[grok]; // the installer and the version check write only to a throwaway home, gone by the end of the step expect(args.match(/\bHOME=\S+/g)).toEqual(["HOME=/tmp/grok-install", "HOME=/tmp/grok-install"]); expect(args).toMatch(/ && install -m 0755 "\$\(readlink -f \/tmp\/grok-install\/\.grok\/bin\/grok\)" \/usr\/local\/bin\/grok /); expect(args).toContain('grep -qF "grok $GROK_VERSION "'); expect(args).toMatch(/ && rm -rf \/tmp\/grok-install; +fi$/); expect(grokRun(chain("server"))).toBe(-1); }); it("scans the saved images for every throwaway build directory, and for one Grok", () => { const scan = (workflow.jobs["build-and-smoke"].steps as { name?: string; run?: string }[]) .find(step => step.name?.startsWith("Prove each image ships its files once"))!.run!; const cache = new RegExp(/cache = re\.compile\(r"(.*)"\)/.exec(scan)![1]); const removed = shipped().flatMap(step => [...step.args.matchAll(/\brm -rf ([^;&|]+)/g)].flatMap(match => match[1].trim().split(/\s+/))); const throwaway = [...new Set(removed.filter(path => path.startsWith("/tmp/")))].sort(); expect(throwaway).toEqual(["/tmp/grok-install", "/tmp/node-compile-cache", "/tmp/npm-cache", "/tmp/omb-build-home", "/tmp/uv-install"]); for (const path of throwaway) expect(cache.test(`${path.slice(1)}/leftover`), path).toBe(true); expect(scan).toContain('grok = files["usr/local/bin/grok"]'); }); it("keeps each image's runtime contract", () => { const last = (steps: Step[], op: string) => steps.filter(step => step.op === op).at(-1)?.args; const server = chain("server"); expect(last(server, "USER")).toBe("maus"); expect(last(server, "VOLUME")).toBe('["/data"]'); expect(last(server, "CMD")).toBe('["node", "dist-server/server-launcher.js"]'); expect(last(server, "HEALTHCHECK")).toContain("http://127.0.0.1:8799/api/health"); const home = chain("cloud-home"); expect(last(home, "USER")).toBe("root"); expect(last(home, "VOLUME")).toBe('["/data"]'); expect(last(home, "EXPOSE")).toBe("8080"); expect(last(home, "CMD")).toBe('["node", "/app/dist-server/cloud-home-start.js"]'); expect(last(home, "HEALTHCHECK")).toContain("http://127.0.0.1:8080/api/health"); expect(last(chain("runtime"), "ENV")).toMatch(/^AGENT_BROWSER_EXECUTABLE_PATH=\/opt\/openmausbot-browser\/chrome .*NODE_ENV=production$/); }); it("hands the Cloud home's server every setting the image's ENV gives it", () => { // The root supervisor passes the server an allow-listed environment; a setting the image sets but the list drops is // lost silently (OMB_DUCKDB_DIR was: the server then found neither the 1Password SDK nor DuckDB in /app/duckdb). const image: Record = {}; for (const step of chain("cloud-home").filter(step => step.op === "ENV")) { for (const [, name, value] of step.args.matchAll(/(?:^|\s)([A-Z][A-Z0-9_]*)=(\S*)/g)) image[name] = value; } expect(image.OMB_DUCKDB_DIR).toBe("/app/duckdb"); const contract = { OMB_CLOUD_ROLE: "home", OMB_CLOUD_MACHINE_ID: "3f9c2a4e-8b1d-4c6e-9a7f-2d5e8c1b0a93", OMB_CLOUD_ADMIN_URL: "https://cloud.example.test", OMB_PUBLIC_URL: "https://omb-u-1a2b3c4d5e6f.fly.dev", OMB_CLOUD_BOOTSTRAP_SECRET: "S".repeat(43) }; const { server } = cloudHomeChildEnvironments(cloudHomeConfiguration(contract)!, { ...image, ...contract }, "/data"); // The launcher sets HOME, the ports and the data folder itself; everything else must come through unchanged. for (const name of Object.keys(image)) expect(server, name).toHaveProperty(name); expect(server.OMB_DUCKDB_DIR).toBe("/app/duckdb"); }); it("never runs a build step with HOME on the volume, so nothing lands in what a fresh volume starts with", () => { for (const target of ["server", "cloud-home"]) { const steps = chain(target); const home = steps.findIndex(step => step.op === "ENV" && /(^|\s)HOME=\/data\b/.test(step.args)); expect(home, target).toBeGreaterThan(-1); expect(steps.slice(home).filter(step => step.op === "RUN"), target).toEqual([]); } }); it.skipIf(process.platform === "win32")("keeps only agent-browser's native binary, and refuses when the command is not one", () => { const { args } = browserRun(); const start = args.indexOf("native="); const end = args.indexOf("&& agent-browser --version", start); expect(start).toBeGreaterThan(args.indexOf("agent-browser install")); expect(end).toBeGreaterThan(start); const prune = args.slice(start, end); const run = (target: string, elf = true) => { const root = mkdtempSync(join(tmpdir(), "omb-agent-browser-")); temporaryDirectories.push(root); const bin = join(root, "lib/node_modules/agent-browser/bin"); mkdirSync(bin, { recursive: true }); mkdirSync(join(root, "bin")); const names = ["agent-browser-darwin-arm64", "agent-browser-linux-arm64", "agent-browser-linux-musl-x64", "agent-browser-linux-x64", "agent-browser-win32-x64.exe", "agent-browser.js"]; for (const name of names) writeFileSync(join(bin, name), name === "agent-browser-linux-x64" && elf ? "\x7fELF\x02\x01" : "#!/usr/bin/env node\n", { mode: 0o755 }); symlinkSync(join(bin, target), join(root, "bin/agent-browser")); const result = spawnSync("sh", ["-c", prune], { env: { PATH: `${join(root, "bin")}:/usr/bin:/bin` }, encoding: "utf8" }); return { status: result.status, left: readdirSync(bin).sort(), names }; }; expect(run("agent-browser-linux-x64")).toMatchObject({ status: 0, left: ["agent-browser-linux-x64", "agent-browser.js"] }); // npm linking the JS wrapper instead must stop the build, not delete every native binary const wrapper = run("agent-browser.js"); expect(wrapper.status).not.toBe(0); expect(wrapper.left).toEqual(wrapper.names); const script = run("agent-browser-linux-x64", false); expect(script.status).not.toBe(0); expect(script.left).toEqual(script.names); }); it("builds the server image for linux/arm64 too: the DuckDB tree and the browser follow the image's CPU", () => { // The JavaScript build runs once on the build host; only the native tree is per CPU, staged after it. const build = all.find(s => s.name === "build")!; expect(build.platform).toBe("$BUILDPLATFORM"); const declared = build.steps.findIndex(step => step.op === "ARG" && step.args === "TARGETARCH"); expect(declared).toBeGreaterThan(build.steps.findIndex(step => step.op === "RUN" && step.args.startsWith("pnpm build:server"))); const stage = build.steps.find(step => step.op === "RUN" && step.args.includes("prepare-duckdb.mjs"))!; expect(build.steps.indexOf(stage)).toBeGreaterThan(declared); if (process.platform !== "win32") { for (const [targetArch, arch] of [["amd64", "x64"], ["arm64", "arm64"], ["", "x64"]]) { const root = mkdtempSync(join(tmpdir(), "omb-duckdb-arch-")); temporaryDirectories.push(root); mkdirSync(join(root, "scripts")); // A stand-in for the staging script: it stages the CPU it is told to, and records which. writeFileSync(join(root, "scripts/prepare-duckdb.mjs"), 'import { mkdirSync, writeFileSync } from "node:fs"; const d = `dist-native/duckdb/linux-${process.env.OMB_DUCKDB_ARCH}`; mkdirSync(d, { recursive: true }); writeFileSync(`${d}/arch`, process.env.OMB_DUCKDB_ARCH);'); const result = spawnSync("sh", ["-c", stage.args], { cwd: root, env: { PATH: process.env.PATH, TARGETARCH: targetArch }, encoding: "utf8" }); expect(result.status, `${targetArch}: ${result.stderr}`).toBe(0); expect(readdirSync(join(root, "dist-native/duckdb")), targetArch).toEqual(["target"]); expect(readFileSync(join(root, "dist-native/duckdb/target/arch"), "utf8"), targetArch).toBe(arch); } } // Chrome for Testing has no Linux ARM64 build: an arm64 image takes Debian's Chromium at the same path. const runtime = chain("runtime"); expect(runtime.slice(0, runtime.indexOf(browserRun()))).toContainEqual({ op: "ARG", args: "TARGETARCH" }); expect(browserRun().args).toMatch(/if \[ "\$TARGETARCH" = arm64 \]; then .*apt-get install -y --no-install-recommends chromium .*ln -s \/usr\/lib\/chromium\/chromium \/opt\/openmausbot-browser\/chrome; +else .*agent-browser install .*; +fi/); // Published for both CPUs; the Cloud home (Fly, amd64) stays as it is. const steps = workflow.jobs.publish.steps as { id?: string; uses?: string; with?: Record }[]; const server = steps.findIndex(step => step.uses?.startsWith("docker/build-push-action@") && step.id !== "home"); const qemu = steps.findIndex(step => step.uses?.startsWith("docker/setup-qemu-action@")); expect(qemu).toBeGreaterThan(-1); expect(qemu).toBeLessThan(server); expect(steps[server].with!.platforms).toBe("linux/amd64,linux/arm64"); expect(steps.find(step => step.id === "home")!.with).not.toHaveProperty("platforms"); // and an arm64 runner proves the arm64 image boots, serves and browses before anything is published const arm = workflow.jobs["arm64-smoke"]; expect(arm["runs-on"]).toBe("ubuntu-24.04-arm"); expect(workflow.jobs.publish.needs).toEqual(expect.arrayContaining(["build-and-smoke", "arm64-smoke"])); const armRun = (arm.steps as { run?: string }[]).map(step => step.run ?? "").join("\n"); expect(armRun).toContain("/api/health"); expect(armRun).toContain("agent-browser --session ci-browser open"); }); it("builds Cloud home from the same Dockerfile in CI, with its own layer cache", () => { const smoke = workflow.jobs["build-and-smoke"].steps.map((step: { run?: string }) => step.run ?? "").join("\n"); expect(smoke).toContain("docker build --target cloud-home --build-arg CLOUD_HOME_ENGINES= -t omb-cloud-home:ci ."); const steps = workflow.jobs.publish.steps as { id?: string; uses?: string; with?: Record }[]; const server = steps.find(step => step.uses?.startsWith("docker/build-push-action@") && step.id !== "home")!; const home = steps.find(step => step.id === "home")!; expect(home.with).not.toHaveProperty("file"); expect(home.with!.target).toBe("cloud-home"); expect(home.with!["build-args"]).not.toContain("BASE_IMAGE"); expect(home.with!["cache-from"].split("\n")).toEqual(expect.arrayContaining(["type=gha", "type=gha,scope=cloud-home"])); expect(home.with!["cache-to"]).toBe("type=gha,scope=cloud-home,mode=max"); // Both builds key the browser layer on the same Chrome, so they share it. for (const build of [server, home]) expect(build.with!["build-args"]).toContain("CHROME_CACHE_TAG=${{ steps.keys.outputs.chrome }}"); // and the arm64 one on Debian's newest Chromium, so a security update rebuilds it for (const build of [server, home]) expect(build.with!["build-args"]).toContain("CHROMIUM_CACHE_TAG=${{ steps.keys.outputs.chromium }}"); expect(home.with!["build-args"]).toContain("CLOUD_HOME_ENGINES=${{ steps.keys.outputs.engines }}"); expect(home.with!["build-args"]).toContain("ENGINES_CACHE_TAG=${{ steps.keys.outputs.engines_tag }}"); }); it("keys the browser and engine layers on the versions they install, so a release rebuilds them", () => { const runtime = all.find(s => s.name === "runtime")!.steps; const declared = runtime.findIndex(step => step.op === "ARG" && step.args.startsWith("CHROME_CACHE_TAG=")); // declared after the apt step, so a Chrome release rebuilds only the browser layer expect(declared).toBeGreaterThan(runtime.findIndex(step => step.op === "RUN" && step.args.startsWith("apt-get update"))); expect(declared).toBeLessThan(runtime.indexOf(browserRun())); expect(browserRun().args).toContain("${CHROME_CACHE_TAG"); expect(runtime.findIndex(step => step.op === "ARG" && step.args.startsWith("CHROMIUM_CACHE_TAG="))).toBeLessThan(runtime.indexOf(browserRun())); expect(browserRun().args).toContain("${CHROMIUM_CACHE_TAG"); const home = all.find(s => s.name === "cloud-home")!.steps; expect(home).toContainEqual({ op: "ARG", args: 'CLOUD_HOME_ENGINES="@anthropic-ai/claude-code @openai/codex"' }); // the engines' install, not the tools layer's pinned pnpm beneath it const engines = home.find(step => npmInstall(step) && step.args.includes("$CLOUD_HOME_ENGINES")); expect(engines?.args).toContain("${ENGINES_CACHE_TAG"); const keys = (workflow.jobs.publish.steps as { id?: string; env?: Record }[]).find(step => step.id === "keys")!; expect(keys.env!.CLOUD_HOME_ENGINES).toBe("@anthropic-ai/claude-code @openai/codex"); }); it.skipIf(!jq)("resolves the current Chrome and engine releases, and keys on the date when a lookup fails", () => { const keys = (workflow.jobs.publish.steps as { id?: string; run?: string; env?: Record }[]).find(step => step.id === "keys")!; const resolve = (ok: boolean) => { const root = mkdtempSync(join(tmpdir(), "omb-docker-keys-")); temporaryDirectories.push(root); const fake = (name: string, body: string) => writeFileSync(join(root, name), `#!/bin/sh\n${ok ? body : "exit 1"}\n`, { mode: 0o755 }); fake("curl", `case "$*" in *madison*) printf ' chromium | 150.0.7871.100-1~deb12u1 | bookworm | arm64\\n chromium | 156.0.8078.73-1~deb12u1 | bookworm-security | arm64\\n';; *) echo '{"channels":{"Stable":{"version":"154.0.8037.92","downloads":{}}}}';; esac`); fake("npm", `case "$2" in @anthropic-ai/claude-code@latest) echo 2.1.291;; @openai/codex@latest) echo 0.160.1;; *) exit 1;; esac`); const output = join(root, "output"); writeFileSync(output, ""); const result = spawnSync("bash", ["-e", "-c", keys.run!], { env: { ...keys.env, PATH: `${root}:${dirname(jq)}:/usr/bin:/bin`, GITHUB_OUTPUT: output }, encoding: "utf8", }); expect(result.status, result.stderr).toBe(0); return Object.fromEntries(readFileSync(output, "utf8").trim().split("\n").map(line => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)])); }; expect(resolve(true)).toEqual({ chrome: "154.0.8037.92", chromium: "156.0.8078.73-1~deb12u1", engines: "@anthropic-ai/claude-code@2.1.291 @openai/codex@0.160.1", engines_tag: "", }); const today = `date-${new Date().toISOString().slice(0, 10)}`; expect(resolve(false)).toEqual({ chrome: today, chromium: today, engines: "@anthropic-ai/claude-code @openai/codex", engines_tag: today }); }); // An init collects browser processes whose parents exit, and Chrome gets the // shared memory OpenMausBot's desktop containers give it. it("runs every compose service of the server image with an init and Chrome's shared memory", () => { for (const file of ["../compose.yaml", "../deploy/docker-compose.yml", "../deploy/podman/compose.yaml"]) { const service = parse(readFileSync(new URL(file, import.meta.url), "utf8")).services.omb; expect(service.init, file).toBe(true); expect(service.shm_size, file).toBe("512m"); } }); // Caddy in the server's network (network_mode: service:omb) stayed in the old, dead one whenever the server's // container restarted alone, and HTTPS was refused while both looked healthy. It reaches the server over sockets in // a volume both mount instead, and the Caddyfile names the paths the server listens on. it("runs Caddy in its own container, reaching the server's sockets, never in the server's network", () => { for (const [file, caddyfile] of [["../compose.yaml", "../deploy/local/Caddyfile"], ["../deploy/docker-compose.yml", "../deploy/Caddyfile"]]) { const { services, volumes } = parse(readFileSync(new URL(file, import.meta.url), "utf8")); expect(services.caddy.network_mode, file).toBeUndefined(); // `OMB_PHONE_RELAY=1` in .env reaches the server, never Caddy. expect(Object.keys(services.omb.environment), file).toContain("OMB_PHONE_RELAY"); expect(Object.keys(services.caddy.environment ?? {}), file).not.toContain("OMB_PHONE_RELAY"); expect(services.omb.ports, file).toBeUndefined(); expect(services.caddy.ports.length, file).toBeGreaterThan(0); expect(services.omb.volumes, file).toContain("edge:/run/openmausbot"); expect(services.caddy.volumes, file).toContain("edge:/run/openmausbot"); expect(volumes.edge.driver_opts.type, file).toBe("tmpfs"); const { OMB_TUNNEL_SOCKET: api, OMB_WEBHOOK_SOCKET: hooks } = services.omb.environment; expect([api, hooks], file).toEqual(["/run/openmausbot/http.sock", "/run/openmausbot/hooks.sock"]); const proxied = [...readFileSync(new URL(caddyfile, import.meta.url), "utf8").matchAll(/reverse_proxy (\S+)/g)].map((m) => m[1]); expect(proxied, caddyfile).toEqual([`unix/${hooks}`, `unix/${api}`]); } }); });