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.
831 lines
34 KiB
TypeScript
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);
|
|
});
|
|
});
|