evOo-local/infra/sandboxes/supervisor/src/computer-spec.test.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

831 lines
34 KiB
TypeScript

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<string, string | undefined>();
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("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);
});
});