@autifyhq/aximo-cli 0.15.0 → 0.16.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.
Files changed (72) hide show
  1. package/README.md +19 -1
  2. package/dist/commands/device/device-config.d.ts +2 -2
  3. package/dist/commands/device/device-config.js +5 -8
  4. package/dist/commands/device/index.js +2 -1
  5. package/dist/commands/device/paths.d.ts +4 -0
  6. package/dist/commands/device/paths.js +12 -0
  7. package/dist/commands/device/reset.d.ts +1 -0
  8. package/dist/commands/device/reset.js +18 -3
  9. package/dist/commands/device/reset.test.js +13 -0
  10. package/dist/commands/device/testbox/box.d.ts +90 -0
  11. package/dist/commands/device/testbox/box.js +379 -0
  12. package/dist/commands/device/testbox/box.test.d.ts +1 -0
  13. package/dist/commands/device/testbox/box.test.js +815 -0
  14. package/dist/commands/device/testbox/container.d.ts +58 -0
  15. package/dist/commands/device/testbox/container.js +289 -0
  16. package/dist/commands/device/testbox/docker.d.ts +31 -0
  17. package/dist/commands/device/testbox/docker.js +78 -0
  18. package/dist/commands/device/testbox/docker.test.d.ts +1 -0
  19. package/dist/commands/device/testbox/docker.test.js +82 -0
  20. package/dist/commands/device/testbox/down.d.ts +7 -0
  21. package/dist/commands/device/testbox/down.js +14 -0
  22. package/dist/commands/device/testbox/errors.d.ts +4 -0
  23. package/dist/commands/device/testbox/errors.js +7 -0
  24. package/dist/commands/device/testbox/fake-docker.d.ts +25 -0
  25. package/dist/commands/device/testbox/fake-docker.js +35 -0
  26. package/dist/commands/device/testbox/index.d.ts +2 -0
  27. package/dist/commands/device/testbox/index.js +16 -0
  28. package/dist/commands/device/testbox/logs.d.ts +15 -0
  29. package/dist/commands/device/testbox/logs.js +43 -0
  30. package/dist/commands/device/testbox/prune.d.ts +11 -0
  31. package/dist/commands/device/testbox/prune.js +17 -0
  32. package/dist/commands/device/testbox/registration.d.ts +9 -0
  33. package/dist/commands/device/testbox/registration.js +56 -0
  34. package/dist/commands/device/testbox/reset.d.ts +7 -0
  35. package/dist/commands/device/testbox/reset.js +14 -0
  36. package/dist/commands/device/testbox/result.d.ts +8 -0
  37. package/dist/commands/device/testbox/result.js +23 -0
  38. package/dist/commands/device/testbox/result.test.d.ts +1 -0
  39. package/dist/commands/device/testbox/result.test.js +34 -0
  40. package/dist/commands/device/testbox/self-command.d.ts +12 -0
  41. package/dist/commands/device/testbox/self-command.js +36 -0
  42. package/dist/commands/device/testbox/self-command.test.d.ts +1 -0
  43. package/dist/commands/device/testbox/self-command.test.js +58 -0
  44. package/dist/commands/device/testbox/status.d.ts +7 -0
  45. package/dist/commands/device/testbox/status.js +11 -0
  46. package/dist/commands/device/testbox/up.d.ts +47 -0
  47. package/dist/commands/device/testbox/up.js +76 -0
  48. package/dist/commands/device/testbox/up.test.d.ts +1 -0
  49. package/dist/commands/device/testbox/up.test.js +52 -0
  50. package/dist/commands/device/testbox/url.d.ts +7 -0
  51. package/dist/commands/device/testbox/url.js +20 -0
  52. package/dist/commands/device/user-devices.d.ts +4 -0
  53. package/dist/commands/device/user-devices.js +18 -0
  54. package/dist/commands/mcp/server.d.ts +2 -2
  55. package/dist/commands/mcp/server.test.js +6 -2
  56. package/dist/commands/mcp/testbox-tools.test.d.ts +1 -0
  57. package/dist/commands/mcp/testbox-tools.test.js +278 -0
  58. package/dist/commands/mcp/tools.d.ts +8 -1
  59. package/dist/commands/mcp/tools.js +151 -18
  60. package/dist/commands/mcp/tools.test.js +27 -2
  61. package/dist/commands/schema.js +4 -0
  62. package/dist/commands/session/cancel.d.ts +20 -0
  63. package/dist/commands/session/cancel.js +39 -0
  64. package/dist/commands/session/index.js +1 -0
  65. package/dist/commands/session/session.test.js +38 -0
  66. package/dist/errors.d.ts +2 -0
  67. package/dist/errors.js +5 -0
  68. package/dist/errors.test.js +6 -1
  69. package/dist/index.d.ts +1 -0
  70. package/dist/index.js +1 -0
  71. package/dist/types.d.ts +5 -0
  72. package/package.json +1 -1
@@ -24,7 +24,7 @@ async function connect() {
24
24
  };
25
25
  }
26
26
  describe("createMcpServer wiring", () => {
27
- it("exposes all 36 tools through tools/list with valid JSON Schema", async () => {
27
+ it("exposes all 40 tools through tools/list with valid JSON Schema", async () => {
28
28
  const { client, cleanup } = await connect();
29
29
  try {
30
30
  const { tools } = await client.listTools();
@@ -32,6 +32,7 @@ describe("createMcpServer wiring", () => {
32
32
  "aximo_get_case",
33
33
  "aximo_get_session",
34
34
  "aximo_wait_for_session",
35
+ "aximo_cancel_session",
35
36
  "aximo_get_session_capture",
36
37
  "aximo_get_session_snapshot",
37
38
  "aximo_export_session_pdf",
@@ -65,6 +66,9 @@ describe("createMcpServer wiring", () => {
65
66
  "aximo_list_plan_runs",
66
67
  "aximo_get_plan_run",
67
68
  "aximo_save_session_as_case",
69
+ "aximo_start_testbox",
70
+ "aximo_get_testbox",
71
+ "aximo_stop_testbox",
68
72
  ].sort());
69
73
  const timeline = tools.find((t) => t.name === "aximo_get_session_timeline");
70
74
  expect(timeline?.inputSchema?.required).toContain("sessionId");
@@ -98,7 +102,7 @@ describe("createMcpServer wiring", () => {
98
102
  const { client, cleanup } = await connect();
99
103
  try {
100
104
  const { tools } = await client.listTools();
101
- expect(tools.length).toBe(36);
105
+ expect(tools.length).toBe(40);
102
106
  const result = await client.callTool({ name: "aximo_list_projects", arguments: {} });
103
107
  expect(result.isError).toBe(true);
104
108
  const content = result.content;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,278 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
5
+ import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
6
+ import { getTestboxConfigPath, getTestboxStatePath } from "../device/paths.js";
7
+ import { fail, fakeDocker, ok, starts } from "../device/testbox/fake-docker.js";
8
+ import { defineTools } from "./tools.js";
9
+ let tempHome;
10
+ beforeAll(() => {
11
+ tempHome = mkdtempSync(join(tmpdir(), "aximo-mcp-testbox-"));
12
+ process.env["AXIMO_HOME"] = tempHome;
13
+ process.env["AXIMO_API_KEY"] = "test-key";
14
+ process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
15
+ server.listen();
16
+ });
17
+ afterAll(() => {
18
+ delete process.env["AXIMO_HOME"];
19
+ delete process.env["AXIMO_API_KEY"];
20
+ delete process.env["AXIMO_BASE_URL"];
21
+ server.close();
22
+ rmSync(tempHome, { recursive: true, force: true });
23
+ });
24
+ beforeEach(() => {
25
+ rmSync(join(tempHome, "testbox"), { recursive: true, force: true });
26
+ mkdirSync(join(tempHome, "testbox"), { recursive: true });
27
+ });
28
+ afterEach(() => server.resetHandlers());
29
+ function fakeClock() {
30
+ const start = 1_700_000_000_000;
31
+ let t = start;
32
+ return {
33
+ now: () => t,
34
+ sleep: async (ms) => {
35
+ t += ms;
36
+ },
37
+ elapsed: () => t - start,
38
+ };
39
+ }
40
+ function devices(list) {
41
+ server.use(http.get(`${TEST_BASE_URL}/api/devices/user`, () => HttpResponse.json({ devices: list })));
42
+ }
43
+ function seedRegistration() {
44
+ writeFileSync(getTestboxConfigPath("default"), JSON.stringify({
45
+ deviceId: "dev-1",
46
+ deviceToken: "tok",
47
+ name: "mac-testbox-abcd",
48
+ registeredAt: "2026-01-01T00:00:00.000Z",
49
+ baseUrl: TEST_BASE_URL,
50
+ }));
51
+ }
52
+ function seedState(state) {
53
+ writeFileSync(getTestboxStatePath("default"), JSON.stringify({ startedAt: new Date().toISOString(), pid: process.pid, ...state }));
54
+ }
55
+ const config = {
56
+ image: "ghcr.io/autifyhq/aximo-testbox:0.1.0",
57
+ ports: "3000",
58
+ blockHosts: [],
59
+ network: "bridge",
60
+ novncPort: 6080,
61
+ screen: "1280x720",
62
+ };
63
+ function runningBox(docker) {
64
+ docker.on(starts("inspect"), ok(`true|${config.image}|${JSON.stringify({ "aximo.testbox": "default", "aximo.testbox.config": JSON.stringify(config) })}\n`));
65
+ }
66
+ function toolsWithDetachedUp(docker = fakeDocker(), detachedUp = {}) {
67
+ const spawned = [];
68
+ const clock = fakeClock();
69
+ const deps = {
70
+ testbox: {
71
+ docker: docker.run,
72
+ clock,
73
+ spawn: (args) => {
74
+ spawned.push(args);
75
+ if (detachedUp.writesPullingState ?? true)
76
+ seedState({ phase: "pulling" });
77
+ detachedUp.onSpawn?.();
78
+ return 4242;
79
+ },
80
+ },
81
+ };
82
+ const all = defineTools(deps);
83
+ const tool = (name) => {
84
+ const t = all.find((x) => x.name === name);
85
+ if (!t)
86
+ throw new Error(`tool ${name} not found`);
87
+ return t;
88
+ };
89
+ const payload = (result) => JSON.parse(result.content[0].text);
90
+ return { tool, payload, spawned, clock };
91
+ }
92
+ describe("aximo_start_testbox", () => {
93
+ it("spawns a detached up with the flags and reports the pull in progress", async () => {
94
+ devices([]);
95
+ const { tool, payload, spawned } = toolsWithDetachedUp();
96
+ const result = await tool("aximo_start_testbox").handler({
97
+ ports: "5173",
98
+ blockHosts: ["prod.example.com"],
99
+ startUrl: "http://localhost:5173/",
100
+ screen: "1920x1080",
101
+ timeoutSeconds: 5,
102
+ });
103
+ expect(spawned).toEqual([
104
+ [
105
+ "device",
106
+ "testbox",
107
+ "up",
108
+ "--no-wait",
109
+ "--name",
110
+ "default",
111
+ "--ports",
112
+ "5173",
113
+ "--block-hosts",
114
+ "prod.example.com",
115
+ "--start-url",
116
+ "http://localhost:5173/",
117
+ "--screen",
118
+ "1920x1080",
119
+ ],
120
+ ]);
121
+ expect(result.isError).toBeFalsy();
122
+ expect(payload(result)).toMatchObject({
123
+ ready: false,
124
+ state: "pulling",
125
+ hint: expect.stringContaining("aximo_get_testbox"),
126
+ });
127
+ });
128
+ it("does not spawn a second up while one is still running", async () => {
129
+ devices([]);
130
+ seedState({ phase: "starting" });
131
+ const { tool, payload, spawned } = toolsWithDetachedUp();
132
+ const result = await tool("aximo_start_testbox").handler({ timeoutSeconds: 3 });
133
+ expect(spawned).toEqual([]);
134
+ expect(payload(result)).toMatchObject({ ready: false, state: "starting" });
135
+ });
136
+ it("forgets a previous run's error before spawning, instead of reporting it as this run's", async () => {
137
+ devices([]);
138
+ seedState({ phase: "error", pid: 1, lastError: "old failure" });
139
+ const { tool, payload, spawned } = toolsWithDetachedUp();
140
+ const result = payload(await tool("aximo_start_testbox").handler({ timeoutSeconds: 3 }));
141
+ expect(spawned).toHaveLength(1);
142
+ expect(result["state"]).toBe("pulling");
143
+ expect(result["lastError"]).toBeUndefined();
144
+ });
145
+ it("returns ready with the device id once the box is online", async () => {
146
+ devices([{ id: "dev-1", name: "mac-testbox-abcd", status: "online" }]);
147
+ seedRegistration();
148
+ const docker = fakeDocker();
149
+ const { tool, payload, spawned } = toolsWithDetachedUp(docker, {
150
+ writesPullingState: false,
151
+ onSpawn: () => runningBox(docker),
152
+ });
153
+ const result = payload(await tool("aximo_start_testbox").handler({ timeoutSeconds: 3 }));
154
+ expect(spawned).toHaveLength(1);
155
+ expect(result).toMatchObject({
156
+ ready: true,
157
+ state: "online",
158
+ container: "running",
159
+ agent: "running",
160
+ userDeviceId: "dev-1",
161
+ deviceName: "mac-testbox-abcd",
162
+ deviceStatus: "online",
163
+ liveViewUrl: "http://127.0.0.1:6080/vnc.html?autoconnect=1&resize=scale&password=pw123",
164
+ ports: [{ port: 3000, reachable: true }],
165
+ hint: expect.stringContaining("userDeviceId"),
166
+ });
167
+ });
168
+ it("does not touch a box that is already online unless asked to reconfigure it", async () => {
169
+ devices([{ id: "dev-1", name: "mac-testbox-abcd", status: "online" }]);
170
+ seedRegistration();
171
+ const docker = fakeDocker();
172
+ runningBox(docker);
173
+ const { tool, payload, spawned } = toolsWithDetachedUp(docker, { writesPullingState: false });
174
+ expect(payload(await tool("aximo_start_testbox").handler({}))["ready"]).toBe(true);
175
+ expect(spawned).toEqual([]);
176
+ await tool("aximo_start_testbox").handler({ ports: "5173" });
177
+ expect(spawned).toHaveLength(1);
178
+ });
179
+ it("returns within timeoutSeconds even when the detached up never writes state", async () => {
180
+ devices([]);
181
+ const { tool, payload, clock } = toolsWithDetachedUp(fakeDocker(), {
182
+ writesPullingState: false,
183
+ });
184
+ const result = payload(await tool("aximo_start_testbox").handler({ timeoutSeconds: 7 }));
185
+ expect(result["state"]).toBe("down");
186
+ expect(clock.elapsed()).toBeLessThanOrEqual(7_000 + 3_000);
187
+ });
188
+ it("is a tool error with the install link when Docker is missing", async () => {
189
+ devices([]);
190
+ const docker = fakeDocker();
191
+ docker.on(starts("info"), () => {
192
+ throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
193
+ });
194
+ const { tool, payload, spawned } = toolsWithDetachedUp(docker);
195
+ const result = await tool("aximo_start_testbox").handler({});
196
+ expect(result.isError).toBe(true);
197
+ expect(payload(result)["error"]).toContain("https://docs.docker.com/get-docker/");
198
+ expect(spawned).toEqual([]);
199
+ });
200
+ it("rejects a name that is not a valid box name", async () => {
201
+ devices([]);
202
+ const { tool } = toolsWithDetachedUp();
203
+ const result = await tool("aximo_start_testbox").handler({ name: "../x" });
204
+ expect(result.isError).toBe(true);
205
+ });
206
+ });
207
+ describe("aximo_get_testbox and aximo_stop_testbox", () => {
208
+ it("get reports down with the start hint when nothing runs", async () => {
209
+ devices([]);
210
+ const { tool, payload } = toolsWithDetachedUp();
211
+ expect(payload(await tool("aximo_get_testbox").handler({}))).toEqual({
212
+ ready: false,
213
+ state: "down",
214
+ name: "default",
215
+ container: "absent",
216
+ hint: "Not running. Call aximo_start_testbox.",
217
+ });
218
+ });
219
+ it("get waits up to timeoutSeconds while the box is still connecting", async () => {
220
+ devices([{ id: "dev-1", name: "mac-testbox-abcd", status: "offline" }]);
221
+ seedRegistration();
222
+ const docker = fakeDocker();
223
+ runningBox(docker);
224
+ const { tool, payload, clock } = toolsWithDetachedUp(docker);
225
+ const result = payload(await tool("aximo_get_testbox").handler({ timeoutSeconds: 7 }));
226
+ expect(result).toMatchObject({
227
+ ready: false,
228
+ state: "connecting",
229
+ hint: expect.stringContaining("aximo_get_testbox"),
230
+ });
231
+ expect(clock.elapsed()).toBe(6_000);
232
+ });
233
+ it("get surfaces a failed detached up as an error with its cause", async () => {
234
+ devices([]);
235
+ seedState({
236
+ phase: "error",
237
+ pid: 1,
238
+ lastError: "Could not pull ghcr.io/autifyhq/aximo-testbox:0.1.0: denied",
239
+ });
240
+ const { tool, payload } = toolsWithDetachedUp();
241
+ expect(payload(await tool("aximo_get_testbox").handler({}))).toMatchObject({
242
+ state: "error",
243
+ lastError: expect.stringContaining("Could not pull"),
244
+ hint: expect.stringContaining("aximo_start_testbox"),
245
+ });
246
+ });
247
+ it("get passes the agent's last message through", async () => {
248
+ devices([{ id: "dev-1", name: "mac-testbox-abcd", status: "offline" }]);
249
+ seedRegistration();
250
+ const docker = fakeDocker();
251
+ runningBox(docker);
252
+ docker.on((args) => args.includes("cat") && args.includes("/run/aximo/agent.status"), ok("Auth failed. Re-register with: aximo device register\n"));
253
+ const { tool, payload } = toolsWithDetachedUp(docker);
254
+ expect(payload(await tool("aximo_get_testbox").handler({}))["agentStatus"]).toContain("Auth failed");
255
+ });
256
+ it("stop removes the container and nothing else", async () => {
257
+ devices([]);
258
+ const docker = fakeDocker();
259
+ runningBox(docker);
260
+ const { tool, payload } = toolsWithDetachedUp(docker);
261
+ expect(payload(await tool("aximo_stop_testbox").handler({}))).toEqual({
262
+ name: "default",
263
+ removed: true,
264
+ });
265
+ expect(docker.find("rm", "-f", "aximo-testbox")).toHaveLength(1);
266
+ expect(docker.find("volume")).toHaveLength(0);
267
+ });
268
+ it("maps a Docker failure to a tool error", async () => {
269
+ devices([]);
270
+ const docker = fakeDocker();
271
+ runningBox(docker);
272
+ docker.on(starts("rm"), fail("permission denied"));
273
+ const { tool, payload } = toolsWithDetachedUp(docker);
274
+ const result = await tool("aximo_stop_testbox").handler({});
275
+ expect(result.isError).toBe(true);
276
+ expect(payload(result)["error"]).toContain("permission denied");
277
+ });
278
+ });
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { type ClientDeps } from "../../client.js";
3
+ import { type TestboxDeps } from "../device/testbox/box.js";
4
+ import { type DetachedSpawner } from "../device/testbox/self-command.js";
3
5
  interface ToolResult {
4
6
  content: Array<{
5
7
  type: "text";
@@ -7,6 +9,11 @@ interface ToolResult {
7
9
  }>;
8
10
  isError?: boolean;
9
11
  }
12
+ export interface ToolDeps extends ClientDeps {
13
+ testbox?: TestboxDeps & {
14
+ spawn?: DetachedSpawner;
15
+ };
16
+ }
10
17
  export interface Tool<TInput extends z.ZodRawShape = z.ZodRawShape> {
11
18
  name: string;
12
19
  title: string;
@@ -14,5 +21,5 @@ export interface Tool<TInput extends z.ZodRawShape = z.ZodRawShape> {
14
21
  inputSchema: TInput;
15
22
  handler: (input: z.infer<z.ZodObject<TInput>>) => Promise<ToolResult>;
16
23
  }
17
- export declare function defineTools(deps?: ClientDeps): Tool[];
24
+ export declare function defineTools(deps?: ToolDeps): Tool[];
18
25
  export {};
@@ -1,9 +1,16 @@
1
1
  import { isAbsolute } from "node:path";
2
2
  import { z } from "zod";
3
3
  import { request } from "../../client.js";
4
- import { WAIT_DEFAULT_SECONDS, WAIT_MAX_SECONDS, waitForSession } from "./wait.js";
4
+ import { getTestboxLogPath } from "../device/paths.js";
5
+ import { clearState, downTestbox, pendingUp, readState, statusTestbox, waitForTestbox, } from "../device/testbox/box.js";
6
+ import { BOX_NAME_RE, validateBoxName } from "../device/testbox/container.js";
7
+ import { isDockerProblem, preflightDocker, runDocker } from "../device/testbox/docker.js";
8
+ import { TestboxError } from "../device/testbox/errors.js";
9
+ import { spawnDetached } from "../device/testbox/self-command.js";
10
+ import { listUserDevices } from "../device/user-devices.js";
11
+ import { realClock, WAIT_DEFAULT_SECONDS, WAIT_MAX_SECONDS, waitForSession } from "./wait.js";
5
12
  import { exportSession, SESSION_EXPORT_LOCALES, } from "../../session-export.js";
6
- import { ARCHIVE_FIRST_HINT, featureHint, isArchiveFirstConflict, withDetails, } from "../../errors.js";
13
+ import { ARCHIVE_FIRST_HINT, creditHint, featureHint, isArchiveFirstConflict, withDetails, } from "../../errors.js";
7
14
  function ok(payload) {
8
15
  return {
9
16
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
@@ -32,7 +39,9 @@ async function call(fn) {
32
39
  if (status === 401)
33
40
  return err("Unauthorized: check AXIMO_API_KEY", "Run `aximo configure`.");
34
41
  if (status === 403) {
35
- return err(message || "Forbidden", featureHint(e.message, details) ?? "API key may not have access to this resource.");
42
+ return err(message || "Forbidden", featureHint(e.message, details) ??
43
+ creditHint(e.message) ??
44
+ "API key may not have access to this resource.");
36
45
  }
37
46
  if (status === 404)
38
47
  return err(message || "Not found");
@@ -99,7 +108,48 @@ function exportSessionTo(input, deps) {
99
108
  }
100
109
  return exportSession(input, deps);
101
110
  }
111
+ const TESTBOX_HINTS = {
112
+ online: "Pass userDeviceId as the device for aximo_create_session or aximo_run_case, and give the user liveViewUrl to watch the run.",
113
+ pulling: "Still starting (a first start pulls a ~1 GB image). Call aximo_get_testbox with timeoutSeconds: 45 until ready.",
114
+ starting: "Still starting. Call aximo_get_testbox with timeoutSeconds: 45 until ready.",
115
+ connecting: "The box is up and the agent is connecting. Call aximo_get_testbox with timeoutSeconds: 45 until ready.",
116
+ down: "Not running. Call aximo_start_testbox.",
117
+ error: "Fix the cause in lastError, then call aximo_start_testbox again.",
118
+ };
119
+ function testboxReport(status) {
120
+ return {
121
+ ready: status.state === "online",
122
+ state: status.state,
123
+ name: status.name,
124
+ container: status.container,
125
+ ...(status.agent ? { agent: status.agent } : {}),
126
+ ...(status.device
127
+ ? {
128
+ userDeviceId: status.device.userDeviceId,
129
+ deviceName: status.device.name,
130
+ deviceStatus: status.device.status,
131
+ }
132
+ : {}),
133
+ ...(status.liveViewUrl ? { liveViewUrl: status.liveViewUrl } : {}),
134
+ ...(status.ports ? { ports: status.ports } : {}),
135
+ ...(status.agentStatus ? { agentStatus: status.agentStatus } : {}),
136
+ ...(status.lastError ? { lastError: status.lastError } : {}),
137
+ hint: TESTBOX_HINTS[status.state],
138
+ };
139
+ }
140
+ const testboxNameSchema = z
141
+ .string()
142
+ .regex(BOX_NAME_RE)
143
+ .optional()
144
+ .describe("Box name, only when running more than one box on this machine.");
145
+ // The detached `up` normally writes its first state within a second; after this
146
+ // the tool reads Docker directly.
147
+ const START_SETTLE_MS = 10_000;
102
148
  export function defineTools(deps = {}) {
149
+ const testbox = { ...deps, ...deps.testbox };
150
+ const spawnUp = deps.testbox?.spawn ?? spawnDetached;
151
+ const clock = testbox.clock ?? realClock;
152
+ const secondsLeft = (deadline) => Math.max(0, Math.round((deadline - clock.now()) / 1000));
103
153
  return [
104
154
  {
105
155
  name: "aximo_list_projects",
@@ -131,21 +181,7 @@ export function defineTools(deps = {}) {
131
181
  title: "List registered BYOD devices",
132
182
  description: "List the BYOD (bring-your-own) desktop and mobile devices registered with `aximo device register` by the owner of this API key — other members' devices are not included. Each device's id is the userDeviceId to pass in a case's apiDesktopConfig or multi-device local entries (aximo_update_case) and in per-run overrides. status shows whether the device is online. Returns 404 when BYOD is not enabled for the organization.",
133
183
  inputSchema: {},
134
- handler: async () => call(async () => {
135
- try {
136
- return await request("/api/devices/user", {}, deps);
137
- }
138
- catch (e) {
139
- // The route answers a bare 404 when the org has no BYOD feature.
140
- if (e.status === 404) {
141
- throw Object.assign(new Error("BYOD is not enabled for this organization"), {
142
- status: 403,
143
- details: { feature: "byodEnabled" },
144
- });
145
- }
146
- throw e;
147
- }
148
- }),
184
+ handler: async () => call(async () => ({ devices: await listUserDevices(deps) })),
149
185
  },
150
186
  {
151
187
  name: "aximo_list_uploads",
@@ -498,6 +534,17 @@ export function defineTools(deps = {}) {
498
534
  return await request("/api/sessions", { method: "POST", body }, deps);
499
535
  }),
500
536
  },
537
+ {
538
+ name: "aximo_cancel_session",
539
+ title: "Cancel session",
540
+ description: "Cancel a queued or running session: it moves to terminated and stops on its device at the next step, including a BYOD session waiting for an offline device. Use it to stop a run that went wrong instead of letting it spend credits. A session that has already finished returns a conflict error and is left unchanged.",
541
+ inputSchema: {
542
+ sessionId: z.string().uuid(),
543
+ },
544
+ handler: async ({ sessionId }) => call(async () => {
545
+ return await request(`/api/sessions/${encodeURIComponent(sessionId)}/cancel`, { method: "POST" }, deps);
546
+ }),
547
+ },
501
548
  {
502
549
  name: "aximo_create_plan",
503
550
  title: "Create test plan",
@@ -724,5 +771,91 @@ export function defineTools(deps = {}) {
724
771
  return await request(`/api/sessions/${encodeURIComponent(sessionId)}/save-as-case`, { method: "POST", body }, deps);
725
772
  }),
726
773
  },
774
+ // Local tools: these run Docker on the machine hosting the MCP server.
775
+ {
776
+ name: "aximo_start_testbox",
777
+ title: "Start the testbox",
778
+ description: "Start the testbox: a sandboxed Chromium in Docker on this machine, registered as a local (BYOD) device, whose localhost is this machine's localhost. Use it to test an app running locally without taking over the user's screen. Idempotent: a box that is already up is reused, and calling again never starts a second one. Returns within timeoutSeconds with ready=true and the userDeviceId once the device is online, or ready=false with the current state; then poll with aximo_get_testbox (read-only) rather than calling this again. Requires Docker; the error says how to install it. The user signs into the app through liveViewUrl if needed, and keeps that tab to watch runs.",
779
+ inputSchema: {
780
+ name: testboxNameSchema,
781
+ ports: z
782
+ .string()
783
+ .regex(/^[0-9, -]+$/)
784
+ .optional()
785
+ .describe("Host ports the app serves on, e.g. 3000 or 3000,5173-5180. Default 3000."),
786
+ blockHosts: z
787
+ .array(z.string())
788
+ .optional()
789
+ .describe("Hosts the browser must refuse, e.g. the production domain. A browser rule matched by name, not a network boundary."),
790
+ startUrl: z.string().url().optional().describe("Page the browser opens on start."),
791
+ screen: z
792
+ .string()
793
+ .regex(/^\d{3,4}x\d{3,4}$/)
794
+ .optional()
795
+ .describe("Virtual screen size WIDTHxHEIGHT. Default 1280x720; use 1920x1080 only for a layout that needs a wide viewport."),
796
+ timeoutSeconds: z
797
+ .number()
798
+ .int()
799
+ .min(1)
800
+ .max(WAIT_MAX_SECONDS)
801
+ .optional()
802
+ .describe(`Seconds to wait before returning. Default ${WAIT_DEFAULT_SECONDS}.`),
803
+ },
804
+ handler: async ({ name, ports, blockHosts, startUrl, screen, timeoutSeconds }) => call(async () => {
805
+ const deadline = clock.now() + (timeoutSeconds ?? WAIT_DEFAULT_SECONDS) * 1000;
806
+ const boxName = validateBoxName(name);
807
+ const docker = await preflightDocker(testbox.docker ?? runDocker);
808
+ if (isDockerProblem(docker))
809
+ throw new TestboxError(docker.message);
810
+ const current = await statusTestbox(boxName, testbox);
811
+ const reconfigure = ports !== undefined ||
812
+ blockHosts !== undefined ||
813
+ startUrl !== undefined ||
814
+ screen !== undefined;
815
+ if (current.state === "online" && !reconfigure)
816
+ return testboxReport(current);
817
+ if (!pendingUp(boxName, clock)) {
818
+ // A previous run's error would otherwise be read as this run's result.
819
+ clearState(boxName);
820
+ const args = ["device", "testbox", "up", "--no-wait", "--name", boxName];
821
+ if (ports)
822
+ args.push("--ports", ports);
823
+ if (blockHosts?.length)
824
+ args.push("--block-hosts", blockHosts.join(","));
825
+ if (startUrl)
826
+ args.push("--start-url", startUrl);
827
+ if (screen)
828
+ args.push("--screen", screen);
829
+ spawnUp(args, getTestboxLogPath(boxName));
830
+ const settleBy = Math.min(deadline, clock.now() + START_SETTLE_MS);
831
+ while (readState(boxName) === null && clock.now() < settleBy)
832
+ await clock.sleep(500);
833
+ }
834
+ return testboxReport(await waitForTestbox(boxName, secondsLeft(deadline), testbox));
835
+ }),
836
+ },
837
+ {
838
+ name: "aximo_get_testbox",
839
+ title: "Get testbox status",
840
+ description: "Read-only. Container, agent and device state of the testbox, which mirrored ports answer from inside the box, and the live-view link. With timeoutSeconds it waits up to that long (max 90) for the box to come online and returns whatever the state is then: pulling, starting, connecting, online, down or error. A first start pulls the image and can take a few minutes, so keep calling until ready=true. Check ports before a run: an unreachable port means the app is not listening, not that the test failed.",
841
+ inputSchema: {
842
+ name: testboxNameSchema,
843
+ timeoutSeconds: z
844
+ .number()
845
+ .int()
846
+ .min(0)
847
+ .max(WAIT_MAX_SECONDS)
848
+ .optional()
849
+ .describe("Seconds to wait for the box to come online. Default 0: answer at once."),
850
+ },
851
+ handler: async ({ name, timeoutSeconds }) => call(async () => testboxReport(await waitForTestbox(name, timeoutSeconds ?? 0, testbox))),
852
+ },
853
+ {
854
+ name: "aximo_stop_testbox",
855
+ title: "Stop the testbox",
856
+ description: "Stop and remove the testbox container. The browser profile (sign-ins) and the device registration are kept, so the next aximo_start_testbox is quick. Dropping the profile or the registration is a human action: `aximo device testbox reset` / `prune` in a terminal.",
857
+ inputSchema: { name: testboxNameSchema },
858
+ handler: async ({ name }) => call(() => downTestbox(name, testbox)),
859
+ },
727
860
  ];
728
861
  }
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
5
5
  import { z } from "zod";
6
- import { ARCHIVE_FIRST_HINT, handleApiError, isArchiveFirstConflict } from "../../errors.js";
6
+ import { ARCHIVE_FIRST_HINT, CREDIT_HINT, handleApiError, isArchiveFirstConflict, } from "../../errors.js";
7
7
  import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
8
8
  import { defineTools } from "./tools.js";
9
9
  beforeAll(() => {
@@ -49,6 +49,7 @@ describe("defineTools", () => {
49
49
  "aximo_delete_case",
50
50
  "aximo_run_case",
51
51
  "aximo_create_session",
52
+ "aximo_cancel_session",
52
53
  "aximo_create_plan",
53
54
  "aximo_add_case_to_plan",
54
55
  "aximo_remove_case_from_plan",
@@ -62,6 +63,9 @@ describe("defineTools", () => {
62
63
  "aximo_list_plan_runs",
63
64
  "aximo_get_plan_run",
64
65
  "aximo_save_session_as_case",
66
+ "aximo_start_testbox",
67
+ "aximo_get_testbox",
68
+ "aximo_stop_testbox",
65
69
  ]);
66
70
  });
67
71
  });
@@ -288,6 +292,24 @@ describe("aximo_wait_for_session", () => {
288
292
  expect(parsePayload(result)).toEqual({ error: "Session not found" });
289
293
  });
290
294
  });
295
+ describe("aximo_cancel_session", () => {
296
+ const sessionId = "78787878-7878-4787-9787-787878787878";
297
+ it("POSTs the cancel endpoint and returns the terminated session", async () => {
298
+ const payload = { success: true, sessionId, status: "terminated" };
299
+ server.use(http.post(`${TEST_BASE_URL}/api/sessions/${sessionId}/cancel`, () => HttpResponse.json(payload)));
300
+ const result = await byName(defineTools(), "aximo_cancel_session").handler({ sessionId });
301
+ expect(result.isError).toBeUndefined();
302
+ expect(parsePayload(result)).toEqual(payload);
303
+ });
304
+ it("reports a finished session as an error with no archive hint", async () => {
305
+ server.use(http.post(`${TEST_BASE_URL}/api/sessions/${sessionId}/cancel`, () => HttpResponse.json({ error: "Cannot cancel a session that has already finished" }, { status: 409 })));
306
+ const result = await byName(defineTools(), "aximo_cancel_session").handler({ sessionId });
307
+ expect(result.isError).toBe(true);
308
+ expect(parsePayload(result)).toEqual({
309
+ error: "Cannot cancel a session that has already finished",
310
+ });
311
+ });
312
+ });
291
313
  describe("aximo_create_session", () => {
292
314
  const projectId = "12121212-1212-4212-9212-121212121212";
293
315
  it("POSTs /api/sessions with the prompt and device and returns sessionId", async () => {
@@ -370,7 +392,10 @@ describe("aximo_run_case", () => {
370
392
  const tool = byName(defineTools(), "aximo_run_case");
371
393
  const result = await tool.handler({ caseId });
372
394
  expect(result.isError).toBe(true);
373
- expect(parsePayload(result).error).toBe("Insufficient credits to start session");
395
+ expect(parsePayload(result)).toEqual({
396
+ error: "Insufficient credits to start session",
397
+ hint: CREDIT_HINT,
398
+ });
374
399
  });
375
400
  it("maps a 429 to a rate-limit error an agent can back off on", async () => {
376
401
  server.use(http.post(`${TEST_BASE_URL}/api/triggers/cases/${caseId}/run`, () => HttpResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429, headers: { "Retry-After": "60" } })));
@@ -42,6 +42,10 @@ export function getSchema() {
42
42
  cmd("get", "GET /api/sessions/{sessionId}", [
43
43
  arg("sessionId", "positional", "Session ID", true),
44
44
  ]),
45
+ cmd("cancel", "POST /api/sessions/{sessionId}/cancel", [
46
+ arg("sessionId", "positional", "Session ID", true),
47
+ arg("dry-run", "boolean", "Validate and show request without calling API"),
48
+ ]),
45
49
  cmd("save-as-case", "POST /api/sessions/{sessionId}/save-as-case", [
46
50
  arg("sessionId", "positional", "Session ID", true),
47
51
  arg("title", "string", ""),
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { CancelSessionResult, Envelope } from "../../types.js";
4
+ export interface SessionCancelOptions {
5
+ sessionId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runSessionCancel(options: SessionCancelOptions, deps?: ClientDeps): Promise<Envelope<CancelSessionResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly sessionId: {
11
+ readonly type: "positional";
12
+ readonly description: "Session ID";
13
+ readonly required: true;
14
+ };
15
+ readonly "dry-run": {
16
+ readonly type: "boolean";
17
+ readonly description: "Validate and show request without calling API";
18
+ };
19
+ }>;
20
+ export default _default;