import { spawnSync } from "node:child_process"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type Docker from "dockerode"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { assertVolumeSubpathSupport, COMPUTER_IMAGE, computerHomeStorage, computerNetworkNameFor, computerNetworkNamesForCleanup, containerCreateOptions, containerNameFor, controlPortPublicationMatches, homeVolumeMatches, hostComputerUser, legacyNetworkOwnedSolelyBy, parseMemoryBytes, publishedLoopbackControlHostPort, resolveComputerControlEndpoint, resolveScreenNetworkMode, resolveScreenPublishTarget, resolveSpaceComputerLimit, resolveTeamScreenLimit, screenPorts, screenUrlFor, screenUrlWithToken, xdotoolCommand, } from "./computer-spec.js"; describe("graphical computer spec", () => { it("binds the embed WebSocket path to the current screen capability", () => { const url = new URL(screenUrlWithToken("http://screen.test:6080/embed.html", "current-token")); expect(url.pathname).toBe("/embed.html"); expect(url.searchParams.get("path")).toBe("websockify?token=current-token"); }); it("has no small default cap and accepts optional operator limits", () => { expect(resolveTeamScreenLimit(undefined)).toBeGreaterThan(1000); expect(resolveTeamScreenLimit("0")).toBe(resolveTeamScreenLimit(undefined)); expect(resolveTeamScreenLimit("1000")).toBe(1000); expect(resolveTeamScreenLimit("4")).toBe(4); for (const value of ["-1", "1.5", "not-a-number"]) expect(() => resolveTeamScreenLimit(value)).toThrow(/positive integer/); }); it("validates space computer limit", () => { expect(resolveSpaceComputerLimit(undefined)).toBe(0); expect(resolveSpaceComputerLimit("")).toBe(0); expect(resolveSpaceComputerLimit("0")).toBe(0); expect(resolveSpaceComputerLimit("unlimited")).toBe(0); expect(resolveSpaceComputerLimit("none")).toBe(0); expect(resolveSpaceComputerLimit("10")).toBe(10); expect(resolveSpaceComputerLimit("1")).toBe(1); for (const value of ["-1", "1.5", "not-a-number"]) expect(() => resolveSpaceComputerLimit(value)).toThrow(/positive integer/); }); it("creates a VNC desktop, not an alpine sleep fallback", () => { const options = containerCreateOptions({ name: "rakazo-bot-abc", image: COMPUTER_IMAGE, botId: "abc", spaceId: "ws", homePath: "/var/rakazo/homes/abc", networkMode: "rakazo_default", }); expect(options.Image).toBe("rakazo/computer:local"); expect(options.Image).not.toMatch(/alpine/); expect(options).not.toHaveProperty("Entrypoint"); expect(JSON.stringify(options)).not.toMatch(/sleep/); expect(options.HostConfig.Binds).toEqual(["/var/rakazo/homes/abc:/home/rakazo"]); expect(options.Env).toContain( "PATH=/home/rakazo/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", ); expect(options.Env).toContain("NPM_CONFIG_PREFIX=/home/rakazo/.local"); expect(options.Env?.join("\n")).not.toMatch(/AXIOM_|LOG_LEVEL|LOG_FORMAT/); expect(options.ExposedPorts).toEqual({ "6080/tcp": {} }); // Browser debugging stays inside the computer trust boundary. for (let index = 0; index < 1000; index += 1) { const cdpPort = `${screenPorts(index).debugPort}/tcp`; expect(options.ExposedPorts).not.toHaveProperty(cdpPort); expect(options.HostConfig.PortBindings).not.toHaveProperty(cdpPort); } expect(options.ExposedPorts).not.toHaveProperty("7070/tcp"); expect(options.HostConfig.PortBindings).not.toHaveProperty("7070/tcp"); expect(options.HostConfig.PortBindings["6080/tcp"]?.[0]?.HostIp).toBe("127.0.0.1"); expect(options.HostConfig.PortBindings).not.toHaveProperty("6081/tcp"); expect(options.HostConfig.PortBindings).not.toHaveProperty("6082/tcp"); expect(screenPorts(0)).toMatchObject({ display: ":1", viewPort: "6080", controlPort: "6080" }); expect(screenPorts(1)).toMatchObject({ display: ":2", viewPort: "6080", controlPort: "6080" }); expect(options.HostConfig.ShmSize).toBeGreaterThanOrEqual(256 * 1024 * 1024); expect(options.User).toBe("1000:1000"); expect(options.HostConfig.CapDrop).toEqual(["ALL"]); expect(options.HostConfig.SecurityOpt).toEqual(["no-new-privileges:true"]); expect(options.HostConfig.PidsLimit).toBe(2048); expect(options.HostConfig.ReadonlyPaths).toContain("/usr/share/novnc"); expect(options.HostConfig.NetworkMode).toBe("rakazo_default"); }); it("still publishes host ports when NetworkMode is a per-bot isolated network", () => { const networkMode = computerNetworkNameFor("bot_isolation"); const options = containerCreateOptions({ name: containerNameFor("bot_isolation"), image: COMPUTER_IMAGE, botId: "bot_isolation", spaceId: "ws", homePath: "/var/rakazo/homes/bot_isolation", networkMode, }); expect(networkMode).toMatch(/^rakazo-computer-bot_isolation-[0-9a-f]{32}$/); expect(options.HostConfig.NetworkMode).toBe(networkMode); expect(options.HostConfig.PortBindings["6080/tcp"]).toEqual([ { HostIp: "127.0.0.1", HostPort: "0" }, ]); expect(options.ExposedPorts["6080/tcp"]).toEqual({}); }); it("keeps sanitized network names unique when botIds only differ by stripped characters", () => { expect(computerNetworkNameFor("a/b")).not.toBe(computerNetworkNameFor("ab")); expect(computerNetworkNameFor("a/b")).toBe(computerNetworkNameFor("a/b")); }); it("lists prior network name variants for cleanup", () => { const names = computerNetworkNamesForCleanup("bot_1"); expect(names[0]).toBe(computerNetworkNameFor("bot_1")); expect(names).toContain("rakazo-computer-bot_1"); expect(names.some((name) => /-[0-9a-f]{8}$/.test(name))).toBe(true); expect(names.some((name) => /-[0-9a-f]{32}$/.test(name))).toBe(true); }); it("skips legacy network removal when another bot is still attached", () => { expect(legacyNetworkOwnedSolelyBy("a/b", ["a/b", "a/b"])).toBe(true); expect(legacyNetworkOwnedSolelyBy("a/b", ["a/b", undefined])).toBe(false); expect(legacyNetworkOwnedSolelyBy("a/b", ["a/b", "ab"])).toBe(false); expect(legacyNetworkOwnedSolelyBy("ab", [undefined, undefined])).toBe(false); }); it("ships a browser desktop, not a fullscreen terminal", () => { const root = path.resolve(import.meta.dirname, "../../computer"); const dockerfile = readFileSync(path.join(root, "Dockerfile"), "utf8"); const start = readFileSync(path.join(root, "start.sh"), "utf8"); const browser = readFileSync(path.join(root, "rakazo-browser"), "utf8"); const desktop = readFileSync(path.join(root, "rakazo-browser.desktop"), "utf8"); expect(dockerfile).toMatch(/chromium/); expect(dockerfile).toMatch(/rakazo-browser\.desktop/); expect(dockerfile).toMatch(/control.py/); expect(dockerfile).toMatch(/USER 1000:1000/); expect(start).toMatch(/rakazo-computer-control/); expect(start).toMatch(/rakazo-browser/); expect(start).not.toMatch(/browser\.log/); expect(start).toMatch(/xdg-mime default rakazo-browser\.desktop/); expect(start).toMatch(/register_browser_handler x-scheme-handler\/http/); expect(start).toMatch(/register_browser_handler x-scheme-handler\/https/); expect(start).toMatch(/register_browser_handler text\/html/); expect(start).toMatch(/xdg-mime query default/); expect(start).toMatch(/failed to register rakazo-browser/); expect(start).toMatch(/failed to set default web browser/); expect(start).toMatch(/xdg-settings set default-web-browser rakazo-browser\.desktop/); expect(start).not.toMatch(/xdg-mime default rakazo-browser\.desktop .*\|\| true/); expect(start).toMatch(/x11vnc .* -viewonly /); expect(browser).toMatch(/\.browser-profiles\/chromium/); expect(browser).toMatch(/chromium-screen-\$DISPLAY_NUM/); expect(browser).toMatch(/USER_DATA_DIR_SET/); expect(browser).toMatch(/RAKAZO_BROWSER_PROFILE/); expect(desktop).toMatch(/Exec=\/usr\/local\/bin\/rakazo-browser %U/); expect(dockerfile).toMatch(/rakazo-page-browser/); expect(browser).toMatch(/remote-debugging-port/); expect(desktop).toMatch(/x-scheme-handler\/http/); expect(desktop).toMatch(/x-scheme-handler\/https/); expect(start).not.toMatch(/windowsize 1280 800/); }); it.skipIf(process.platform === "win32")( "selects a display-specific browser profile and preserves explicit profiles", () => { const root = path.resolve(import.meta.dirname, "../../computer"); const temp = mkdtempSync(path.join(tmpdir(), "rakazo-browser-wrapper-")); const bin = path.join(temp, "bin"); const capture = path.join(temp, "args"); const home = path.join(temp, "home"); const chromium = path.join(bin, "chromium"); mkdirSync(bin); writeFileSync(chromium, '#!/bin/sh\nprintf "%s\\n" "$@" > "$RAKAZO_TEST_ARGS"\n'); chmodSync(chromium, 0o755); const run = (display: string, args: string[] = []) => { const result = spawnSync("sh", [path.join(root, "rakazo-browser"), ...args], { env: { ...process.env, DISPLAY: display, HOME: home, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, RAKAZO_TEST_ARGS: capture, }, encoding: "utf8", }); expect(result.status, result.error?.message ?? result.stderr).toBe(0); return readFileSync(capture, "utf8").trim().split(/\r?\n/); }; try { expect(run(":1")).toContain(`--user-data-dir=${home}/.browser-profiles/chromium`); expect(run(":1").some((arg) => arg.startsWith("--remote-debugging-port="))).toBe(true); expect(run(":2")).toContain(`--user-data-dir=${home}/.browser-profiles/chromium-screen-2`); expect(run(":2")).toContain("--remote-debugging-port=9223"); for (const display of [8, 9]) { const args = run(`:0${display}.0`); expect(args).toContain(`--remote-debugging-port=${9221 + display}`); expect(args).toContain( `--user-data-dir=${home}/.browser-profiles/chromium-screen-${display}`, ); } const explicit = run(":3", [`--user-data-dir=${home}/custom-profile`]); expect(explicit).toContain(`--user-data-dir=${home}/custom-profile`); expect(explicit).not.toContain( `--user-data-dir=${home}/.browser-profiles/chromium-screen-3`, ); } finally { rmSync(temp, { recursive: true, force: true }); } }, ); it.skipIf(process.platform === "win32")( "clears crashed state from Chromium preferences and Local State", () => { const root = path.resolve(import.meta.dirname, "../../computer"); const temp = mkdtempSync(path.join(tmpdir(), "rakazo-browser-crash-")); const bin = path.join(temp, "bin"); const home = path.join(temp, "home"); const chromium = path.join(bin, "chromium"); const capture = path.join(temp, "args"); mkdirSync(bin); writeFileSync(chromium, '#!/bin/sh\nprintf "%s\\n" "$@" > "$RAKAZO_TEST_ARGS"\n'); chmodSync(chromium, 0o755); const profile = path.join(home, ".browser-profiles/chromium"); const prefsDir = path.join(profile, "Default"); mkdirSync(prefsDir, { recursive: true }); const prefsPath = path.join(prefsDir, "Preferences"); const localStatePath = path.join(profile, "Local State"); writeFileSync( prefsPath, '{\n "profile": {\n "exit_type": "Crashed",\n "exited_cleanly": false\n }\n}\n', ); writeFileSync(localStatePath, '{\n "profile": {\n "exited_cleanly": false\n }\n}\n'); try { const result = spawnSync("bash", [path.join(root, "rakazo-browser")], { env: { ...process.env, DISPLAY: ":1", HOME: home, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, RAKAZO_TEST_ARGS: capture, }, encoding: "utf8", }); expect(result.status, result.error?.message ?? result.stderr).toBe(0); const flags = readFileSync(capture, "utf8"); expect(flags).toMatch(/--hide-crash-restore-bubble/); expect(flags).toMatch(/--disable-session-crashed-bubble/); const updatedPrefs = readFileSync(prefsPath, "utf8"); expect(updatedPrefs).toContain('"exit_type":"Normal"'); expect(updatedPrefs).toContain('"exited_cleanly":true'); expect(updatedPrefs).not.toContain("Crashed"); expect(updatedPrefs).not.toMatch(/"exited_cleanly"\s*:\s*false/); const updatedLocalState = readFileSync(localStatePath, "utf8"); expect(updatedLocalState).toContain('"exited_cleanly":true'); expect(updatedLocalState).not.toMatch(/"exited_cleanly"\s*:\s*false/); writeFileSync(prefsPath, '{\n "profile": {\n "exit_type": "Crashed"\n }\n}\n'); symlinkSync("testhost-12345", path.join(profile, "SingletonLock")); const skipped = spawnSync("bash", [path.join(root, "rakazo-browser")], { env: { ...process.env, DISPLAY: ":1", HOME: home, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, RAKAZO_TEST_ARGS: capture, }, encoding: "utf8", }); expect(skipped.status, skipped.error?.message ?? skipped.stderr).toBe(0); expect(readFileSync(prefsPath, "utf8")).toContain("Crashed"); } finally { rmSync(temp, { recursive: true, force: true }); } }, ); it("keeps container names stable so a bot can resume", () => { expect(containerNameFor("bot_1")).toBe("rakazo-bot-bot_1"); expect(containerNameFor("bot_1")).toBe(containerNameFor("bot_1")); }); it("points the screen at the chrome-less noVNC embed", () => { expect(screenUrlFor("16080")).toBe("http://127.0.0.1:16080/embed.html"); }); it("wires host clipboard paste into the chrome-less embed", () => { const root = path.resolve(import.meta.dirname, "../../computer"); const embed = readFileSync(path.join(root, "embed.html"), "utf8"); expect(embed).toMatch(/clipboard-bridge\.js/); expect(embed).toMatch(/attachHostClipboardPaste/); }); it("uses the published host mapping in the default topology even when a container IP exists", () => { // Regression: per-bot NetworkMode always yields a 172.x address. Returning // that to clients makes local/dev screens look dead — browsers cannot load // docker-internal IPs. Probe and return the host mapping instead. const networkMode = computerNetworkNameFor("bot_1"); expect( resolveScreenPublishTarget({ screenNetwork: "published", networkMode, networks: { [networkMode]: { IPAddress: "172.18.0.4" } }, hostPort: "49152", containerPort: "6080", }), ).toEqual({ host: "127.0.0.1", port: "49152" }); expect( resolveScreenPublishTarget({ screenNetwork: "published", networkMode, networks: { [networkMode]: { IPAddress: "172.18.0.4" } }, hostPort: undefined, containerPort: "6080", }), ).toBeUndefined(); }); it("validates the configured screen network mode", () => { expect(resolveScreenNetworkMode(undefined)).toBe("published"); expect(resolveScreenNetworkMode("published")).toBe("published"); expect(resolveScreenNetworkMode("isolated")).toBe("isolated"); expect(() => resolveScreenNetworkMode("typo")).toThrow(/Unsupported/); }); it("uses the host identity for host-run bind mounts without ever using root", () => { expect(hostComputerUser(501, 20)).toBe("501:20"); expect(hostComputerUser(0, 0)).toBe("1000:1000"); }); it("uses the container IP only for the internal screen network topology", () => { const networkMode = "rakazo_default"; expect( resolveScreenPublishTarget({ screenNetwork: "internal", networkMode, networks: { [networkMode]: { IPAddress: "172.18.0.4" } }, hostPort: "49152", containerPort: "6080", }), ).toEqual({ host: "172.18.0.4", port: "6080" }); expect( resolveScreenPublishTarget({ screenNetwork: "isolated", networkMode: "rakazo-computer-bot-1", networks: { "rakazo-computer-bot-1": { IPAddress: "172.20.0.4" } }, hostPort: "49152", containerPort: "6080", }), ).toEqual({ host: "172.20.0.4", port: "6080" }); }); it("does not publish computer control port 7070 on the host", () => { const options = containerCreateOptions({ name: "rakazo-bot-ctrl", image: COMPUTER_IMAGE, botId: "ctrl", spaceId: "ws", homePath: "/var/rakazo/homes/ctrl", }); expect(options.HostConfig.PortBindings["7070/tcp"]).toBeUndefined(); expect(options.ExposedPorts["7070/tcp"]).toBeUndefined(); expect(JSON.stringify(options.HostConfig.PortBindings)).not.toMatch(/7070/); }); it("publishes the control port to loopback only when explicitly opted in", () => { const options = containerCreateOptions({ name: "rakazo-bot-ctrl", image: COMPUTER_IMAGE, botId: "ctrl", spaceId: "ws", homePath: "/var/rakazo/homes/ctrl", publishControlPort: true, }); expect(options.ExposedPorts["7070/tcp"]).toEqual({}); expect(options.HostConfig.PortBindings["7070/tcp"]).toEqual([ { HostIp: "127.0.0.1", HostPort: "0" }, ]); }); it("resolves computer control through the container network IP, never a host mapping", () => { const networkMode = "rakazo_default"; expect( resolveComputerControlEndpoint({ token: "secret", networkMode, networks: { [networkMode]: { IPAddress: "172.18.0.4" } }, }), ).toEqual({ url: "http://172.18.0.4:7070/v1/desktop", token: "secret" }); expect( resolveComputerControlEndpoint({ token: "secret", networkMode: computerNetworkNameFor("bot_1"), networks: { [computerNetworkNameFor("bot_1")]: { IPAddress: "172.19.0.2" } }, }), ).toEqual({ url: "http://172.19.0.2:7070/v1/desktop", token: "secret" }); expect( resolveComputerControlEndpoint({ token: undefined, networkMode, networks: { [networkMode]: { IPAddress: "172.18.0.4" } }, }), ).toBeUndefined(); expect( resolveComputerControlEndpoint({ token: "secret", networkMode, networks: {}, }), ).toBeUndefined(); }); it("resolves computer control through a published loopback port when provided", () => { const networkMode = "rakazo_default"; expect( resolveComputerControlEndpoint({ token: "secret", networkMode, networks: { [networkMode]: { IPAddress: "172.18.0.4" } }, publishedHostPort: "55101", }), ).toEqual({ url: "http://127.0.0.1:55101/v1/desktop", token: "secret" }); expect( resolveComputerControlEndpoint({ token: undefined, networkMode, networks: { [networkMode]: { IPAddress: "172.18.0.4" } }, publishedHostPort: "55101", }), ).toBeUndefined(); }); it.each([undefined, null, {}, { "7070/tcp": null }, { "7070/tcp": [] }])( "recreates unpublished computers only when loopback control is enabled (%j)", (bindings) => { expect(controlPortPublicationMatches(bindings, true)).toBe(false); expect(controlPortPublicationMatches(bindings, false)).toBe(true); expect(publishedLoopbackControlHostPort(bindings)).toBeUndefined(); }, ); it.each(["", "0", "55101"])( "accepts configured loopback ports on resume, including unassigned ports (%j)", (HostPort) => { const bindings = { "7070/tcp": [{ HostIp: "127.0.0.1", HostPort }] }; expect(controlPortPublicationMatches(bindings, true)).toBe(true); // Opting out must remove existing publication on the next provision. expect(controlPortPublicationMatches(bindings, false)).toBe(false); expect(publishedLoopbackControlHostPort(bindings)).toBe( HostPort === "55101" ? HostPort : undefined, ); }, ); it.each(["0.0.0.0", "", "::", "192.0.2.1", undefined])( "rejects external control bindings even alongside loopback (%j)", (HostIp) => { const external = { HostIp, HostPort: "55100" }; const loopback = { HostIp: "127.0.0.1", HostPort: "55101" }; for (const entries of [[external], [external, loopback], [loopback, external]]) { const bindings = { "7070/tcp": entries }; expect(controlPortPublicationMatches(bindings, true)).toBe(false); expect(controlPortPublicationMatches(bindings, false)).toBe(false); expect(publishedLoopbackControlHostPort(bindings)).toBeUndefined(); } }, ); it.each([undefined, "-1", "65536", "abc", "55101/other", "80@192.0.2.1"])( "rejects invalid configured and runtime control ports (%j)", (HostPort) => { const bindings = { "7070/tcp": [{ HostIp: "127.0.0.1", HostPort }] }; expect(controlPortPublicationMatches(bindings, true)).toBe(false); expect(publishedLoopbackControlHostPort(bindings)).toBeUndefined(); expect( resolveComputerControlEndpoint({ token: "test-token", networkMode: "bridge", networks: { bridge: { IPAddress: "192.0.2.1" } }, publishedHostPort: HostPort, requirePublishedHostPort: true, }), ).toBeUndefined(); }, ); it("does not fall back to the container IP when a published control port is required", () => { const networkMode = "rakazo_default"; expect( resolveComputerControlEndpoint({ token: "secret", networkMode, networks: { [networkMode]: { IPAddress: "172.18.0.4" } }, requirePublishedHostPort: true, }), ).toBeUndefined(); expect( resolveComputerControlEndpoint({ token: "secret", networkMode, networks: { [networkMode]: { IPAddress: "172.18.0.4" } }, publishedHostPort: "55101", requirePublishedHostPort: true, }), ).toEqual({ url: "http://127.0.0.1:55101/v1/desktop", token: "secret" }); }); it("restricts computer control argv to supervisor shapes", () => { const controlPath = path.resolve(import.meta.dirname, "../../computer/control.py"); const result = spawnSync( "python3", [ "-c", [ "import importlib.util", "import os", "import signal", "import time", `spec = importlib.util.spec_from_file_location('control', ${JSON.stringify(controlPath)})`, "module = importlib.util.module_from_spec(spec)", "spec.loader.exec_module(module)", "allow = module.allowed_control_argv", "long_lived = module.is_long_lived_control", "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'key', '--clearmodifiers', 'a'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'mousemove', '--', '10', '20', 'click', '1'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'click', '--repeat', '3', '4'], ':1')", "assert allow(['env', 'DISPLAY=:1', 'xdotool', 'type', '--clearmodifiers', '--', 'hi'], ':1')", "assert allow(['env', 'DISPLAY=:2', 'xdg-open', 'https://example.com'], ':2')", "assert allow(['env', 'DISPLAY=:1', 'rakazo-browser'], ':1')", "assert allow(['env', 'DISPLAY=:2', 'rakazo-browser', 'https://example.com'], ':2')", "assert allow(['env', 'DISPLAY=:1', 'xterm'], ':1')", "assert long_lived(['env', 'DISPLAY=:1', 'rakazo-browser'])", "assert long_lived(['env', 'DISPLAY=:1', 'rakazo-browser', 'https://example.com'])", "assert long_lived(['env', 'DISPLAY=:1', 'xterm'])", "assert long_lived(['env', 'DISPLAY=:1', 'xdg-open', 'https://example.com'])", "assert not long_lived(['env', 'DISPLAY=:1', 'xdotool', 'key', '--clearmodifiers', 'a'])", "assert not allow(['env', 'DISPLAY=:1', 'chromium', 'https://example.com'], ':1')", "assert not allow(['bash', '-c', 'id'], ':1')", "assert not allow(['env', 'DISPLAY=:1', 'bash', '-c', 'id'], ':1')", "assert not allow(['env', 'DISPLAY=:1', '/bin/sh', '-c', 'id'], ':1')", "assert not allow(['env', 'DISPLAY=:1', 'xdotool', 'exec', '/bin/sh', '-c', 'id'], ':1')", "assert not allow(['env', 'DISPLAY=:1', 'xdotool', 'key', 'a'], ':1')", "assert not allow(['env', 'DISPLAY=:2', 'xdotool', 'key', '--clearmodifiers', 'a'], ':1')", "assert not allow(['env', 'DISPLAY=wayland-0', 'xdotool', 'key', '--clearmodifiers', 'a'], ':1')", "assert not allow(['env', 'DISPLAY=:1', 'xdg-open', 'a', 'b'], ':1')", "known = set(module.KNOWN_LAUNCH)", "module.KNOWN_LAUNCH = frozenset({'sleep'})", "spawned = []", "real_popen = module.subprocess.Popen", "def tracking_popen(*args, **kwargs):", " child = real_popen(*args, **kwargs)", " spawned.append(child.pid)", " return child", "module.subprocess.Popen = tracking_popen", "try:", " started = time.monotonic()", " module.run_control_argv(['env', 'DISPLAY=:1', 'sleep', '30'], ':1')", " assert time.monotonic() - started < 2", " assert len(spawned) == 1, 'expected one detached child'", " os.kill(spawned[0], 0)", " os.kill(spawned[0], signal.SIGTERM)", "finally:", " module.subprocess.Popen = real_popen", " module.KNOWN_LAUNCH = frozenset(known)", "try:", " module.run_control_argv(['env', 'DISPLAY=:1', 'false'], ':1')", " raise SystemExit('expected nonzero failure')", "except RuntimeError as error:", " assert str(error) == 'computer action failed'", "timeout = module.CONTROL_TIMEOUT_SEC", "module.CONTROL_TIMEOUT_SEC = 0.2", "try:", " module.run_control_argv(['env', 'DISPLAY=:1', 'sleep', '5'], ':1')", " raise SystemExit('expected timeout')", "except RuntimeError as error:", " assert str(error) == 'computer action timed out'", "finally:", " module.CONTROL_TIMEOUT_SEC = timeout", "print('ok')", ].join("\n"), ], { encoding: "utf8" }, ); expect(result.status, result.stderr || result.stdout).toBe(0); expect(result.stdout.trim()).toBe("ok"); }); it("turns takeover input into xdotool", () => { expect(xdotoolCommand({ kind: "key", key: "Enter" })).toEqual([ "xdotool", "key", "--clearmodifiers", "Return", ]); expect(xdotoolCommand({ kind: "pointer", x: 10, y: 20, type: "click" })).toEqual([ "xdotool", "mousemove", "--", "10", "20", "click", "1", ]); }); }); describe("computer resource limits", () => { const KEYS = [ "RAKAZO_COMPUTER_MEMORY", "RAKAZO_COMPUTER_CPUS", "RAKAZO_COMPUTER_PIDS_LIMIT", ] as const; const saved = new Map(); beforeEach(() => { for (const k of KEYS) { saved.set(k, process.env[k]); delete process.env[k]; } }); afterEach(() => { for (const [k, v] of saved) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } }); const createInput = { name: "rakazo-bot-x", image: "rakazo/computer:local", botId: "bot-x", spaceId: "ws", homePath: "/var/rakazo/homes/bot-x", }; it("caps memory and cpu by default and keeps #343's pids ceiling", () => { const { HostConfig } = containerCreateOptions(createInput); expect(HostConfig.Memory).toBe(2 * 1024 ** 3); expect(HostConfig.MemorySwap).toBe(HostConfig.Memory); expect(HostConfig.NanoCpus).toBe(2 * 1e9); expect(HostConfig.PidsLimit).toBe(2048); }); it("falls back to the defaults when a limit is set but blank", () => { // .env.example ships these keys with no value, which loads as "" rather than undefined. for (const value of ["", " "]) { for (const k of KEYS) process.env[k] = value; const { HostConfig } = containerCreateOptions(createInput); expect(HostConfig.Memory).toBe(2 * 1024 ** 3); expect(HostConfig.NanoCpus).toBe(2 * 1e9); expect(HostConfig.PidsLimit).toBe(2048); } }); it("pins MemorySwap to Memory so the ceiling cannot be swapped past", () => { process.env.RAKAZO_COMPUTER_MEMORY = "1536m"; const { HostConfig } = containerCreateOptions(createInput); expect(HostConfig.Memory).toBe(1536 * 1024 ** 2); expect(HostConfig.MemorySwap).toBe(1536 * 1024 ** 2); }); it("accepts fractional CPUs", () => { process.env.RAKAZO_COMPUTER_CPUS = "1.5"; expect(containerCreateOptions(createInput).HostConfig.NanoCpus).toBe(1_500_000_000); }); it("lets an operator opt out explicitly", () => { process.env.RAKAZO_COMPUTER_MEMORY = "unlimited"; process.env.RAKAZO_COMPUTER_CPUS = "0"; process.env.RAKAZO_COMPUTER_PIDS_LIMIT = "none"; const { HostConfig } = containerCreateOptions(createInput); expect(HostConfig.Memory).toBe(0); expect(HostConfig.NanoCpus).toBe(0); expect(HostConfig.PidsLimit).toBe(0); }); it("rejects a malformed size instead of silently falling back", () => { process.env.RAKAZO_COMPUTER_MEMORY = "2 gigs"; expect(() => containerCreateOptions(createInput)).toThrow(/RAKAZO_COMPUTER_MEMORY/); }); it("rejects a negative cpu count", () => { process.env.RAKAZO_COMPUTER_CPUS = "-1"; expect(() => containerCreateOptions(createInput)).toThrow(/RAKAZO_COMPUTER_CPUS/); }); it("rejects a pids limit that is not a positive integer", () => { process.env.RAKAZO_COMPUTER_PIDS_LIMIT = "12.5"; expect(() => containerCreateOptions(createInput)).toThrow(/RAKAZO_COMPUTER_PIDS_LIMIT/); }); it("rejects a memory limit below Docker's 6 MiB minimum", () => { // The daemon refuses these at container creation, so accepting them here would turn a typo // into a 500 on the first bot rather than a startup failure naming the variable. for (const value of ["1", "1m", "5m", "5242880"]) { process.env.RAKAZO_COMPUTER_MEMORY = value; expect(() => containerCreateOptions(createInput)).toThrow(/RAKAZO_COMPUTER_MEMORY/); } process.env.RAKAZO_COMPUTER_MEMORY = "6m"; expect(containerCreateOptions(createInput).HostConfig.Memory).toBe(6 * 1024 ** 2); }); it("rejects a CPU count that would floor to Docker's unlimited", () => { // Math.floor(1e-10 * 1e9) is 0, and 0 NanoCpus means uncapped. An accepted value must never // turn a ceiling into no ceiling. process.env.RAKAZO_COMPUTER_CPUS = "0.0000000001"; expect(() => containerCreateOptions(createInput)).toThrow(/RAKAZO_COMPUTER_CPUS/); }); it("rejects a CPU count that leaves the safe-integer NanoCpus range", () => { // 1e300 is finite, but Math.floor(1e300 * 1e9) is Infinity. 1e7 CPUs yields a non-safe // integer. Both must fail closed rather than reach HostConfig.NanoCpus. for (const value of ["1e300", "10000000"]) { process.env.RAKAZO_COMPUTER_CPUS = value; expect(() => containerCreateOptions(createInput)).toThrow(/RAKAZO_COMPUTER_CPUS/); } }); it("parses byte counts without a unit suffix", () => { expect(parseMemoryBytes("X", "1073741824")).toBe(1024 ** 3); }); }); describe("computer home storage", () => { const runtime = (mount: object) => ({ Mounts: [mount] }) as Docker.ContainerInspectInfo; it("keeps named volumes native and exposes only the bot subdirectory", () => { const storage = computerHomeStorage( "/data/homes/bot", "/data", runtime({ Type: "volume", Name: "example_appdata", Source: "/var/lib/docker/volumes/example_appdata/_data", Destination: "/data", }), ); const options = containerCreateOptions({ name: "bot", image: "computer", botId: "bot", spaceId: "space", ...storage, }); expect(options.HostConfig.Binds).toBeUndefined(); expect(options.HostConfig.Mounts).toEqual([ { Type: "volume", Source: "example_appdata", Target: "/home/rakazo", VolumeOptions: { NoCopy: true, Subpath: "homes/bot" }, }, ]); expect(homeVolumeMatches(options.HostConfig.Mounts, storage.homeVolume!)).toBe(true); expect(homeVolumeMatches(undefined, storage.homeVolume!)).toBe(false); expect( homeVolumeMatches(options.HostConfig.Mounts, { name: "example_appdata", subpath: "homes/other", }), ).toBe(false); }); it("preserves native host paths and translates supervisor bind mounts", () => { expect(computerHomeStorage("/data/homes/bot", "/data", undefined)).toEqual({ homePath: "/data/homes/bot", }); expect( computerHomeStorage( "/data/homes/bot", "/data", runtime({ Type: "bind", Source: "/srv/data", Destination: "/data" }), ), ).toEqual({ homePath: "/srv/data/homes/bot" }); }); it("rejects paths outside the volume and missing volume names", () => { for (const home of ["/data", "/other", "/data/../other"]) expect(() => computerHomeStorage(home, "/data", undefined)).toThrow(); expect(() => computerHomeStorage( "/data/homes/bot", "/data", runtime({ Type: "volume", Destination: "/data" }), ), ).toThrow(/no name/); }); it("fails closed on daemons that could ignore volume subpaths", () => { for (const version of ["1.44", "", "invalid", "0.99"]) expect(() => assertVolumeSubpathSupport(version)).toThrow(/Docker Engine 26/); for (const version of ["1.45", "1.46", "2.0"]) expect(() => assertVolumeSubpathSupport(version)).not.toThrow(); }); it("stops promptly when Docker sends SIGTERM to the start script", () => { const root = path.resolve(import.meta.dirname, "../../computer"); const start = readFileSync(path.join(root, "start.sh"), "utf8"); expect(start).toMatch(/trap shutdown TERM INT/); expect(start).toMatch(/kill -TERM "\$XVFB_PID"/); expect(start).not.toMatch(/while kill -0 "\$XVFB_PID"/); // The handler must be in place before the first child process starts, so a stop that // arrives during startup is honoured instead of waiting for Docker's grace period. const trapAt = start.indexOf("trap shutdown TERM INT"); const firstChildAt = start.search(/^[^#\n]*&\s*$/m); expect(trapAt).toBeGreaterThan(-1); expect(firstChildAt).toBeGreaterThan(-1); expect(trapAt).toBeLessThan(firstChildAt); // A stop before Xvfb exists must not try to signal or wait on an empty PID. expect(start).toMatch(/^XVFB_PID=""$/m); expect(start.match(/if \[\[ -n "\$XVFB_PID" \]\]; then/g)?.length ?? 0).toBeGreaterThanOrEqual( 2, ); // Steady state waits on Xvfb instead of polling, so the trap runs immediately. expect(start).toMatch(/^wait "\$XVFB_PID"$/m); }); });