@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.
Files changed (74) 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 +7 -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 +171 -20
  60. package/dist/commands/mcp/tools.test.js +59 -2
  61. package/dist/commands/schema.js +5 -1
  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/commands/trigger/plan/run.d.ts +1 -1
  67. package/dist/commands/trigger/plan/run.js +4 -1
  68. package/dist/errors.d.ts +2 -0
  69. package/dist/errors.js +5 -0
  70. package/dist/errors.test.js +6 -1
  71. package/dist/index.d.ts +1 -0
  72. package/dist/index.js +1 -0
  73. package/dist/types.d.ts +14 -0
  74. package/package.json +1 -1
@@ -24,14 +24,16 @@ 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 41 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();
31
31
  expect(tools.map((t) => t.name).sort()).toEqual([
32
32
  "aximo_get_case",
33
+ "aximo_get_credits",
33
34
  "aximo_get_session",
34
35
  "aximo_wait_for_session",
36
+ "aximo_cancel_session",
35
37
  "aximo_get_session_capture",
36
38
  "aximo_get_session_snapshot",
37
39
  "aximo_export_session_pdf",
@@ -65,6 +67,9 @@ describe("createMcpServer wiring", () => {
65
67
  "aximo_list_plan_runs",
66
68
  "aximo_get_plan_run",
67
69
  "aximo_save_session_as_case",
70
+ "aximo_start_testbox",
71
+ "aximo_get_testbox",
72
+ "aximo_stop_testbox",
68
73
  ].sort());
69
74
  const timeline = tools.find((t) => t.name === "aximo_get_session_timeline");
70
75
  expect(timeline?.inputSchema?.required).toContain("sessionId");
@@ -98,7 +103,7 @@ describe("createMcpServer wiring", () => {
98
103
  const { client, cleanup } = await connect();
99
104
  try {
100
105
  const { tools } = await client.listTools();
101
- expect(tools.length).toBe(36);
106
+ expect(tools.length).toBe(41);
102
107
  const result = await client.callTool({ name: "aximo_list_projects", arguments: {} });
103
108
  expect(result.isError).toBe(true);
104
109
  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",
@@ -111,6 +161,24 @@ export function defineTools(deps = {}) {
111
161
  return res;
112
162
  }),
113
163
  },
164
+ {
165
+ name: "aximo_get_credits",
166
+ title: "Get credit balance",
167
+ description: "Get your organization's credit balance for the current period: granted, used and remaining, in the unit named by creditUnit (legacy or platform). onDemandRemaining is on-demand credit still available once remaining runs out, so remaining can be 0 while runs still start. blocked is true when a new session or run would be refused for lack of credits; metered is false when credits are not enforced. Check it before starting runs to warn when the balance is low.",
168
+ inputSchema: {},
169
+ handler: async () => call(async () => {
170
+ try {
171
+ return await request("/api/organizations/credits", {}, deps);
172
+ }
173
+ catch (e) {
174
+ // Servers before the route answer with Next.js's HTML 404 page.
175
+ if (e.status === 404) {
176
+ throw Object.assign(new Error("This Aximo server does not support the credit balance yet"), { status: 404 });
177
+ }
178
+ throw e;
179
+ }
180
+ }),
181
+ },
114
182
  {
115
183
  name: "aximo_list_devices",
116
184
  title: "List Device Farm devices",
@@ -131,21 +199,7 @@ export function defineTools(deps = {}) {
131
199
  title: "List registered BYOD devices",
132
200
  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
201
  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
- }),
202
+ handler: async () => call(async () => ({ devices: await listUserDevices(deps) })),
149
203
  },
150
204
  {
151
205
  name: "aximo_list_uploads",
@@ -498,6 +552,17 @@ export function defineTools(deps = {}) {
498
552
  return await request("/api/sessions", { method: "POST", body }, deps);
499
553
  }),
500
554
  },
555
+ {
556
+ name: "aximo_cancel_session",
557
+ title: "Cancel session",
558
+ 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.",
559
+ inputSchema: {
560
+ sessionId: z.string().uuid(),
561
+ },
562
+ handler: async ({ sessionId }) => call(async () => {
563
+ return await request(`/api/sessions/${encodeURIComponent(sessionId)}/cancel`, { method: "POST" }, deps);
564
+ }),
565
+ },
501
566
  {
502
567
  name: "aximo_create_plan",
503
568
  title: "Create test plan",
@@ -614,7 +679,7 @@ export function defineTools(deps = {}) {
614
679
  {
615
680
  name: "aximo_run_plan",
616
681
  title: "Run test plan",
617
- description: "Start a run of a test plan (all its cases) and return the new planRunId, totalCases, and the effective executionMode. The plan must be apiEnabled (aximo_get_plan shows this). All fields except planId are per-run overrides of the plan's stored settings: omit them to run with the saved defaults. Poll aximo_get_plan_run with the returned planRunId to follow the run; wait at least 5 seconds between polls.",
682
+ description: "Start a run of a test plan (all its cases) and return the new planRunId, totalCases, and the effective executionMode. The plan must be apiEnabled (aximo_get_plan shows this). All fields except planId and mobileConfig are per-run overrides of the plan's stored settings: omit them to run with the saved defaults. mobileConfig is only a fallback (see its description). Poll aximo_get_plan_run with the returned planRunId to follow the run; wait at least 5 seconds between polls.",
618
683
  inputSchema: {
619
684
  planId: z.string().uuid(),
620
685
  model: z
@@ -638,7 +703,7 @@ export function defineTools(deps = {}) {
638
703
  .describe("Takes precedence over the single-app uploadArn/uploadType/appName fields."),
639
704
  })
640
705
  .optional()
641
- .describe("Device + app for the plan's mobile cases; overrides the plan's stored config."),
706
+ .describe("Fallback device + app, used only for mobile cases with no device saved on the plan or the case. A saved device always wins and keeps its own app; to run on another device, save it on the case or the plan."),
642
707
  desktopConfig: z
643
708
  .object({
644
709
  devices: z.array(z.object({ userDeviceId: z.string().uuid() })).min(1),
@@ -724,5 +789,91 @@ export function defineTools(deps = {}) {
724
789
  return await request(`/api/sessions/${encodeURIComponent(sessionId)}/save-as-case`, { method: "POST", body }, deps);
725
790
  }),
726
791
  },
792
+ // Local tools: these run Docker on the machine hosting the MCP server.
793
+ {
794
+ name: "aximo_start_testbox",
795
+ title: "Start the testbox",
796
+ 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.",
797
+ inputSchema: {
798
+ name: testboxNameSchema,
799
+ ports: z
800
+ .string()
801
+ .regex(/^[0-9, -]+$/)
802
+ .optional()
803
+ .describe("Host ports the app serves on, e.g. 3000 or 3000,5173-5180. Default 3000."),
804
+ blockHosts: z
805
+ .array(z.string())
806
+ .optional()
807
+ .describe("Hosts the browser must refuse, e.g. the production domain. A browser rule matched by name, not a network boundary."),
808
+ startUrl: z.string().url().optional().describe("Page the browser opens on start."),
809
+ screen: z
810
+ .string()
811
+ .regex(/^\d{3,4}x\d{3,4}$/)
812
+ .optional()
813
+ .describe("Virtual screen size WIDTHxHEIGHT. Default 1280x720; use 1920x1080 only for a layout that needs a wide viewport."),
814
+ timeoutSeconds: z
815
+ .number()
816
+ .int()
817
+ .min(1)
818
+ .max(WAIT_MAX_SECONDS)
819
+ .optional()
820
+ .describe(`Seconds to wait before returning. Default ${WAIT_DEFAULT_SECONDS}.`),
821
+ },
822
+ handler: async ({ name, ports, blockHosts, startUrl, screen, timeoutSeconds }) => call(async () => {
823
+ const deadline = clock.now() + (timeoutSeconds ?? WAIT_DEFAULT_SECONDS) * 1000;
824
+ const boxName = validateBoxName(name);
825
+ const docker = await preflightDocker(testbox.docker ?? runDocker);
826
+ if (isDockerProblem(docker))
827
+ throw new TestboxError(docker.message);
828
+ const current = await statusTestbox(boxName, testbox);
829
+ const reconfigure = ports !== undefined ||
830
+ blockHosts !== undefined ||
831
+ startUrl !== undefined ||
832
+ screen !== undefined;
833
+ if (current.state === "online" && !reconfigure)
834
+ return testboxReport(current);
835
+ if (!pendingUp(boxName, clock)) {
836
+ // A previous run's error would otherwise be read as this run's result.
837
+ clearState(boxName);
838
+ const args = ["device", "testbox", "up", "--no-wait", "--name", boxName];
839
+ if (ports)
840
+ args.push("--ports", ports);
841
+ if (blockHosts?.length)
842
+ args.push("--block-hosts", blockHosts.join(","));
843
+ if (startUrl)
844
+ args.push("--start-url", startUrl);
845
+ if (screen)
846
+ args.push("--screen", screen);
847
+ spawnUp(args, getTestboxLogPath(boxName));
848
+ const settleBy = Math.min(deadline, clock.now() + START_SETTLE_MS);
849
+ while (readState(boxName) === null && clock.now() < settleBy)
850
+ await clock.sleep(500);
851
+ }
852
+ return testboxReport(await waitForTestbox(boxName, secondsLeft(deadline), testbox));
853
+ }),
854
+ },
855
+ {
856
+ name: "aximo_get_testbox",
857
+ title: "Get testbox status",
858
+ 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.",
859
+ inputSchema: {
860
+ name: testboxNameSchema,
861
+ timeoutSeconds: z
862
+ .number()
863
+ .int()
864
+ .min(0)
865
+ .max(WAIT_MAX_SECONDS)
866
+ .optional()
867
+ .describe("Seconds to wait for the box to come online. Default 0: answer at once."),
868
+ },
869
+ handler: async ({ name, timeoutSeconds }) => call(async () => testboxReport(await waitForTestbox(name, timeoutSeconds ?? 0, testbox))),
870
+ },
871
+ {
872
+ name: "aximo_stop_testbox",
873
+ title: "Stop the testbox",
874
+ 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.",
875
+ inputSchema: { name: testboxNameSchema },
876
+ handler: async ({ name }) => call(() => downTestbox(name, testbox)),
877
+ },
727
878
  ];
728
879
  }