@autifyhq/aximo-cli 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/dist/commands/device/device-config.d.ts +2 -2
- package/dist/commands/device/device-config.js +5 -8
- package/dist/commands/device/index.js +2 -1
- package/dist/commands/device/paths.d.ts +4 -0
- package/dist/commands/device/paths.js +12 -0
- package/dist/commands/device/reset.d.ts +1 -0
- package/dist/commands/device/reset.js +18 -3
- package/dist/commands/device/reset.test.js +13 -0
- package/dist/commands/device/testbox/box.d.ts +90 -0
- package/dist/commands/device/testbox/box.js +379 -0
- package/dist/commands/device/testbox/box.test.d.ts +1 -0
- package/dist/commands/device/testbox/box.test.js +815 -0
- package/dist/commands/device/testbox/container.d.ts +58 -0
- package/dist/commands/device/testbox/container.js +289 -0
- package/dist/commands/device/testbox/docker.d.ts +31 -0
- package/dist/commands/device/testbox/docker.js +78 -0
- package/dist/commands/device/testbox/docker.test.d.ts +1 -0
- package/dist/commands/device/testbox/docker.test.js +82 -0
- package/dist/commands/device/testbox/down.d.ts +7 -0
- package/dist/commands/device/testbox/down.js +14 -0
- package/dist/commands/device/testbox/errors.d.ts +4 -0
- package/dist/commands/device/testbox/errors.js +7 -0
- package/dist/commands/device/testbox/fake-docker.d.ts +25 -0
- package/dist/commands/device/testbox/fake-docker.js +35 -0
- package/dist/commands/device/testbox/index.d.ts +2 -0
- package/dist/commands/device/testbox/index.js +16 -0
- package/dist/commands/device/testbox/logs.d.ts +15 -0
- package/dist/commands/device/testbox/logs.js +43 -0
- package/dist/commands/device/testbox/prune.d.ts +11 -0
- package/dist/commands/device/testbox/prune.js +17 -0
- package/dist/commands/device/testbox/registration.d.ts +9 -0
- package/dist/commands/device/testbox/registration.js +56 -0
- package/dist/commands/device/testbox/reset.d.ts +7 -0
- package/dist/commands/device/testbox/reset.js +14 -0
- package/dist/commands/device/testbox/result.d.ts +8 -0
- package/dist/commands/device/testbox/result.js +23 -0
- package/dist/commands/device/testbox/result.test.d.ts +1 -0
- package/dist/commands/device/testbox/result.test.js +34 -0
- package/dist/commands/device/testbox/self-command.d.ts +12 -0
- package/dist/commands/device/testbox/self-command.js +36 -0
- package/dist/commands/device/testbox/self-command.test.d.ts +1 -0
- package/dist/commands/device/testbox/self-command.test.js +58 -0
- package/dist/commands/device/testbox/status.d.ts +7 -0
- package/dist/commands/device/testbox/status.js +11 -0
- package/dist/commands/device/testbox/up.d.ts +47 -0
- package/dist/commands/device/testbox/up.js +76 -0
- package/dist/commands/device/testbox/up.test.d.ts +1 -0
- package/dist/commands/device/testbox/up.test.js +52 -0
- package/dist/commands/device/testbox/url.d.ts +7 -0
- package/dist/commands/device/testbox/url.js +20 -0
- package/dist/commands/device/user-devices.d.ts +4 -0
- package/dist/commands/device/user-devices.js +18 -0
- package/dist/commands/mcp/server.d.ts +2 -2
- package/dist/commands/mcp/server.test.js +7 -2
- package/dist/commands/mcp/testbox-tools.test.d.ts +1 -0
- package/dist/commands/mcp/testbox-tools.test.js +278 -0
- package/dist/commands/mcp/tools.d.ts +8 -1
- package/dist/commands/mcp/tools.js +171 -20
- package/dist/commands/mcp/tools.test.js +59 -2
- package/dist/commands/schema.js +5 -1
- package/dist/commands/session/cancel.d.ts +20 -0
- package/dist/commands/session/cancel.js +39 -0
- package/dist/commands/session/index.js +1 -0
- package/dist/commands/session/session.test.js +38 -0
- package/dist/commands/trigger/plan/run.d.ts +1 -1
- package/dist/commands/trigger/plan/run.js +4 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +5 -0
- package/dist/errors.test.js +6 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type DockerRunner } from "./docker.js";
|
|
2
|
+
export declare const DEFAULT_BOX_NAME = "default";
|
|
3
|
+
export declare const TESTBOX_IMAGE = "ghcr.io/autifyhq/aximo-testbox";
|
|
4
|
+
export declare const TESTBOX_IMAGE_TAG = "0.1.1";
|
|
5
|
+
export declare const DEFAULT_NOVNC_PORT = 6080;
|
|
6
|
+
export declare const DEFAULT_SCREEN = "1280x720";
|
|
7
|
+
export declare const RUN_DIR = "/run/aximo";
|
|
8
|
+
export declare const AGENT_LOG = "/var/log/aximo/agent.log";
|
|
9
|
+
export declare const MAX_PORTS = 32;
|
|
10
|
+
export declare const BOX_NAME_RE: RegExp;
|
|
11
|
+
export interface TestboxConfig {
|
|
12
|
+
image: string;
|
|
13
|
+
ports: string;
|
|
14
|
+
blockHosts: string[];
|
|
15
|
+
startUrl: string | undefined;
|
|
16
|
+
network: "bridge" | "host";
|
|
17
|
+
novncPort: number;
|
|
18
|
+
screen: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function validateBoxName(name: string | undefined): string;
|
|
21
|
+
export declare function containerName(name: string): string;
|
|
22
|
+
export declare function profileVolume(name: string): string;
|
|
23
|
+
export declare function legacyVolumes(name: string): string[];
|
|
24
|
+
/** Flag, then AXIMO_TESTBOX_IMAGE, then the running box, then the published tag. */
|
|
25
|
+
export declare function resolveImage(explicit?: string, previous?: string): string;
|
|
26
|
+
export declare function parsePorts(spec: string): number[];
|
|
27
|
+
/** The live view's own ports cannot also be mirrored into the box. */
|
|
28
|
+
export declare function reservedPorts(cfg: TestboxConfig): number[];
|
|
29
|
+
export declare function validateScreen(spec: string): string;
|
|
30
|
+
export declare function validateNovncPort(port: number, network: TestboxConfig["network"]): void;
|
|
31
|
+
export interface ContainerInfo {
|
|
32
|
+
running: boolean;
|
|
33
|
+
image: string;
|
|
34
|
+
config: TestboxConfig | null;
|
|
35
|
+
}
|
|
36
|
+
export declare function inspectContainer(run: DockerRunner, container: string): Promise<ContainerInfo | null>;
|
|
37
|
+
export declare function nextNovncPort(run: DockerRunner): Promise<number>;
|
|
38
|
+
export declare function runArgs(name: string, cfg: TestboxConfig): string[];
|
|
39
|
+
export declare function ensureImage(run: DockerRunner, image: string): Promise<void>;
|
|
40
|
+
export declare function stopAgent(run: DockerRunner, container: string): Promise<void>;
|
|
41
|
+
export declare function removeContainer(run: DockerRunner, container: string): Promise<void>;
|
|
42
|
+
export declare function containerLog(run: DockerRunner, container: string, lines: number): Promise<string>;
|
|
43
|
+
export declare function relayUrl(baseUrl: string, network: TestboxConfig["network"]): string;
|
|
44
|
+
export declare function readInContainer(run: DockerRunner, container: string, path: string): Promise<string | null>;
|
|
45
|
+
export interface AgentIdentity {
|
|
46
|
+
deviceToken: string;
|
|
47
|
+
deviceName: string;
|
|
48
|
+
baseUrl: string;
|
|
49
|
+
}
|
|
50
|
+
export declare function injectAgent(run: DockerRunner, container: string, identity: AgentIdentity, network: TestboxConfig["network"], agent: {
|
|
51
|
+
scriptPath: string;
|
|
52
|
+
requirementsPath: string;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
export declare function liveViewUrl(run: DockerRunner, container: string, novncPort: number): Promise<string | undefined>;
|
|
55
|
+
export declare function probePorts(run: DockerRunner, container: string, spec: string): Promise<{
|
|
56
|
+
port: number;
|
|
57
|
+
reachable: boolean;
|
|
58
|
+
}[]>;
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { arch } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getOfflineDir } from "../../../desktop-server/offline.js";
|
|
5
|
+
import { DockerCommandError, docker } from "./docker.js";
|
|
6
|
+
import { TestboxError } from "./errors.js";
|
|
7
|
+
export const DEFAULT_BOX_NAME = "default";
|
|
8
|
+
export const TESTBOX_IMAGE = "ghcr.io/autifyhq/aximo-testbox";
|
|
9
|
+
// Must match testbox/image/TAG; the image is released on its own cadence.
|
|
10
|
+
export const TESTBOX_IMAGE_TAG = "0.1.1";
|
|
11
|
+
export const DEFAULT_NOVNC_PORT = 6080;
|
|
12
|
+
// The agent reads screenshots at 1000 px wide at most, so a bigger screen only
|
|
13
|
+
// shrinks the text it sees; 1280x720 is also what Aximo's cloud desktop runs.
|
|
14
|
+
export const DEFAULT_SCREEN = "1280x720";
|
|
15
|
+
export const RUN_DIR = "/run/aximo";
|
|
16
|
+
export const AGENT_LOG = "/var/log/aximo/agent.log";
|
|
17
|
+
const BASE_DISPLAY = 99;
|
|
18
|
+
const BASE_VNC_PORT = 5900;
|
|
19
|
+
// Each mirrored port is a supervised socat; an open-ended range would be a fork bomb.
|
|
20
|
+
export const MAX_PORTS = 32;
|
|
21
|
+
const PIDS_LIMIT = 2048;
|
|
22
|
+
const LABEL = "aximo.testbox";
|
|
23
|
+
const CONFIG_LABEL = "aximo.testbox.config";
|
|
24
|
+
// The box name becomes a host file name, a container name and a volume name.
|
|
25
|
+
export const BOX_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
26
|
+
export function validateBoxName(name) {
|
|
27
|
+
const value = name ?? DEFAULT_BOX_NAME;
|
|
28
|
+
if (!BOX_NAME_RE.test(value)) {
|
|
29
|
+
throw new TestboxError(`Invalid testbox name "${value}": use letters, digits, '.', '_' or '-' (max 64 chars)`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export function containerName(name) {
|
|
34
|
+
return name === DEFAULT_BOX_NAME ? "aximo-testbox" : `aximo-testbox-${name}`;
|
|
35
|
+
}
|
|
36
|
+
export function profileVolume(name) {
|
|
37
|
+
return `${containerName(name)}-profile`;
|
|
38
|
+
}
|
|
39
|
+
// The Power's script kept the API key and an npm cache in these; they go when
|
|
40
|
+
// the box is recreated or pruned.
|
|
41
|
+
export function legacyVolumes(name) {
|
|
42
|
+
return [`${containerName(name)}-aximo`, `${containerName(name)}-npm`];
|
|
43
|
+
}
|
|
44
|
+
/** Flag, then AXIMO_TESTBOX_IMAGE, then the running box, then the published tag. */
|
|
45
|
+
export function resolveImage(explicit, previous) {
|
|
46
|
+
return (explicit ??
|
|
47
|
+
process.env["AXIMO_TESTBOX_IMAGE"] ??
|
|
48
|
+
previous ??
|
|
49
|
+
`${TESTBOX_IMAGE}:${TESTBOX_IMAGE_TAG}`);
|
|
50
|
+
}
|
|
51
|
+
export function parsePorts(spec) {
|
|
52
|
+
const ports = [];
|
|
53
|
+
for (const item of spec.split(",")) {
|
|
54
|
+
const part = item.trim();
|
|
55
|
+
if (!part)
|
|
56
|
+
continue;
|
|
57
|
+
const match = /^(\d{1,5})(?:-(\d{1,5}))?$/.exec(part);
|
|
58
|
+
const lo = match ? Number(match[1]) : 0;
|
|
59
|
+
const hi = match ? Number(match[2] ?? match[1]) : 0;
|
|
60
|
+
if (!match || lo < 1 || hi > 65535 || hi < lo) {
|
|
61
|
+
throw new TestboxError(`Invalid port spec "${spec}": use e.g. 3000 or 3000,5173-5180`);
|
|
62
|
+
}
|
|
63
|
+
for (let p = lo; p <= hi; p++)
|
|
64
|
+
ports.push(p);
|
|
65
|
+
}
|
|
66
|
+
if (ports.length === 0)
|
|
67
|
+
throw new TestboxError("At least one port is required");
|
|
68
|
+
if (ports.length > MAX_PORTS) {
|
|
69
|
+
throw new TestboxError(`Too many ports (${ports.length}); the box mirrors at most ${MAX_PORTS}`);
|
|
70
|
+
}
|
|
71
|
+
return ports;
|
|
72
|
+
}
|
|
73
|
+
/** The live view's own ports cannot also be mirrored into the box. */
|
|
74
|
+
export function reservedPorts(cfg) {
|
|
75
|
+
const offset = cfg.novncPort - DEFAULT_NOVNC_PORT;
|
|
76
|
+
return cfg.network === "host"
|
|
77
|
+
? [cfg.novncPort, BASE_VNC_PORT + offset]
|
|
78
|
+
: [cfg.novncPort, BASE_VNC_PORT];
|
|
79
|
+
}
|
|
80
|
+
export function validateScreen(spec) {
|
|
81
|
+
const match = /^(\d{3,4})x(\d{3,4})$/.exec(spec.trim());
|
|
82
|
+
const width = match ? Number(match[1]) : 0;
|
|
83
|
+
const height = match ? Number(match[2]) : 0;
|
|
84
|
+
if (!match || width < 800 || width > 3840 || height < 600 || height > 2160) {
|
|
85
|
+
throw new TestboxError(`Invalid screen "${spec}": use WIDTHxHEIGHT between 800x600 and 3840x2160, e.g. 1280x720 or 1920x1080`);
|
|
86
|
+
}
|
|
87
|
+
return `${width}x${height}`;
|
|
88
|
+
}
|
|
89
|
+
export function validateNovncPort(port, network) {
|
|
90
|
+
// In host mode the X display and VNC port are derived from this number.
|
|
91
|
+
const min = network === "host" ? DEFAULT_NOVNC_PORT : 1024;
|
|
92
|
+
if (!Number.isInteger(port) || port < min || port > 65535) {
|
|
93
|
+
throw new TestboxError(`Invalid noVNC port ${port}: use an integer between ${min} and 65535`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function inspectContainer(run, container) {
|
|
97
|
+
const result = await run([
|
|
98
|
+
"inspect",
|
|
99
|
+
"-f",
|
|
100
|
+
"{{.State.Running}}|{{.Config.Image}}|{{json .Config.Labels}}",
|
|
101
|
+
container,
|
|
102
|
+
]);
|
|
103
|
+
if (result.code !== 0) {
|
|
104
|
+
// Only a confirmed missing container is "absent"; a daemon that is down
|
|
105
|
+
// or refusing the socket must not read as a stopped box.
|
|
106
|
+
if (/no such (object|container)/i.test(result.stderr))
|
|
107
|
+
return null;
|
|
108
|
+
throw new DockerCommandError(["inspect", container], result);
|
|
109
|
+
}
|
|
110
|
+
const [running = "", image = "", ...rest] = result.stdout.trim().split("|");
|
|
111
|
+
let config = null;
|
|
112
|
+
try {
|
|
113
|
+
const labels = JSON.parse(rest.join("|") || "{}") ?? {};
|
|
114
|
+
const raw = labels[CONFIG_LABEL];
|
|
115
|
+
config = raw ? JSON.parse(raw) : null;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
config = null;
|
|
119
|
+
}
|
|
120
|
+
return { running: running === "true", image, config };
|
|
121
|
+
}
|
|
122
|
+
export async function nextNovncPort(run) {
|
|
123
|
+
const out = await docker(run, [
|
|
124
|
+
"ps",
|
|
125
|
+
"-a",
|
|
126
|
+
"--filter",
|
|
127
|
+
`label=${LABEL}`,
|
|
128
|
+
"--format",
|
|
129
|
+
`{{.Label "${CONFIG_LABEL}"}}`,
|
|
130
|
+
]);
|
|
131
|
+
const used = new Set();
|
|
132
|
+
for (const line of out.split("\n")) {
|
|
133
|
+
try {
|
|
134
|
+
const cfg = JSON.parse(line);
|
|
135
|
+
if (typeof cfg.novncPort === "number")
|
|
136
|
+
used.add(cfg.novncPort);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// not a testbox container
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
let port = DEFAULT_NOVNC_PORT;
|
|
143
|
+
while (used.has(port))
|
|
144
|
+
port += 1;
|
|
145
|
+
return port;
|
|
146
|
+
}
|
|
147
|
+
export function runArgs(name, cfg) {
|
|
148
|
+
const offset = cfg.novncPort - DEFAULT_NOVNC_PORT;
|
|
149
|
+
const env = {
|
|
150
|
+
AXIMO_TESTBOX_PORTS: cfg.ports,
|
|
151
|
+
AXIMO_TESTBOX_NETWORK: cfg.network,
|
|
152
|
+
AXIMO_TESTBOX_NOVNC_PORT: String(cfg.novncPort),
|
|
153
|
+
AXIMO_TESTBOX_SCREEN: cfg.screen,
|
|
154
|
+
AXIMO_TESTBOX_BLOCK_HOSTS: cfg.blockHosts.join(","),
|
|
155
|
+
};
|
|
156
|
+
if (cfg.startUrl)
|
|
157
|
+
env["AXIMO_TESTBOX_START_URL"] = cfg.startUrl;
|
|
158
|
+
const args = [
|
|
159
|
+
"run",
|
|
160
|
+
"-d",
|
|
161
|
+
"--init",
|
|
162
|
+
"--name",
|
|
163
|
+
containerName(name),
|
|
164
|
+
"--label",
|
|
165
|
+
`${LABEL}=${name}`,
|
|
166
|
+
"--label",
|
|
167
|
+
`${CONFIG_LABEL}=${JSON.stringify(cfg)}`,
|
|
168
|
+
"--cap-drop",
|
|
169
|
+
"ALL",
|
|
170
|
+
...["CHOWN", "SETUID", "SETGID", "DAC_OVERRIDE", "FOWNER"].flatMap((c) => ["--cap-add", c]),
|
|
171
|
+
"--security-opt",
|
|
172
|
+
"no-new-privileges",
|
|
173
|
+
"--pids-limit",
|
|
174
|
+
String(PIDS_LIMIT),
|
|
175
|
+
"--shm-size=2g",
|
|
176
|
+
"--tmpfs",
|
|
177
|
+
`${RUN_DIR}:rw,size=64m`,
|
|
178
|
+
"-v",
|
|
179
|
+
`${profileVolume(name)}:/profile`,
|
|
180
|
+
];
|
|
181
|
+
if (cfg.network === "host") {
|
|
182
|
+
// Every box on a host network shares one X socket namespace and one port
|
|
183
|
+
// space, so each takes its own display and ports.
|
|
184
|
+
env["AXIMO_TESTBOX_DISPLAY"] = String(BASE_DISPLAY + 1 + offset);
|
|
185
|
+
env["AXIMO_TESTBOX_VNC_PORT"] = String(BASE_VNC_PORT + offset);
|
|
186
|
+
args.push("--network", "host");
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
args.push("--add-host=host.docker.internal:host-gateway", "-p", `127.0.0.1:${cfg.novncPort}:${cfg.novncPort}`);
|
|
190
|
+
}
|
|
191
|
+
for (const [k, v] of Object.entries(env))
|
|
192
|
+
args.push("-e", `${k}=${v}`);
|
|
193
|
+
args.push(cfg.image);
|
|
194
|
+
return args;
|
|
195
|
+
}
|
|
196
|
+
function offlineImageTarball() {
|
|
197
|
+
const dir = getOfflineDir();
|
|
198
|
+
if (!dir)
|
|
199
|
+
return undefined;
|
|
200
|
+
const platform = arch() === "arm64" ? "linux-arm64" : "linux-amd64";
|
|
201
|
+
const path = join(dir, "testbox", `aximo-testbox-${platform}.tar`);
|
|
202
|
+
return existsSync(path) ? path : undefined;
|
|
203
|
+
}
|
|
204
|
+
export async function ensureImage(run, image) {
|
|
205
|
+
if ((await run(["image", "inspect", image])).code === 0)
|
|
206
|
+
return;
|
|
207
|
+
const tarball = offlineImageTarball();
|
|
208
|
+
if (tarball) {
|
|
209
|
+
await docker(run, ["load", "-i", tarball], { timeoutMs: 0 });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const result = await run(["pull", image], { timeoutMs: 0 });
|
|
213
|
+
if (result.code !== 0) {
|
|
214
|
+
throw new TestboxError(`Could not pull ${image}: ${result.stderr.trim()}. Set AXIMO_TESTBOX_IMAGE to a mirror, or stage a tarball under $AXIMO_OFFLINE_DIR/testbox/.`, true);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
export async function stopAgent(run, container) {
|
|
218
|
+
await run(["exec", container, "pkill", "-INT", "-f", "remote.py"]);
|
|
219
|
+
}
|
|
220
|
+
export async function removeContainer(run, container) {
|
|
221
|
+
await stopAgent(run, container);
|
|
222
|
+
await docker(run, ["rm", "-f", container]);
|
|
223
|
+
}
|
|
224
|
+
export async function containerLog(run, container, lines) {
|
|
225
|
+
const result = await run(["logs", "--tail", String(lines), container]);
|
|
226
|
+
return `${result.stdout}${result.stderr}`.trim();
|
|
227
|
+
}
|
|
228
|
+
export function relayUrl(baseUrl, network) {
|
|
229
|
+
const url = new URL(baseUrl);
|
|
230
|
+
// A dev server on the laptop is only reachable from the bridge by this name.
|
|
231
|
+
if (network === "bridge" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) {
|
|
232
|
+
url.hostname = "host.docker.internal";
|
|
233
|
+
}
|
|
234
|
+
return `${url.toString().replace(/\/+$/, "").replace(/^http/, "ws")}/devices/tunnel`;
|
|
235
|
+
}
|
|
236
|
+
export async function readInContainer(run, container, path) {
|
|
237
|
+
const result = await run(["exec", container, "cat", path]);
|
|
238
|
+
return result.code === 0 ? result.stdout : null;
|
|
239
|
+
}
|
|
240
|
+
async function writeInContainer(run, container, path, content, mode) {
|
|
241
|
+
await docker(run, ["exec", "-i", container, "sh", "-c", `umask ${mode} && cat > '${path}'`], {
|
|
242
|
+
input: content,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// Everything the agent needs goes in over stdin: nothing lands in the image,
|
|
246
|
+
// `docker inspect`, argv or a volume. `docker cp` cannot write into a tmpfs.
|
|
247
|
+
export async function injectAgent(run, container, identity, network, agent) {
|
|
248
|
+
await writeInContainer(run, container, `${RUN_DIR}/requirements.txt`, readFileSync(agent.requirementsPath, "utf-8"), "022");
|
|
249
|
+
await writeInContainer(run, container, `${RUN_DIR}/remote.py`, readFileSync(agent.scriptPath, "utf-8"), "022");
|
|
250
|
+
const gateway = relayUrl(identity.baseUrl, network);
|
|
251
|
+
const env = [
|
|
252
|
+
`GATEWAY_URL=${gateway}`,
|
|
253
|
+
`RELAY_URL=${gateway}`,
|
|
254
|
+
`DEVICE_TOKEN=${identity.deviceToken}`,
|
|
255
|
+
`DEVICE_NAME=${identity.deviceName}`,
|
|
256
|
+
"",
|
|
257
|
+
].join("\n");
|
|
258
|
+
const current = await readInContainer(run, container, `${RUN_DIR}/agent.env`);
|
|
259
|
+
if (current === env)
|
|
260
|
+
return;
|
|
261
|
+
await writeInContainer(run, container, `${RUN_DIR}/agent.env`, env, "077");
|
|
262
|
+
// The supervisor relaunches the agent with the new env.
|
|
263
|
+
if (current !== null)
|
|
264
|
+
await stopAgent(run, container);
|
|
265
|
+
}
|
|
266
|
+
export async function liveViewUrl(run, container, novncPort) {
|
|
267
|
+
const password = await readInContainer(run, container, "/profile/.vncpass");
|
|
268
|
+
if (password === null)
|
|
269
|
+
return undefined;
|
|
270
|
+
return `http://127.0.0.1:${novncPort}/vnc.html?autoconnect=1&resize=scale&password=${password.trim()}`;
|
|
271
|
+
}
|
|
272
|
+
export async function probePorts(run, container, spec) {
|
|
273
|
+
return Promise.all(parsePorts(spec)
|
|
274
|
+
.slice(0, 10)
|
|
275
|
+
.map(async (port) => {
|
|
276
|
+
const result = await run([
|
|
277
|
+
"exec",
|
|
278
|
+
container,
|
|
279
|
+
"curl",
|
|
280
|
+
"-s",
|
|
281
|
+
"-o",
|
|
282
|
+
"/dev/null",
|
|
283
|
+
"--max-time",
|
|
284
|
+
"3",
|
|
285
|
+
`http://127.0.0.1:${port}/`,
|
|
286
|
+
], { timeoutMs: 5_000 });
|
|
287
|
+
return { port, reachable: result.code === 0 };
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface DockerResult {
|
|
2
|
+
code: number | null;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
timedOut: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface DockerRunOptions {
|
|
8
|
+
/** Piped to the child's stdin and closed; how secrets reach the container. */
|
|
9
|
+
input?: string;
|
|
10
|
+
/** 0 disables the limit (pulls); everything else must not hang an MCP call. */
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare const DEFAULT_DOCKER_TIMEOUT_MS = 60000;
|
|
14
|
+
export type DockerRunner = (args: string[], opts?: DockerRunOptions) => Promise<DockerResult>;
|
|
15
|
+
export declare const createDockerRunner: (binary: string) => DockerRunner;
|
|
16
|
+
export declare const runDocker: DockerRunner;
|
|
17
|
+
export declare class DockerCommandError extends Error {
|
|
18
|
+
constructor(args: string[], result: DockerResult);
|
|
19
|
+
}
|
|
20
|
+
export declare function docker(run: DockerRunner, args: string[], opts?: DockerRunOptions): Promise<string>;
|
|
21
|
+
export declare const DOCKER_INSTALL_URL = "https://docs.docker.com/get-docker/";
|
|
22
|
+
export interface DockerInfo {
|
|
23
|
+
desktop: boolean;
|
|
24
|
+
rootless: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface DockerProblem {
|
|
27
|
+
kind: "missing" | "unreachable" | "denied";
|
|
28
|
+
message: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function isDockerProblem(value: DockerInfo | DockerProblem): value is DockerProblem;
|
|
31
|
+
export declare function preflightDocker(run: DockerRunner): Promise<DockerInfo | DockerProblem>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { TestboxError } from "./errors.js";
|
|
3
|
+
export const DEFAULT_DOCKER_TIMEOUT_MS = 60_000;
|
|
4
|
+
// Explicit argv, no shell. Rejects only when the binary cannot be started
|
|
5
|
+
// (ENOENT); a non-zero exit is a result the caller inspects.
|
|
6
|
+
export const createDockerRunner = (binary) => (args, opts = {}) => new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(binary, args, {
|
|
8
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
9
|
+
windowsHide: true,
|
|
10
|
+
timeout: opts.timeoutMs ?? DEFAULT_DOCKER_TIMEOUT_MS,
|
|
11
|
+
});
|
|
12
|
+
let stdout = "";
|
|
13
|
+
let stderr = "";
|
|
14
|
+
child.stdout.setEncoding("utf-8").on("data", (chunk) => (stdout += chunk));
|
|
15
|
+
child.stderr.setEncoding("utf-8").on("data", (chunk) => (stderr += chunk));
|
|
16
|
+
child.on("error", (err) => {
|
|
17
|
+
// Every command, not only the preflighted ones, should say how to install Docker.
|
|
18
|
+
reject(err.code === "ENOENT"
|
|
19
|
+
? Object.assign(new TestboxError(DOCKER_MISSING_MESSAGE), { code: "ENOENT" })
|
|
20
|
+
: err);
|
|
21
|
+
});
|
|
22
|
+
child.on("close", (code, signal) => {
|
|
23
|
+
resolve({ code, stdout, stderr, timedOut: code === null && signal !== null });
|
|
24
|
+
});
|
|
25
|
+
child.stdin.on("error", () => { });
|
|
26
|
+
child.stdin.end(opts.input ?? "");
|
|
27
|
+
});
|
|
28
|
+
export const runDocker = createDockerRunner("docker");
|
|
29
|
+
export class DockerCommandError extends Error {
|
|
30
|
+
constructor(args, result) {
|
|
31
|
+
const detail = result.timedOut
|
|
32
|
+
? "timed out"
|
|
33
|
+
: result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
|
|
34
|
+
super(`docker ${args.slice(0, 2).join(" ")} failed: ${detail}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function docker(run, args, opts) {
|
|
38
|
+
const result = await run(args, opts);
|
|
39
|
+
if (result.code !== 0)
|
|
40
|
+
throw new DockerCommandError(args, result);
|
|
41
|
+
return result.stdout;
|
|
42
|
+
}
|
|
43
|
+
export const DOCKER_INSTALL_URL = "https://docs.docker.com/get-docker/";
|
|
44
|
+
const DOCKER_MISSING_MESSAGE = `Docker is not installed. Install Docker Desktop (macOS, Windows) or Docker Engine (Linux): ${DOCKER_INSTALL_URL}`;
|
|
45
|
+
const PREFLIGHT_TIMEOUT_MS = 10_000;
|
|
46
|
+
export function isDockerProblem(value) {
|
|
47
|
+
return "kind" in value;
|
|
48
|
+
}
|
|
49
|
+
// Three different situations hide behind a failing `docker info`, and each has
|
|
50
|
+
// a different fix, so the user gets the one that applies.
|
|
51
|
+
export async function preflightDocker(run) {
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await run(["info", "-f", "{{.OperatingSystem}}|{{json .SecurityOptions}}"], {
|
|
55
|
+
timeoutMs: PREFLIGHT_TIMEOUT_MS,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err.code === "ENOENT") {
|
|
60
|
+
return { kind: "missing", message: DOCKER_MISSING_MESSAGE };
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
if (result.code === 0) {
|
|
65
|
+
const [os = "", security = ""] = result.stdout.trim().split("|");
|
|
66
|
+
return { desktop: /desktop/i.test(os), rootless: security.includes("name=rootless") };
|
|
67
|
+
}
|
|
68
|
+
if (/permission denied/i.test(result.stderr)) {
|
|
69
|
+
return {
|
|
70
|
+
kind: "denied",
|
|
71
|
+
message: "Docker refused the connection (permission denied). Add your user to the docker group (sudo usermod -aG docker $USER) and log in again, or use Docker Desktop.",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
kind: "unreachable",
|
|
76
|
+
message: "Docker is installed but the daemon is not reachable. Start Docker Desktop, or on Linux: sudo systemctl start docker",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createDockerRunner, DOCKER_INSTALL_URL, DockerCommandError, docker, isDockerProblem, preflightDocker, } from "./docker.js";
|
|
3
|
+
function runner(result) {
|
|
4
|
+
return async () => ({ code: 0, stdout: "", stderr: "", timedOut: false, ...result });
|
|
5
|
+
}
|
|
6
|
+
describe("preflightDocker", () => {
|
|
7
|
+
it("reports a missing binary with the install link", async () => {
|
|
8
|
+
const missing = async () => {
|
|
9
|
+
throw Object.assign(new Error("spawn docker ENOENT"), { code: "ENOENT" });
|
|
10
|
+
};
|
|
11
|
+
const info = await preflightDocker(missing);
|
|
12
|
+
expect(isDockerProblem(info) && info.kind).toBe("missing");
|
|
13
|
+
expect(isDockerProblem(info) && info.message).toContain(DOCKER_INSTALL_URL);
|
|
14
|
+
});
|
|
15
|
+
it("rethrows spawn failures that are not ENOENT", async () => {
|
|
16
|
+
const broken = async () => {
|
|
17
|
+
throw Object.assign(new Error("EACCES"), { code: "EACCES" });
|
|
18
|
+
};
|
|
19
|
+
await expect(preflightDocker(broken)).rejects.toThrow("EACCES");
|
|
20
|
+
});
|
|
21
|
+
it("distinguishes a socket permission problem from a stopped daemon", async () => {
|
|
22
|
+
const denied = await preflightDocker(runner({ code: 1, stderr: "permission denied while trying to connect to the Docker daemon" }));
|
|
23
|
+
expect(isDockerProblem(denied) && denied.kind).toBe("denied");
|
|
24
|
+
expect(isDockerProblem(denied) && denied.message).toContain("docker group");
|
|
25
|
+
const stopped = await preflightDocker(runner({
|
|
26
|
+
code: 1,
|
|
27
|
+
stderr: "Cannot connect to the Docker daemon. Is the docker daemon running?",
|
|
28
|
+
}));
|
|
29
|
+
expect(isDockerProblem(stopped) && stopped.kind).toBe("unreachable");
|
|
30
|
+
const hung = await preflightDocker(runner({ code: null, timedOut: true }));
|
|
31
|
+
expect(isDockerProblem(hung) && hung.kind).toBe("unreachable");
|
|
32
|
+
});
|
|
33
|
+
it("detects Docker Desktop and rootless engines", async () => {
|
|
34
|
+
const desktop = await preflightDocker(runner({ stdout: 'Docker Desktop|["name=seccomp"]\n' }));
|
|
35
|
+
expect(desktop).toEqual({ desktop: true, rootless: false });
|
|
36
|
+
const rootless = await preflightDocker(runner({ stdout: 'Ubuntu 24.04|["name=rootless","name=seccomp"]\n' }));
|
|
37
|
+
expect(rootless).toEqual({ desktop: false, rootless: true });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe("docker", () => {
|
|
41
|
+
it("returns stdout on success and throws the stderr on failure", async () => {
|
|
42
|
+
await expect(docker(runner({ stdout: "abc\n" }), ["ps"])).resolves.toBe("abc\n");
|
|
43
|
+
await expect(docker(runner({ code: 125, stderr: "no such container" }), ["rm", "-f", "x"])).rejects.toThrow(new DockerCommandError(["rm", "-f", "x"], {
|
|
44
|
+
code: 125,
|
|
45
|
+
stdout: "",
|
|
46
|
+
stderr: "no such container",
|
|
47
|
+
timedOut: false,
|
|
48
|
+
}));
|
|
49
|
+
await expect(docker(runner({ code: null, timedOut: true }), ["pull", "img"])).rejects.toThrow("docker pull img failed: timed out");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe("createDockerRunner", () => {
|
|
53
|
+
const node = createDockerRunner(process.execPath);
|
|
54
|
+
it("captures exit code, stdout and stderr without a shell", async () => {
|
|
55
|
+
const result = await node([
|
|
56
|
+
"-e",
|
|
57
|
+
"process.stdout.write('out'); process.stderr.write('err'); process.exit(3)",
|
|
58
|
+
]);
|
|
59
|
+
expect(result).toEqual({ code: 3, stdout: "out", stderr: "err", timedOut: false });
|
|
60
|
+
});
|
|
61
|
+
it("pipes input to stdin and closes it", async () => {
|
|
62
|
+
const result = await node(["-e", "process.stdin.pipe(process.stdout)"], { input: "secret\n" });
|
|
63
|
+
expect(result.stdout).toBe("secret\n");
|
|
64
|
+
});
|
|
65
|
+
it("applies a default time limit so a stuck daemon cannot hang a call", async () => {
|
|
66
|
+
const { DEFAULT_DOCKER_TIMEOUT_MS } = await import("./docker.js");
|
|
67
|
+
expect(DEFAULT_DOCKER_TIMEOUT_MS).toBe(60_000);
|
|
68
|
+
const result = await node(["-e", "setTimeout(() => {}, 30000)"], { timeoutMs: 200 });
|
|
69
|
+
expect(result.timedOut).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it("kills a hung command after timeoutMs and flags it", async () => {
|
|
72
|
+
const result = await node(["-e", "setTimeout(() => {}, 30000)"], { timeoutMs: 200 });
|
|
73
|
+
expect(result.timedOut).toBe(true);
|
|
74
|
+
expect(result.code).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
it("rejects a missing binary with the install message, keeping the ENOENT code", async () => {
|
|
77
|
+
await expect(createDockerRunner("aximo-no-such-binary")(["info"])).rejects.toMatchObject({
|
|
78
|
+
code: "ENOENT",
|
|
79
|
+
message: expect.stringContaining(DOCKER_INSTALL_URL),
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { printResult } from "../../../output.js";
|
|
3
|
+
import { downTestbox } from "./box.js";
|
|
4
|
+
import { nameArg, toEnvelope } from "./result.js";
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
meta: {
|
|
7
|
+
name: "down",
|
|
8
|
+
description: "Stop and remove the container; keeps the browser profile and the device registration",
|
|
9
|
+
},
|
|
10
|
+
args: { ...nameArg },
|
|
11
|
+
async run({ args }) {
|
|
12
|
+
printResult(await toEnvelope(() => downTestbox(args.name)));
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DockerResult, DockerRunOptions, DockerRunner } from "./docker.js";
|
|
2
|
+
type Reply = Partial<DockerResult> | ((args: string[], opts?: DockerRunOptions) => Partial<DockerResult>);
|
|
3
|
+
export interface Rule {
|
|
4
|
+
match: (args: string[]) => boolean;
|
|
5
|
+
reply: Reply;
|
|
6
|
+
}
|
|
7
|
+
export interface FakeDocker {
|
|
8
|
+
run: DockerRunner;
|
|
9
|
+
calls: {
|
|
10
|
+
args: string[];
|
|
11
|
+
input?: string;
|
|
12
|
+
}[];
|
|
13
|
+
/** Prepend a rule; the newest rule wins. */
|
|
14
|
+
on(match: Rule["match"], reply: Reply): void;
|
|
15
|
+
find(...prefix: string[]): {
|
|
16
|
+
args: string[];
|
|
17
|
+
input?: string;
|
|
18
|
+
}[];
|
|
19
|
+
}
|
|
20
|
+
export declare const starts: (...prefix: string[]) => (args: string[]) => boolean;
|
|
21
|
+
export declare const has: (...tokens: string[]) => (args: string[]) => boolean;
|
|
22
|
+
export declare const ok: (stdout?: string) => Partial<DockerResult>;
|
|
23
|
+
export declare const fail: (stderr?: string) => Partial<DockerResult>;
|
|
24
|
+
export declare function fakeDocker(): FakeDocker;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const starts = (...prefix) => (args) => prefix.every((p, i) => args[i] === p);
|
|
2
|
+
export const has = (...tokens) => (args) => tokens.every((t) => args.includes(t));
|
|
3
|
+
export const ok = (stdout = "") => ({ code: 0, stdout });
|
|
4
|
+
export const fail = (stderr = "error") => ({ code: 1, stderr });
|
|
5
|
+
// A Docker Desktop engine with no testbox yet: the image must be pulled, the
|
|
6
|
+
// container does not exist, and the box's live-view password is "pw123".
|
|
7
|
+
export function fakeDocker() {
|
|
8
|
+
const rules = [
|
|
9
|
+
{ match: starts("info"), reply: ok('Docker Desktop|["name=seccomp"]\n') },
|
|
10
|
+
{ match: starts("image", "inspect"), reply: fail("No such image") },
|
|
11
|
+
{ match: starts("inspect"), reply: fail("No such object") },
|
|
12
|
+
{ match: starts("ps"), reply: ok("") },
|
|
13
|
+
{ match: has("exec", "cat", "/profile/.vncpass"), reply: ok("pw123\n") },
|
|
14
|
+
{ match: has("exec", "cat", "/run/aximo/agent.env"), reply: fail("No such file") },
|
|
15
|
+
{ match: has("exec", "cat", "/run/aximo/agent.status"), reply: fail("No such file") },
|
|
16
|
+
{ match: has("exec", "pgrep"), reply: ok("42\n") },
|
|
17
|
+
];
|
|
18
|
+
const calls = [];
|
|
19
|
+
const run = async (args, opts) => {
|
|
20
|
+
calls.push({ args, ...(opts?.input !== undefined ? { input: opts.input } : {}) });
|
|
21
|
+
const rule = rules.find((r) => r.match(args));
|
|
22
|
+
const reply = rule
|
|
23
|
+
? typeof rule.reply === "function"
|
|
24
|
+
? rule.reply(args, opts)
|
|
25
|
+
: rule.reply
|
|
26
|
+
: {};
|
|
27
|
+
return { code: 0, stdout: "", stderr: "", timedOut: false, ...reply };
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
run,
|
|
31
|
+
calls,
|
|
32
|
+
on: (match, reply) => rules.unshift({ match, reply }),
|
|
33
|
+
find: (...prefix) => calls.filter((c) => starts(...prefix)(c.args)),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
export default defineCommand({
|
|
3
|
+
meta: {
|
|
4
|
+
name: "testbox",
|
|
5
|
+
description: "A sandboxed Chromium in Docker, registered as a local device, so tests of an app on localhost never touch your screen. macOS and Linux; Windows untested.",
|
|
6
|
+
},
|
|
7
|
+
subCommands: {
|
|
8
|
+
up: () => import("./up.js").then((m) => m.default),
|
|
9
|
+
status: () => import("./status.js").then((m) => m.default),
|
|
10
|
+
url: () => import("./url.js").then((m) => m.default),
|
|
11
|
+
logs: () => import("./logs.js").then((m) => m.default),
|
|
12
|
+
down: () => import("./down.js").then((m) => m.default),
|
|
13
|
+
reset: () => import("./reset.js").then((m) => m.default),
|
|
14
|
+
prune: () => import("./prune.js").then((m) => m.default),
|
|
15
|
+
},
|
|
16
|
+
});
|