@autifyhq/aximo-cli 0.3.0 → 0.4.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 CHANGED
@@ -73,6 +73,7 @@ Run `aximo <command> --help` for detailed usage. `aximo schema [command.path]` r
73
73
  | `aximo session get` | Get session status |
74
74
  | `aximo session save-as-case` | Save a finished session as a reusable test case |
75
75
  | `aximo case create` | Create a test case |
76
+ | `aximo case delete` | Delete a test case |
76
77
  | `aximo plan create` | Create a test plan |
77
78
  | `aximo plan add-case` | Add a case to a plan |
78
79
  | `aximo plan remove-case` | Remove a case from a plan |
@@ -140,13 +141,15 @@ aximo case create --project-id <id> --scenario <text> \
140
141
  [--format gherkin|markdown|plain_text] [--start-url <url>] \
141
142
  [--directory-id <id>] [--json '<body>'] [--dry-run]
142
143
 
144
+ aximo case delete <caseId> [--dry-run]
145
+
143
146
  aximo session save-as-case <sessionId> \
144
147
  [--title <t>] [--type web|mobile|desktop] \
145
148
  [--scenario <text>] [--format gherkin|markdown|plain_text] \
146
149
  [--json '<body>'] [--dry-run]
147
150
  ```
148
151
 
149
- `case create` returns a `caseId` you can run with `aximo trigger case run`. `session save-as-case` persists a finished session (terminal status, not yet linked to a case) as a reusable case, carrying over its scenario and run config. Cases created either way have API access enabled by default, so they are triggerable immediately.
152
+ `case create` returns a `caseId` you can run with `aximo trigger case run`. `session save-as-case` persists a finished session (terminal status, not yet linked to a case) as a reusable case, carrying over its scenario and run config. Cases created either way have API access enabled by default, so they are triggerable immediately. `case delete` permanently removes a case: it is dropped from any plans it belongs to, and past run sessions are kept but detached.
150
153
 
151
154
  ### Test plans
152
155
 
@@ -247,6 +250,33 @@ aximo device connect # start the agent; keep the terminal open while tests r
247
250
 
248
251
  Your device shows up in the Aximo UI under **Settings → Devices** with status `online` while connected. In the session form, pick your device as the execution target; Aximo routes the agent's actions through the tunnel to your machine.
249
252
 
253
+ ### Self-hosted servers (self-signed / custom CA)
254
+
255
+ On-prem Aximo servers often present a self-signed certificate (a private "Aximo CA"). BYOD has **two** TLS clients: the Node CLI (API calls) and the embedded Python desktop agent (the `wss://…/devices/tunnel` relay). They trust a custom CA differently, so configure both:
256
+
257
+ ```bash
258
+ # 1. Save the server's CA on this machine (or copy aximo-ca.pem from your admin).
259
+ openssl s_client -connect YOUR_SERVER:8443 -showcerts </dev/null 2>/dev/null \
260
+ | awk '/BEGIN CERTIFICATE/{i++} i==2{print} /END CERTIFICATE/{if(i==2) exit}' > ~/aximo-ca.pem
261
+
262
+ # 2. Point the CLI (Node) at it. Used for API calls AND auto-forwarded to the agent.
263
+ export NODE_EXTRA_CA_CERTS="$HOME/aximo-ca.pem"
264
+
265
+ aximo device connect
266
+ ```
267
+
268
+ With `NODE_EXTRA_CA_CERTS` set, `device connect` automatically builds a combined trust store (the embedded Python's `certifi` roots **plus** your CA) and points the agent at it via `SSL_CERT_FILE`, so the relay connection verifies without extra steps.
269
+
270
+ To override the agent's trust store explicitly, set `SSL_CERT_FILE` yourself. It must include public roots **and** your CA — `SSL_CERT_FILE` replaces the entire store, so your CA alone would break any other TLS the agent does (browser launch, uploads):
271
+
272
+ ```bash
273
+ PYBIN="$HOME/.aximo/python/bin/python3"
274
+ cat "$($PYBIN -m certifi)" ~/aximo-ca.pem > ~/.aximo/agent-ca-bundle.pem
275
+ export SSL_CERT_FILE="$HOME/.aximo/agent-ca-bundle.pem"
276
+ ```
277
+
278
+ Persist whichever variables you use in your shell profile. Public-CA servers (e.g. `aximo.autify.com`) need none of this.
279
+
250
280
  ### Managing devices
251
281
 
252
282
  ```bash
@@ -382,7 +412,7 @@ claude mcp add aximo -- aximo mcp
382
412
 
383
413
  VS Code (Copilot agent mode) uses the same entry under a `servers` key in `.vscode/mcp.json`.
384
414
 
385
- The server exposes 15 tools: 10 read-only ones for inspecting sessions and cases (`aximo_get_session_timeline` is the load-bearing one for Playwright export) plus 5 that author content (create cases and plans, save a session as a case). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
415
+ The server exposes 16 tools: 10 read-only ones for inspecting sessions and cases (`aximo_get_session_timeline` is the load-bearing one for Playwright export) plus 6 that author content (create and delete cases, create plans, save a session as a case). See the [MCP reference](./skills/aximo-cli/references/mcp.md) for per-client config paths, the full tool table, the Playwright export prompt, and a no-client smoke test.
386
416
 
387
417
  ## Agent Skills
388
418
 
@@ -1,6 +1,7 @@
1
1
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
2
2
  import { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
3
3
  import { runCaseCreate } from "./create.js";
4
+ import { runCaseDelete } from "./delete.js";
4
5
  beforeAll(() => {
5
6
  process.env["AXIMO_API_KEY"] = "test-key";
6
7
  process.env["AXIMO_BASE_URL"] = TEST_BASE_URL;
@@ -84,3 +85,36 @@ describe("runCaseCreate", () => {
84
85
  expect(result.error.code).toBe("not_found");
85
86
  });
86
87
  });
88
+ describe("runCaseDelete", () => {
89
+ it("DELETEs the case path and returns success", async () => {
90
+ let called = false;
91
+ server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, () => {
92
+ called = true;
93
+ return HttpResponse.json({ success: true });
94
+ }));
95
+ const result = await runCaseDelete({ caseId: "c1" });
96
+ expect(result.ok).toBe(true);
97
+ expect(called).toBe(true);
98
+ });
99
+ it("rejects missing caseId", async () => {
100
+ const result = await runCaseDelete({});
101
+ expect(result.ok).toBe(false);
102
+ if (!result.ok)
103
+ expect(result.error.code).toBe("validation_error");
104
+ });
105
+ it("maps 404 to not_found", async () => {
106
+ server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, () => HttpResponse.json({ error: "Test case not found" }, { status: 404 })));
107
+ const result = await runCaseDelete({ caseId: "c1" });
108
+ expect(result.ok).toBe(false);
109
+ if (!result.ok)
110
+ expect(result.error.code).toBe("not_found");
111
+ });
112
+ it("--dry-run returns a DELETE request without calling API", async () => {
113
+ const result = await runCaseDelete({ caseId: "c1", dryRun: true });
114
+ expect(result.ok).toBe(true);
115
+ if (result.ok && "dryRun" in result.data) {
116
+ expect(result.data.method).toBe("DELETE");
117
+ expect(result.data.url).toBe(`${TEST_BASE_URL}/api/cases/c1`);
118
+ }
119
+ });
120
+ });
@@ -0,0 +1,20 @@
1
+ import { type ClientDeps } from "../../client.js";
2
+ import { type DryRunResult } from "../../mutate.js";
3
+ import type { DeleteCaseResult, Envelope } from "../../types.js";
4
+ export interface DeleteCaseOptions {
5
+ caseId?: unknown;
6
+ dryRun?: unknown;
7
+ }
8
+ export declare function runCaseDelete(options: DeleteCaseOptions, deps?: ClientDeps): Promise<Envelope<DeleteCaseResult | DryRunResult>>;
9
+ declare const _default: import("citty").CommandDef<{
10
+ readonly caseId: {
11
+ readonly type: "positional";
12
+ readonly description: "Case 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;
@@ -0,0 +1,39 @@
1
+ import { defineCommand } from "citty";
2
+ import { request } from "../../client.js";
3
+ import { handleApiError } from "../../errors.js";
4
+ import { dryRunEnvelope } from "../../mutate.js";
5
+ import { formatSuccess, printResult, validationError } from "../../output.js";
6
+ import { requireId } from "../../validate.js";
7
+ export async function runCaseDelete(options, deps) {
8
+ let caseId;
9
+ try {
10
+ caseId = requireId("caseId", options.caseId);
11
+ }
12
+ catch (err) {
13
+ return validationError(err);
14
+ }
15
+ const path = `/api/cases/${encodeURIComponent(caseId)}`;
16
+ if (options.dryRun) {
17
+ return dryRunEnvelope("DELETE", path);
18
+ }
19
+ try {
20
+ const res = await request(path, { method: "DELETE" }, deps ?? {});
21
+ return formatSuccess(res);
22
+ }
23
+ catch (err) {
24
+ return handleApiError(err);
25
+ }
26
+ }
27
+ export default defineCommand({
28
+ meta: { name: "delete", description: "Delete a test case" },
29
+ args: {
30
+ caseId: { type: "positional", description: "Case ID", required: true },
31
+ "dry-run": { type: "boolean", description: "Validate and show request without calling API" },
32
+ },
33
+ async run({ args }) {
34
+ printResult(await runCaseDelete({
35
+ caseId: args.caseId,
36
+ dryRun: args["dry-run"],
37
+ }));
38
+ },
39
+ });
@@ -3,5 +3,6 @@ export default defineCommand({
3
3
  meta: { name: "case", description: "Test case commands" },
4
4
  subCommands: {
5
5
  create: () => import("./create.js").then((m) => m.default),
6
+ delete: () => import("./delete.js").then((m) => m.default),
6
7
  },
7
8
  });
@@ -16,6 +16,7 @@ export interface DoctorReport {
16
16
  info: number;
17
17
  };
18
18
  }
19
+ export declare function relayProbeUrl(): string;
19
20
  export interface RunDoctorOptions {
20
21
  skipRelay?: boolean;
21
22
  skipApi?: boolean;
@@ -1,13 +1,13 @@
1
- import { defineCommand } from "citty";
2
1
  import { spawnSync } from "node:child_process";
3
2
  import { existsSync, readFileSync, statSync } from "node:fs";
4
3
  import { platform } from "node:os";
4
+ import { defineCommand } from "citty";
5
5
  import { WebSocket } from "ws";
6
6
  import { getBaseUrl, request } from "../../client.js";
7
- import { formatSuccess, printResult } from "../../output.js";
8
7
  import { loadConfig } from "../../config.js";
9
8
  import { readCachedManifest } from "../../desktop-server/agent-sync.js";
10
9
  import { installedPythonTag, PYTHON_RELEASE, PYTHON_VERSION } from "../../desktop-server/setup.js";
10
+ import { formatSuccess, printResult } from "../../output.js";
11
11
  import { getAgentRequirementsPath, getAgentScriptPath, getDepsMarkerPath, getDeviceConfigPath, getEmbeddedPythonBinary, getPythonDir, getPythonMarkerPath, } from "./paths.js";
12
12
  function redactKey(value) {
13
13
  if (value.length <= 8)
@@ -249,12 +249,21 @@ async function checkAgentUpToDate(deps) {
249
249
  function relayUrlFromBase() {
250
250
  return `${getBaseUrl().replace(/^http/, "ws")}/devices/tunnel`;
251
251
  }
252
+ // The relay is a Socket.IO / engine.io endpoint (websocket-only), which answers
253
+ // only at <path>/?EIO=4&transport=websocket. A raw WebSocket to the bare path
254
+ // 404s or hangs, so the old probe reported a false negative even when the relay
255
+ // was healthy. Probe the engine.io upgrade directly — a successful open (HTTP
256
+ // 101) means the relay is up; device auth is enforced later, after this opens.
257
+ export function relayProbeUrl() {
258
+ return `${relayUrlFromBase()}/?EIO=4&transport=websocket`;
259
+ }
252
260
  const RELAY_CHECK_NAME = "relay handshake";
253
261
  function checkRelayReachable() {
254
262
  return new Promise((resolve) => {
255
263
  const url = relayUrlFromBase();
264
+ const probeUrl = relayProbeUrl();
256
265
  let settled = false;
257
- const ws = new WebSocket(url, { handshakeTimeout: 5_000 });
266
+ const ws = new WebSocket(probeUrl, { handshakeTimeout: 5_000 });
258
267
  const done = (check) => {
259
268
  if (settled)
260
269
  return;
@@ -280,7 +289,7 @@ function checkRelayReachable() {
280
289
  done({
281
290
  name: RELAY_CHECK_NAME,
282
291
  status: "pass",
283
- message: `WebSocket handshake to ${url} succeeded (device auth not verified)`,
292
+ message: `engine.io upgrade to ${probeUrl} succeeded (device auth not verified)`,
284
293
  });
285
294
  });
286
295
  ws.on("error", (err) => {
@@ -1,9 +1,9 @@
1
- import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
2
1
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { tmpdir } from "node:os";
4
3
  import { join } from "node:path";
5
- import { server, http, HttpResponse, TEST_BASE_URL } from "../../test-utils.js";
6
- import { runDeviceDoctor } from "./doctor.js";
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 { relayProbeUrl, runDeviceDoctor } from "./doctor.js";
7
7
  let tempHome;
8
8
  beforeAll(() => {
9
9
  tempHome = mkdtempSync(join(tmpdir(), "aximo-doctor-"));
@@ -87,3 +87,9 @@ describe("runDeviceDoctor", () => {
87
87
  expect(findCheck(result.data.checks, "agent files")?.status).toBe("warn");
88
88
  });
89
89
  });
90
+ describe("relayProbeUrl", () => {
91
+ it("targets the engine.io websocket endpoint, not the bare relay path", () => {
92
+ const wsBase = TEST_BASE_URL.replace(/^http/, "ws");
93
+ expect(relayProbeUrl()).toBe(`${wsBase}/devices/tunnel/?EIO=4&transport=websocket`);
94
+ });
95
+ });
@@ -5,6 +5,7 @@ export declare function getPythonMarkerPath(): string;
5
5
  export declare function getDepsMarkerPath(): string;
6
6
  export declare function getCacheDir(): string;
7
7
  export declare function getRemoteLogPath(): string;
8
+ export declare function getAgentCaBundlePath(): string;
8
9
  export declare function getAgentDir(): string;
9
10
  export declare function getAgentScriptPath(): string;
10
11
  export declare function getAgentRequirementsPath(): string;
@@ -21,6 +21,9 @@ export function getCacheDir() {
21
21
  export function getRemoteLogPath() {
22
22
  return join(getAximoDir(), "remote.log");
23
23
  }
24
+ export function getAgentCaBundlePath() {
25
+ return join(getAximoDir(), "agent-ca-bundle.pem");
26
+ }
24
27
  export function getAgentDir() {
25
28
  return join(getAximoDir(), "agent");
26
29
  }
@@ -24,7 +24,7 @@ async function connect() {
24
24
  };
25
25
  }
26
26
  describe("createMcpServer wiring", () => {
27
- it("exposes all 15 tools through tools/list with valid JSON Schema", async () => {
27
+ it("exposes all 16 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();
@@ -40,6 +40,7 @@ describe("createMcpServer wiring", () => {
40
40
  "aximo_list_projects",
41
41
  "aximo_list_sessions",
42
42
  "aximo_create_case",
43
+ "aximo_delete_case",
43
44
  "aximo_create_plan",
44
45
  "aximo_add_case_to_plan",
45
46
  "aximo_remove_case_from_plan",
@@ -212,6 +212,17 @@ export function defineTools(deps = {}) {
212
212
  return await request("/api/cases", { method: "POST", body: input }, deps);
213
213
  }),
214
214
  },
215
+ {
216
+ name: "aximo_delete_case",
217
+ title: "Delete test case",
218
+ description: "Permanently delete a test case. It is removed from any plans it belongs to; past run sessions are kept but detached. This cannot be undone.",
219
+ inputSchema: {
220
+ caseId: z.string().uuid(),
221
+ },
222
+ handler: async ({ caseId }) => call(async () => {
223
+ return await request(`/api/cases/${encodeURIComponent(caseId)}`, { method: "DELETE" }, deps);
224
+ }),
225
+ },
215
226
  {
216
227
  name: "aximo_create_plan",
217
228
  title: "Create test plan",
@@ -32,6 +32,7 @@ describe("defineTools", () => {
32
32
  "aximo_list_cases",
33
33
  "aximo_get_case",
34
34
  "aximo_create_case",
35
+ "aximo_delete_case",
35
36
  "aximo_create_plan",
36
37
  "aximo_add_case_to_plan",
37
38
  "aximo_remove_case_from_plan",
@@ -255,6 +256,17 @@ describe("write tools", () => {
255
256
  expect(parsePayload(result)).toEqual({ success: true });
256
257
  expect(method).toBe("DELETE");
257
258
  });
259
+ it("aximo_delete_case DELETEs the case path", async () => {
260
+ let method = null;
261
+ server.use(http.delete(`${TEST_BASE_URL}/api/cases/c1`, ({ request: req }) => {
262
+ method = req.method;
263
+ return HttpResponse.json({ success: true });
264
+ }));
265
+ const tool = byName(defineTools(), "aximo_delete_case");
266
+ const result = await tool.handler({ caseId: "c1" });
267
+ expect(parsePayload(result)).toEqual({ success: true });
268
+ expect(method).toBe("DELETE");
269
+ });
258
270
  it("aximo_save_session_as_case POSTs to the save-as-case path without the id in the body", async () => {
259
271
  let body;
260
272
  server.use(http.post(`${TEST_BASE_URL}/api/sessions/s1/save-as-case`, async ({ request: req }) => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { resolveAgentSslCertFile, writeCertBundle } from "./setup.js";
6
+ let tempDir;
7
+ const savedEnv = {};
8
+ beforeEach(() => {
9
+ tempDir = mkdtempSync(join(tmpdir(), "aximo-tls-"));
10
+ for (const key of ["SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "AXIMO_HOME"]) {
11
+ savedEnv[key] = process.env[key];
12
+ delete process.env[key];
13
+ }
14
+ process.env["AXIMO_HOME"] = tempDir;
15
+ });
16
+ afterEach(() => {
17
+ for (const [key, value] of Object.entries(savedEnv)) {
18
+ if (value === undefined)
19
+ delete process.env[key];
20
+ else
21
+ process.env[key] = value;
22
+ }
23
+ rmSync(tempDir, { recursive: true, force: true });
24
+ vi.doUnmock("node:child_process");
25
+ vi.resetModules();
26
+ });
27
+ describe("writeCertBundle", () => {
28
+ it("concatenates certifi roots and the custom CA into one bundle", () => {
29
+ const certifi = join(tempDir, "certifi.pem");
30
+ const customCa = join(tempDir, "ca.pem");
31
+ const out = join(tempDir, "bundle.pem");
32
+ writeFileSync(certifi, "-----CERTIFI ROOT-----\n");
33
+ writeFileSync(customCa, "-----AXIMO CA-----\n");
34
+ writeCertBundle(out, certifi, customCa);
35
+ const result = readFileSync(out, "utf-8");
36
+ expect(result).toContain("-----CERTIFI ROOT-----");
37
+ expect(result).toContain("-----AXIMO CA-----");
38
+ // certifi first, custom CA second, single trailing newline
39
+ expect(result).toBe("-----CERTIFI ROOT-----\n-----AXIMO CA-----\n");
40
+ });
41
+ });
42
+ describe("resolveAgentSslCertFile", () => {
43
+ it("respects an explicit SSL_CERT_FILE override", () => {
44
+ process.env["SSL_CERT_FILE"] = "/custom/bundle.pem";
45
+ process.env["NODE_EXTRA_CA_CERTS"] = join(tempDir, "ca.pem");
46
+ writeFileSync(join(tempDir, "ca.pem"), "ca");
47
+ expect(resolveAgentSslCertFile("/no/python")).toBe("/custom/bundle.pem");
48
+ });
49
+ it("returns undefined when no custom CA is configured (public-CA servers)", () => {
50
+ expect(resolveAgentSslCertFile("/no/python")).toBeUndefined();
51
+ });
52
+ it("returns undefined when NODE_EXTRA_CA_CERTS points at a missing file", () => {
53
+ process.env["NODE_EXTRA_CA_CERTS"] = join(tempDir, "does-not-exist.pem");
54
+ expect(resolveAgentSslCertFile("/no/python")).toBeUndefined();
55
+ });
56
+ it("degrades gracefully (no throw) when the embedded certifi store can't be located", () => {
57
+ process.env["NODE_EXTRA_CA_CERTS"] = join(tempDir, "ca.pem");
58
+ writeFileSync(join(tempDir, "ca.pem"), "-----AXIMO CA-----\n");
59
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => { });
60
+ // A non-existent python makes `python -m certifi` fail, so no bundle is built.
61
+ expect(resolveAgentSslCertFile(join(tempDir, "no-such-python"))).toBeUndefined();
62
+ expect(errSpy).toHaveBeenCalled();
63
+ errSpy.mockRestore();
64
+ });
65
+ it("builds and returns the certifi+CA bundle when a custom CA and certifi store are present", async () => {
66
+ const certifiPath = join(tempDir, "certifi.pem");
67
+ const customCaPath = join(tempDir, "ca.pem");
68
+ writeFileSync(certifiPath, "-----CERTIFI ROOT-----\n");
69
+ writeFileSync(customCaPath, "-----AXIMO CA-----\n");
70
+ process.env["NODE_EXTRA_CA_CERTS"] = customCaPath;
71
+ // Stub `python -m certifi` so it resolves to the fixture certifi store above.
72
+ vi.resetModules();
73
+ vi.doMock("node:child_process", () => ({
74
+ spawn: vi.fn(),
75
+ spawnSync: vi.fn(() => ({ status: 0, stdout: `${certifiPath}\n` })),
76
+ }));
77
+ const { resolveAgentSslCertFile: resolve } = await import("./setup.js");
78
+ const bundlePath = resolve("/fake/python");
79
+ expect(bundlePath).toBe(join(tempDir, "agent-ca-bundle.pem"));
80
+ // The bundle is the certifi roots followed by the custom CA, in that order.
81
+ expect(readFileSync(bundlePath, "utf-8")).toBe("-----CERTIFI ROOT-----\n-----AXIMO CA-----\n");
82
+ });
83
+ });
@@ -15,5 +15,22 @@ interface RemoteOptions {
15
15
  deviceToken: string;
16
16
  deviceName: string;
17
17
  }
18
+ /** Path to the embedded interpreter's certifi CA store, or undefined if unavailable. */
19
+ export declare function embeddedCertifiPath(pythonPath: string): string | undefined;
20
+ /** Write a combined trust store (certifi roots followed by the custom CA) to outPath. */
21
+ export declare function writeCertBundle(outPath: string, certifiPath: string, customCaPath: string): void;
22
+ /**
23
+ * Resolve the SSL_CERT_FILE for the Python desktop agent.
24
+ *
25
+ * The Node CLI trusts a custom CA (e.g. an on-prem self-signed server) via
26
+ * NODE_EXTRA_CA_CERTS, but Python ignores that variable, so the agent's wss://
27
+ * relay connection would fail verification. Mirror the trust by building a
28
+ * bundle of the embedded interpreter's certifi roots PLUS the custom CA and
29
+ * pointing SSL_CERT_FILE at it. SSL_CERT_FILE replaces the whole trust store, so
30
+ * the bundle must contain both — the custom CA alone would break every other TLS
31
+ * the agent does. No-op when no custom CA is configured (public-CA servers
32
+ * already verify via certifi).
33
+ */
34
+ export declare function resolveAgentSslCertFile(pythonPath: string): string | undefined;
18
35
  export declare function startDesktopRemote(pythonPath: string, scriptPath: string, options: RemoteOptions): ChildProcess;
19
36
  export {};
@@ -5,7 +5,7 @@ import { arch as osArch, platform } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { Readable } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
- import { getAximoDir, getCacheDir, getDepsMarkerPath, getEmbeddedPythonBinary, getPythonDir, getPythonMarkerPath, getRemoteLogPath, } from "../commands/device/paths.js";
8
+ import { getAgentCaBundlePath, getAximoDir, getCacheDir, getDepsMarkerPath, getEmbeddedPythonBinary, getPythonDir, getPythonMarkerPath, getRemoteLogPath, } from "../commands/device/paths.js";
9
9
  import { ensureAgent } from "./agent-sync.js";
10
10
  export const PYTHON_RELEASE = "20260414";
11
11
  export const PYTHON_VERSION = "3.12.13";
@@ -174,7 +174,58 @@ export async function ensurePythonReady(deps) {
174
174
  agentChanged: agent.changed,
175
175
  };
176
176
  }
177
+ /** Path to the embedded interpreter's certifi CA store, or undefined if unavailable. */
178
+ export function embeddedCertifiPath(pythonPath) {
179
+ const result = spawnSync(pythonPath, ["-m", "certifi"], { encoding: "utf-8" });
180
+ if (result.status !== 0)
181
+ return undefined;
182
+ const path = (result.stdout ?? "").trim();
183
+ return path && existsSync(path) ? path : undefined;
184
+ }
185
+ /** Write a combined trust store (certifi roots followed by the custom CA) to outPath. */
186
+ export function writeCertBundle(outPath, certifiPath, customCaPath) {
187
+ const certifi = readFileSync(certifiPath, "utf-8");
188
+ const customCa = readFileSync(customCaPath, "utf-8");
189
+ writeFileSync(outPath, `${certifi.trimEnd()}\n${customCa.trimEnd()}\n`, { mode: 0o644 });
190
+ }
191
+ /**
192
+ * Resolve the SSL_CERT_FILE for the Python desktop agent.
193
+ *
194
+ * The Node CLI trusts a custom CA (e.g. an on-prem self-signed server) via
195
+ * NODE_EXTRA_CA_CERTS, but Python ignores that variable, so the agent's wss://
196
+ * relay connection would fail verification. Mirror the trust by building a
197
+ * bundle of the embedded interpreter's certifi roots PLUS the custom CA and
198
+ * pointing SSL_CERT_FILE at it. SSL_CERT_FILE replaces the whole trust store, so
199
+ * the bundle must contain both — the custom CA alone would break every other TLS
200
+ * the agent does. No-op when no custom CA is configured (public-CA servers
201
+ * already verify via certifi).
202
+ */
203
+ export function resolveAgentSslCertFile(pythonPath) {
204
+ // Respect an explicit override — the user may already point Python at a bundle.
205
+ if (process.env["SSL_CERT_FILE"])
206
+ return process.env["SSL_CERT_FILE"];
207
+ const customCa = process.env["NODE_EXTRA_CA_CERTS"];
208
+ if (!customCa || !existsSync(customCa))
209
+ return undefined;
210
+ const certifiPath = embeddedCertifiPath(pythonPath);
211
+ if (!certifiPath) {
212
+ console.error("[remote] NODE_EXTRA_CA_CERTS is set but the embedded Python's certifi store " +
213
+ "could not be located; the desktop agent may fail TLS verification against a " +
214
+ "custom-CA server. Set SSL_CERT_FILE to a certifi+CA bundle to override.");
215
+ return undefined;
216
+ }
217
+ try {
218
+ const bundlePath = getAgentCaBundlePath();
219
+ writeCertBundle(bundlePath, certifiPath, customCa);
220
+ return bundlePath;
221
+ }
222
+ catch (err) {
223
+ console.error(`[remote] Failed to build the agent CA bundle: ${err instanceof Error ? err.message : String(err)}`);
224
+ return undefined;
225
+ }
226
+ }
177
227
  export function startDesktopRemote(pythonPath, scriptPath, options) {
228
+ const sslCertFile = resolveAgentSslCertFile(pythonPath);
178
229
  const child = spawn(pythonPath, [scriptPath], {
179
230
  env: {
180
231
  ...process.env,
@@ -183,6 +234,7 @@ export function startDesktopRemote(pythonPath, scriptPath, options) {
183
234
  RELAY_URL: options.relayUrl,
184
235
  DEVICE_TOKEN: options.deviceToken,
185
236
  DEVICE_NAME: options.deviceName,
237
+ ...(sslCertFile ? { SSL_CERT_FILE: sslCertFile } : {}),
186
238
  },
187
239
  stdio: "pipe",
188
240
  });
package/dist/types.d.ts CHANGED
@@ -133,6 +133,9 @@ export interface CreateCaseResult {
133
133
  success: boolean;
134
134
  caseId: string;
135
135
  }
136
+ export interface DeleteCaseResult {
137
+ success: boolean;
138
+ }
136
139
  export interface CreatePlanInput {
137
140
  projectId: string;
138
141
  title: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",