@autifyhq/aximo-cli 0.4.0 → 0.5.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 +98 -34
- package/dist/cli.js +1 -0
- package/dist/commands/device/device-config.d.ts +14 -0
- package/dist/commands/device/device-config.js +46 -0
- package/dist/commands/device/device-config.test.d.ts +1 -0
- package/dist/commands/device/device-config.test.js +56 -0
- package/dist/commands/device/doctor.d.ts +13 -0
- package/dist/commands/device/doctor.js +142 -25
- package/dist/commands/device/doctor.test.js +61 -1
- package/dist/commands/device/register.d.ts +2 -0
- package/dist/commands/device/register.js +50 -32
- package/dist/commands/device/register.test.d.ts +1 -0
- package/dist/commands/device/register.test.js +95 -0
- package/dist/commands/device/unregister.d.ts +5 -3
- package/dist/commands/device/unregister.js +32 -33
- package/dist/commands/device/unregister.test.d.ts +1 -0
- package/dist/commands/device/unregister.test.js +73 -0
- package/dist/commands/mobile/connect-core.d.ts +12 -0
- package/dist/commands/mobile/connect-core.js +74 -0
- package/dist/commands/mobile/connect.d.ts +9 -0
- package/dist/commands/mobile/connect.js +35 -0
- package/dist/commands/mobile/connect.test.d.ts +1 -0
- package/dist/commands/mobile/connect.test.js +87 -0
- package/dist/commands/mobile/doctor.d.ts +28 -0
- package/dist/commands/mobile/doctor.js +143 -0
- package/dist/commands/mobile/index.d.ts +2 -0
- package/dist/commands/mobile/index.js +15 -0
- package/dist/commands/mobile/list.d.ts +10 -0
- package/dist/commands/mobile/list.js +30 -0
- package/dist/commands/mobile/paths.d.ts +14 -0
- package/dist/commands/mobile/paths.js +71 -0
- package/dist/commands/mobile/register-core.d.ts +19 -0
- package/dist/commands/mobile/register-core.js +46 -0
- package/dist/commands/mobile/register.d.ts +32 -0
- package/dist/commands/mobile/register.js +51 -0
- package/dist/commands/mobile/register.test.d.ts +1 -0
- package/dist/commands/mobile/register.test.js +60 -0
- package/dist/commands/mobile/setup.d.ts +27 -0
- package/dist/commands/mobile/setup.js +100 -0
- package/dist/commands/mobile/setup.test.d.ts +1 -0
- package/dist/commands/mobile/setup.test.js +49 -0
- package/dist/commands/mobile/unregister.d.ts +14 -0
- package/dist/commands/mobile/unregister.js +37 -0
- package/dist/commands/schema.js +22 -0
- package/dist/commands/session/create.d.ts +5 -0
- package/dist/commands/session/create.js +6 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/mobile-control/appium-controller.d.ts +39 -0
- package/dist/mobile-control/appium-controller.js +367 -0
- package/dist/mobile-control/appium-controller.test.d.ts +1 -0
- package/dist/mobile-control/appium-controller.test.js +252 -0
- package/dist/mobile-control/appium-import.d.ts +7 -0
- package/dist/mobile-control/appium-import.js +6 -0
- package/dist/mobile-control/appium-import.test.d.ts +1 -0
- package/dist/mobile-control/appium-import.test.js +9 -0
- package/dist/mobile-control/appium-server.d.ts +30 -0
- package/dist/mobile-control/appium-server.js +110 -0
- package/dist/mobile-control/appium-server.test.d.ts +1 -0
- package/dist/mobile-control/appium-server.test.js +108 -0
- package/dist/mobile-control/controller.d.ts +22 -0
- package/dist/mobile-control/controller.js +1 -0
- package/dist/mobile-control/devices.d.ts +21 -0
- package/dist/mobile-control/devices.js +99 -0
- package/dist/mobile-control/devices.test.d.ts +1 -0
- package/dist/mobile-control/devices.test.js +35 -0
- package/dist/mobile-control/exec.d.ts +6 -0
- package/dist/mobile-control/exec.js +13 -0
- package/dist/mobile-control/tunnel.d.ts +11 -0
- package/dist/mobile-control/tunnel.js +152 -0
- package/dist/mobile-control/tunnel.test.d.ts +1 -0
- package/dist/mobile-control/tunnel.test.js +155 -0
- package/dist/mobile-control/webdriver.d.ts +31 -0
- package/dist/mobile-control/webdriver.js +137 -0
- package/dist/mobile-control/webdriver.test.d.ts +1 -0
- package/dist/mobile-control/webdriver.test.js +45 -0
- package/dist/types.d.ts +1 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -64,40 +64,46 @@ Credentials and device registrations are **per-environment** — to switch betwe
|
|
|
64
64
|
|
|
65
65
|
Run `aximo <command> --help` for detailed usage. `aximo schema [command.path]` returns machine-readable argument introspection (e.g. `aximo schema session.create`).
|
|
66
66
|
|
|
67
|
-
| Command | Description
|
|
68
|
-
| ---------------------------- |
|
|
69
|
-
| `aximo configure` | Save credentials to `$AXIMO_HOME/config.json`
|
|
70
|
-
| `aximo health check` | Check API reachability
|
|
71
|
-
| `aximo project list` | List organization projects
|
|
72
|
-
| `aximo session create` | Create and queue a test session
|
|
73
|
-
| `aximo session get` | Get session status
|
|
74
|
-
| `aximo session save-as-case` | Save a finished session as a reusable test case
|
|
75
|
-
| `aximo case create` | Create a test case
|
|
76
|
-
| `aximo case delete` | Delete a test case
|
|
77
|
-
| `aximo plan create` | Create a test plan
|
|
78
|
-
| `aximo plan add-case` | Add a case to a plan
|
|
79
|
-
| `aximo plan remove-case` | Remove a case from a plan
|
|
80
|
-
| `aximo trigger case run` | Trigger a dashboard-created test case
|
|
81
|
-
| `aximo trigger case get` | Get triggered case status
|
|
82
|
-
| `aximo trigger plan run` | Trigger a test plan
|
|
83
|
-
| `aximo trigger plan get` | Get plan run status
|
|
84
|
-
| `aximo upload create` | Create an upload slot for an APK/IPA
|
|
85
|
-
| `aximo upload list` | List previously uploaded apps
|
|
86
|
-
| `aximo upload get` | Poll upload processing status
|
|
87
|
-
| `aximo upload put` | Upload binary file to the slot
|
|
88
|
-
| `aximo upload submit` | Finalize upload and trigger processing
|
|
89
|
-
| `aximo device list` | List mobile devices from Device Farm
|
|
90
|
-
| `aximo device setup` | First-time BYOD onboarding (API + Python + register)
|
|
91
|
-
| `aximo device register` | Register this machine as a BYOD device
|
|
92
|
-
| `aximo device connect` | Start the BYOD agent and connect to the relay
|
|
93
|
-
| `aximo device status` | List your organization's BYOD devices
|
|
94
|
-
| `aximo device ping` | Check your BYOD device's server-side status
|
|
95
|
-
| `aximo device doctor` | Diagnose BYOD setup (env, Python, relay, status)
|
|
96
|
-
| `aximo device logs` | Show the BYOD agent log
|
|
97
|
-
| `aximo device unregister` | Remove a BYOD device server-side + clear local creds
|
|
98
|
-
| `aximo device reset` | Remove BYOD local state (Python, deps, credentials)
|
|
99
|
-
| `aximo
|
|
100
|
-
| `aximo
|
|
67
|
+
| Command | Description |
|
|
68
|
+
| ---------------------------- | ------------------------------------------------------ |
|
|
69
|
+
| `aximo configure` | Save credentials to `$AXIMO_HOME/config.json` |
|
|
70
|
+
| `aximo health check` | Check API reachability |
|
|
71
|
+
| `aximo project list` | List organization projects |
|
|
72
|
+
| `aximo session create` | Create and queue a test session |
|
|
73
|
+
| `aximo session get` | Get session status |
|
|
74
|
+
| `aximo session save-as-case` | Save a finished session as a reusable test case |
|
|
75
|
+
| `aximo case create` | Create a test case |
|
|
76
|
+
| `aximo case delete` | Delete a test case |
|
|
77
|
+
| `aximo plan create` | Create a test plan |
|
|
78
|
+
| `aximo plan add-case` | Add a case to a plan |
|
|
79
|
+
| `aximo plan remove-case` | Remove a case from a plan |
|
|
80
|
+
| `aximo trigger case run` | Trigger a dashboard-created test case |
|
|
81
|
+
| `aximo trigger case get` | Get triggered case status |
|
|
82
|
+
| `aximo trigger plan run` | Trigger a test plan |
|
|
83
|
+
| `aximo trigger plan get` | Get plan run status |
|
|
84
|
+
| `aximo upload create` | Create an upload slot for an APK/IPA |
|
|
85
|
+
| `aximo upload list` | List previously uploaded apps |
|
|
86
|
+
| `aximo upload get` | Poll upload processing status |
|
|
87
|
+
| `aximo upload put` | Upload binary file to the slot |
|
|
88
|
+
| `aximo upload submit` | Finalize upload and trigger processing |
|
|
89
|
+
| `aximo device list` | List mobile devices from Device Farm |
|
|
90
|
+
| `aximo device setup` | First-time BYOD onboarding (API + Python + register) |
|
|
91
|
+
| `aximo device register` | Register this machine as a BYOD device |
|
|
92
|
+
| `aximo device connect` | Start the BYOD agent and connect to the relay |
|
|
93
|
+
| `aximo device status` | List your organization's BYOD devices |
|
|
94
|
+
| `aximo device ping` | Check your BYOD device's server-side status |
|
|
95
|
+
| `aximo device doctor` | Diagnose BYOD setup (env, Python, relay, status) |
|
|
96
|
+
| `aximo device logs` | Show the BYOD agent log |
|
|
97
|
+
| `aximo device unregister` | Remove a BYOD device server-side + clear local creds |
|
|
98
|
+
| `aximo device reset` | Remove BYOD local state (Python, deps, credentials) |
|
|
99
|
+
| `aximo mobile list` | List physically-connected phones (Android via adb) |
|
|
100
|
+
| `aximo mobile setup` | Install the Appium drivers used to drive phones |
|
|
101
|
+
| `aximo mobile doctor` | Diagnose Appium prerequisites (adb, SDK, driver) |
|
|
102
|
+
| `aximo mobile register` | Register a connected Android phone as an Aximo device |
|
|
103
|
+
| `aximo mobile connect` | Start managed Appium + open the tunnel for the phone |
|
|
104
|
+
| `aximo mobile unregister` | Remove a local mobile device server-side + local creds |
|
|
105
|
+
| `aximo schema` | Show CLI surface as JSON |
|
|
106
|
+
| `aximo mcp` | Run a stdio MCP server for agents (see below) |
|
|
101
107
|
|
|
102
108
|
Mutating commands support `--json '<raw body>'` for direct OpenAPI payloads and `--dry-run` to preview requests without executing.
|
|
103
109
|
|
|
@@ -388,6 +394,64 @@ Default `$AXIMO_HOME` is `~/.aximo` on macOS/Linux, `%USERPROFILE%\.aximo` on Wi
|
|
|
388
394
|
|
|
389
395
|
> Devices are registered per-environment (see [Configuration](#configuration)) — a device registered against sandbox can't connect to production and vice versa.
|
|
390
396
|
|
|
397
|
+
## Mobile (local Android phone)
|
|
398
|
+
|
|
399
|
+
The `mobile` commands let you register a **physically-connected Android phone** as an Aximo device and drive it locally, over the same gateway tunnel as desktop BYOD. Aximo's agent controls the phone through a CLI-managed [Appium](https://appium.io) (UiAutomator2) server — useful for running mobile tests against your own handset instead of a Device Farm device.
|
|
400
|
+
|
|
401
|
+
> **Android only today.** iOS local devices are not yet supported — `aximo mobile register --platform ios` is blocked, and registration accepts only `--platform android`. (The Appium XCUITest plumbing is partially present but disabled; iOS local devices are planned for a future release.)
|
|
402
|
+
|
|
403
|
+
Terminology note: **`aximo mobile list`** shows phones plugged into _this_ machine; **`aximo device list`** (under [Device / BYOD](#device-byod--bring-your-own-device)) returns mobile devices from AWS Device Farm. Don't confuse the two.
|
|
404
|
+
|
|
405
|
+
### Prerequisites
|
|
406
|
+
|
|
407
|
+
- **Node.js 20+**, plus `aximo-cli` installed globally (see [Install](#install)).
|
|
408
|
+
- **An Aximo API key**, configured via `aximo configure` or env vars.
|
|
409
|
+
- **A USB-connected Android phone** with **USB debugging** enabled in Developer Options, plugged in, and authorized (accept the "Allow USB debugging?" prompt on the phone).
|
|
410
|
+
- **adb** (Android platform-tools) on your `PATH` — Appium delegates to it. Install via `brew install --cask android-platform-tools` (macOS), your distro's `adb` package (Linux), or the Android Studio SDK Manager (Windows).
|
|
411
|
+
- **The Android SDK**, with `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) set — e.g. installed via Android Studio. Appium's UiAutomator2 driver requires it even though adb is already on PATH.
|
|
412
|
+
|
|
413
|
+
> The Appium server is bundled with the CLI, and the UiAutomator2 driver is installed by `aximo mobile setup` — you don't manage Appium yourself.
|
|
414
|
+
|
|
415
|
+
### First-time setup
|
|
416
|
+
|
|
417
|
+
```bash
|
|
418
|
+
aximo mobile setup # installs the UiAutomator2 Appium driver
|
|
419
|
+
aximo mobile doctor # checks adb / Android SDK / driver / bundled server
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
`setup` installs the Appium driver, then prints what (if anything) is still missing. `doctor` is the first thing to run when anything's off — it reports the bundled Appium server, the UiAutomator2 driver, `adb`, and the Android SDK in one shot, and exits non-zero if a required check fails.
|
|
423
|
+
|
|
424
|
+
### Registering and connecting
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
aximo mobile list # see connected phones + their adb serials
|
|
428
|
+
aximo mobile register --name "My Pixel" --platform android # --platform defaults to android
|
|
429
|
+
aximo mobile connect # starts managed Appium + opens the tunnel
|
|
430
|
+
# Ctrl+C to disconnect
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
`register` writes credentials to `$AXIMO_HOME/mobile-devices/<deviceId>.json` (mode `0600`). It targets the single connected phone by default — pass `--serial` to pick a specific one when several are attached. `connect` re-checks prerequisites, starts the managed Appium server, and opens the tunnel; it runs until Ctrl+C. If you have more than one registered phone, pass `--device-id` to choose which to connect.
|
|
434
|
+
|
|
435
|
+
Once connected, create a **mobile session in Aximo targeting this local device** — the agent routes its actions through the tunnel to the phone over Appium.
|
|
436
|
+
|
|
437
|
+
### Managing devices
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
aximo mobile unregister # remove the locally registered device (server-side + local creds)
|
|
441
|
+
aximo mobile unregister --device-id DEVICE_ID # target a specific device when several are registered
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### Flags reference
|
|
445
|
+
|
|
446
|
+
| Command | Flags |
|
|
447
|
+
| ------------------------- | ----------------------------------------------------------------------------------- |
|
|
448
|
+
| `aximo mobile list` | _(none)_ |
|
|
449
|
+
| `aximo mobile setup` | _(none)_ |
|
|
450
|
+
| `aximo mobile doctor` | _(none)_ |
|
|
451
|
+
| `aximo mobile register` | `--name <name>` (required), `--platform android` (default), `--serial <adb-serial>` |
|
|
452
|
+
| `aximo mobile connect` | `--device-id <id>` (required only if multiple devices are registered) |
|
|
453
|
+
| `aximo mobile unregister` | `--device-id <id>` (defaults to the locally registered device) |
|
|
454
|
+
|
|
391
455
|
## MCP server
|
|
392
456
|
|
|
393
457
|
`aximo mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio, exposing your recorded sessions, screenshots, and test cases to MCP-capable agents (Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, …). The motivating use: let an agent read a finished session and generate a Playwright spec without you piping JSON between commands.
|
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ const main = defineCommand({
|
|
|
27
27
|
plan: () => import("./commands/plan/index.js").then((m) => m.default),
|
|
28
28
|
trigger: () => import("./commands/trigger/index.js").then((m) => m.default),
|
|
29
29
|
device: () => import("./commands/device/index.js").then((m) => m.default),
|
|
30
|
+
mobile: () => import("./commands/mobile/index.js").then((m) => m.default),
|
|
30
31
|
upload: () => import("./commands/upload/index.js").then((m) => m.default),
|
|
31
32
|
project: () => import("./commands/project/index.js").then((m) => m.default),
|
|
32
33
|
schema: () => import("./commands/schema.js").then((m) => m.default),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface DeviceConfig {
|
|
2
|
+
deviceId: string;
|
|
3
|
+
deviceToken: string;
|
|
4
|
+
name: string;
|
|
5
|
+
registeredAt: string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Read and validate the local device registration (~/.aximo/device.json).
|
|
10
|
+
* Returns null when the file is missing, unreadable, or malformed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readDeviceConfig(): DeviceConfig | null;
|
|
13
|
+
/** Persist the local device registration with owner-only permissions. */
|
|
14
|
+
export declare function writeDeviceConfig(config: DeviceConfig): void;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { getAximoDir, getDeviceConfigPath } from "./paths.js";
|
|
3
|
+
/**
|
|
4
|
+
* Read and validate the local device registration (~/.aximo/device.json).
|
|
5
|
+
* Returns null when the file is missing, unreadable, or malformed.
|
|
6
|
+
*/
|
|
7
|
+
export function readDeviceConfig() {
|
|
8
|
+
const path = getDeviceConfigPath();
|
|
9
|
+
if (!existsSync(path))
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
13
|
+
if (!parsed || typeof parsed !== "object")
|
|
14
|
+
return null;
|
|
15
|
+
const raw = parsed;
|
|
16
|
+
if (typeof raw.deviceId !== "string" ||
|
|
17
|
+
typeof raw.deviceToken !== "string" ||
|
|
18
|
+
typeof raw.name !== "string") {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
deviceId: raw.deviceId,
|
|
23
|
+
deviceToken: raw.deviceToken,
|
|
24
|
+
name: raw.name,
|
|
25
|
+
registeredAt: typeof raw.registeredAt === "string" ? raw.registeredAt : "",
|
|
26
|
+
...(typeof raw.baseUrl === "string" ? { baseUrl: raw.baseUrl } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Persist the local device registration with owner-only permissions. */
|
|
34
|
+
export function writeDeviceConfig(config) {
|
|
35
|
+
const dir = getAximoDir();
|
|
36
|
+
if (!existsSync(dir))
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
const path = getDeviceConfigPath();
|
|
39
|
+
writeFileSync(path, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
40
|
+
try {
|
|
41
|
+
chmodSync(path, 0o600);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// chmod may fail on Windows; file-level ACLs apply instead.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
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 } from "vitest";
|
|
5
|
+
import { readDeviceConfig, writeDeviceConfig } from "./device-config.js";
|
|
6
|
+
let tempHome;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
tempHome = mkdtempSync(join(tmpdir(), "aximo-device-config-"));
|
|
9
|
+
process.env["AXIMO_HOME"] = tempHome;
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
delete process.env["AXIMO_HOME"];
|
|
13
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
function devicePath() {
|
|
16
|
+
return join(tempHome, "device.json");
|
|
17
|
+
}
|
|
18
|
+
describe("device-config", () => {
|
|
19
|
+
it("returns null when the file is missing", () => {
|
|
20
|
+
expect(readDeviceConfig()).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
it("round-trips a config including baseUrl", () => {
|
|
23
|
+
writeDeviceConfig({
|
|
24
|
+
deviceId: "d1",
|
|
25
|
+
deviceToken: "tok",
|
|
26
|
+
name: "mac",
|
|
27
|
+
registeredAt: "2026-06-29T00:00:00.000Z",
|
|
28
|
+
baseUrl: "https://srv:8443",
|
|
29
|
+
});
|
|
30
|
+
expect(readDeviceConfig()).toEqual({
|
|
31
|
+
deviceId: "d1",
|
|
32
|
+
deviceToken: "tok",
|
|
33
|
+
name: "mac",
|
|
34
|
+
registeredAt: "2026-06-29T00:00:00.000Z",
|
|
35
|
+
baseUrl: "https://srv:8443",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
it("reads a pre-AT-577 file with no baseUrl (backward compatible)", () => {
|
|
39
|
+
writeFileSync(devicePath(), JSON.stringify({ deviceId: "d1", deviceToken: "tok", name: "mac", registeredAt: "t" }));
|
|
40
|
+
const cfg = readDeviceConfig();
|
|
41
|
+
expect(cfg).not.toBeNull();
|
|
42
|
+
expect(cfg?.deviceId).toBe("d1");
|
|
43
|
+
expect(cfg?.baseUrl).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
it("returns null for malformed json or a missing deviceId", () => {
|
|
46
|
+
writeFileSync(devicePath(), "{ not json");
|
|
47
|
+
expect(readDeviceConfig()).toBeNull();
|
|
48
|
+
writeFileSync(devicePath(), JSON.stringify({ deviceToken: "tok", name: "mac" }));
|
|
49
|
+
expect(readDeviceConfig()).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
it("writes the file with owner-only permissions", () => {
|
|
52
|
+
writeDeviceConfig({ deviceId: "d1", deviceToken: "tok", name: "mac", registeredAt: "t" });
|
|
53
|
+
// Sanity: file is valid JSON and readable back.
|
|
54
|
+
expect(JSON.parse(readFileSync(devicePath(), "utf-8"))).toMatchObject({ deviceId: "d1" });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -17,6 +17,19 @@ export interface DoctorReport {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
export declare function relayProbeUrl(): string;
|
|
20
|
+
export type TlsProbeResult = {
|
|
21
|
+
ok: true;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
kind: "cert" | "conn";
|
|
25
|
+
reason: string;
|
|
26
|
+
};
|
|
27
|
+
export interface AgentTlsDeps {
|
|
28
|
+
env?: NodeJS.ProcessEnv;
|
|
29
|
+
baseUrl?: string;
|
|
30
|
+
probe?: (host: string, port: number, ca: Buffer) => Promise<TlsProbeResult>;
|
|
31
|
+
}
|
|
32
|
+
export declare function checkAgentTlsTrust(deps?: AgentTlsDeps): Promise<Check>;
|
|
20
33
|
export interface RunDoctorOptions {
|
|
21
34
|
skipRelay?: boolean;
|
|
22
35
|
skipApi?: boolean;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { platform } from "node:os";
|
|
4
|
+
import { connect as tlsConnect } from "node:tls";
|
|
4
5
|
import { defineCommand } from "citty";
|
|
5
6
|
import { WebSocket } from "ws";
|
|
6
7
|
import { getBaseUrl, request } from "../../client.js";
|
|
@@ -8,6 +9,7 @@ import { loadConfig } from "../../config.js";
|
|
|
8
9
|
import { readCachedManifest } from "../../desktop-server/agent-sync.js";
|
|
9
10
|
import { installedPythonTag, PYTHON_RELEASE, PYTHON_VERSION } from "../../desktop-server/setup.js";
|
|
10
11
|
import { formatSuccess, printResult } from "../../output.js";
|
|
12
|
+
import { readDeviceConfig } from "./device-config.js";
|
|
11
13
|
import { getAgentRequirementsPath, getAgentScriptPath, getDepsMarkerPath, getDeviceConfigPath, getEmbeddedPythonBinary, getPythonDir, getPythonMarkerPath, } from "./paths.js";
|
|
12
14
|
function redactKey(value) {
|
|
13
15
|
if (value.length <= 8)
|
|
@@ -68,8 +70,7 @@ async function checkApiHealth(deps) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
function checkDeviceConfig() {
|
|
71
|
-
|
|
72
|
-
if (!existsSync(configPath)) {
|
|
73
|
+
if (!existsSync(getDeviceConfigPath())) {
|
|
73
74
|
return {
|
|
74
75
|
check: {
|
|
75
76
|
name: "device registered",
|
|
@@ -79,37 +80,25 @@ function checkDeviceConfig() {
|
|
|
79
80
|
},
|
|
80
81
|
};
|
|
81
82
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (!config.deviceId) {
|
|
85
|
-
return {
|
|
86
|
-
check: {
|
|
87
|
-
name: "device registered",
|
|
88
|
-
status: "fail",
|
|
89
|
-
message: "device.json missing deviceId",
|
|
90
|
-
hint: "Re-register: aximo device register --name ...",
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
return {
|
|
95
|
-
check: {
|
|
96
|
-
name: "device registered",
|
|
97
|
-
status: "pass",
|
|
98
|
-
message: `${config.name ?? "(unnamed)"} · ${config.deviceId}`,
|
|
99
|
-
},
|
|
100
|
-
config,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
catch (err) {
|
|
83
|
+
const config = readDeviceConfig();
|
|
84
|
+
if (!config) {
|
|
104
85
|
return {
|
|
105
86
|
check: {
|
|
106
87
|
name: "device registered",
|
|
107
88
|
status: "fail",
|
|
108
|
-
message:
|
|
89
|
+
message: "device.json unreadable or missing required fields",
|
|
109
90
|
hint: "Delete the file and re-register",
|
|
110
91
|
},
|
|
111
92
|
};
|
|
112
93
|
}
|
|
94
|
+
return {
|
|
95
|
+
check: {
|
|
96
|
+
name: "device registered",
|
|
97
|
+
status: "pass",
|
|
98
|
+
message: `${config.name} · ${config.deviceId}`,
|
|
99
|
+
},
|
|
100
|
+
config,
|
|
101
|
+
};
|
|
113
102
|
}
|
|
114
103
|
function checkEmbeddedPython() {
|
|
115
104
|
if (!existsSync(getPythonDir())) {
|
|
@@ -303,6 +292,133 @@ function checkRelayReachable() {
|
|
|
303
292
|
});
|
|
304
293
|
});
|
|
305
294
|
}
|
|
295
|
+
const NET_ERROR_CODES = new Set([
|
|
296
|
+
"ECONNREFUSED",
|
|
297
|
+
"ENOTFOUND",
|
|
298
|
+
"ETIMEDOUT",
|
|
299
|
+
"ECONNRESET",
|
|
300
|
+
"EHOSTUNREACH",
|
|
301
|
+
"ENETUNREACH",
|
|
302
|
+
"EAI_AGAIN",
|
|
303
|
+
"EPIPE",
|
|
304
|
+
]);
|
|
305
|
+
function classifyTlsError(err) {
|
|
306
|
+
const code = err.code ?? "";
|
|
307
|
+
if (NET_ERROR_CODES.has(code))
|
|
308
|
+
return "conn";
|
|
309
|
+
if (/CERT|SELF_SIGNED/.test(code) ||
|
|
310
|
+
/certificate|self.signed|verify|altname/i.test(err.message)) {
|
|
311
|
+
return "cert";
|
|
312
|
+
}
|
|
313
|
+
return "conn";
|
|
314
|
+
}
|
|
315
|
+
// TLS handshake to host:port trusting ONLY `ca`. Mirrors how the Python agent
|
|
316
|
+
// verifies the relay via SSL_CERT_FILE, which replaces the whole trust store —
|
|
317
|
+
// passing `ca` to tls.connect likewise replaces Node's default roots.
|
|
318
|
+
function defaultTlsProbe(host, port, ca) {
|
|
319
|
+
return new Promise((resolve) => {
|
|
320
|
+
let settled = false;
|
|
321
|
+
const finish = (r) => {
|
|
322
|
+
if (settled)
|
|
323
|
+
return;
|
|
324
|
+
settled = true;
|
|
325
|
+
resolve(r);
|
|
326
|
+
};
|
|
327
|
+
const socket = tlsConnect({ host, port, ca, servername: host, rejectUnauthorized: true }, () => {
|
|
328
|
+
socket.end();
|
|
329
|
+
finish({ ok: true });
|
|
330
|
+
});
|
|
331
|
+
socket.setTimeout(5_000, () => {
|
|
332
|
+
socket.destroy();
|
|
333
|
+
finish({ ok: false, kind: "conn", reason: "timed out" });
|
|
334
|
+
});
|
|
335
|
+
socket.on("error", (err) => {
|
|
336
|
+
socket.destroy();
|
|
337
|
+
finish({ ok: false, kind: classifyTlsError(err), reason: err.message });
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const AGENT_TLS_CHECK_NAME = "agent TLS trust";
|
|
342
|
+
// The desktop agent (Python) verifies the wss:// relay against SSL_CERT_FILE — a
|
|
343
|
+
// different trust path than the Node CLI's NODE_EXTRA_CA_CERTS. A stale
|
|
344
|
+
// SSL_CERT_FILE (e.g. a bundle from a previous ephemeral on-prem box) makes the
|
|
345
|
+
// agent fail TLS even though every CLI check — including the relay handshake
|
|
346
|
+
// above — passes. Catch that gap by validating the relay against the same bundle
|
|
347
|
+
// the agent will use.
|
|
348
|
+
export async function checkAgentTlsTrust(deps = {}) {
|
|
349
|
+
const name = AGENT_TLS_CHECK_NAME;
|
|
350
|
+
const env = deps.env ?? process.env;
|
|
351
|
+
const rawBase = deps.baseUrl ?? getBaseUrl();
|
|
352
|
+
let url;
|
|
353
|
+
try {
|
|
354
|
+
url = new URL(rawBase);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
return { name, status: "info", message: `base URL not parseable: ${rawBase}` };
|
|
358
|
+
}
|
|
359
|
+
if (url.protocol !== "https:") {
|
|
360
|
+
return {
|
|
361
|
+
name,
|
|
362
|
+
status: "info",
|
|
363
|
+
message: "relay is plain ws:// (no TLS); SSL_CERT_FILE not used",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const sslCertFile = env["SSL_CERT_FILE"];
|
|
367
|
+
if (!sslCertFile) {
|
|
368
|
+
return env["NODE_EXTRA_CA_CERTS"]
|
|
369
|
+
? {
|
|
370
|
+
name,
|
|
371
|
+
status: "info",
|
|
372
|
+
message: "SSL_CERT_FILE unset; agent bundle is built from NODE_EXTRA_CA_CERTS at connect",
|
|
373
|
+
}
|
|
374
|
+
: { name, status: "info", message: "SSL_CERT_FILE unset; agent trusts public CAs (certifi)" };
|
|
375
|
+
}
|
|
376
|
+
const unsetHint = "Unset SSL_CERT_FILE so the CLI rebuilds it from NODE_EXTRA_CA_CERTS, or regenerate the bundle for this server.";
|
|
377
|
+
if (!existsSync(sslCertFile)) {
|
|
378
|
+
return {
|
|
379
|
+
name,
|
|
380
|
+
status: "fail",
|
|
381
|
+
message: `SSL_CERT_FILE points at a missing file: ${sslCertFile}`,
|
|
382
|
+
hint: unsetHint,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
let ca;
|
|
386
|
+
try {
|
|
387
|
+
ca = readFileSync(sslCertFile);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
return {
|
|
391
|
+
name,
|
|
392
|
+
status: "fail",
|
|
393
|
+
message: `SSL_CERT_FILE unreadable (${sslCertFile}): ${err instanceof Error ? err.message : String(err)}`,
|
|
394
|
+
hint: unsetHint,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const host = url.hostname;
|
|
398
|
+
const port = url.port ? Number(url.port) : 443;
|
|
399
|
+
const probe = deps.probe ?? defaultTlsProbe;
|
|
400
|
+
const result = await probe(host, port, ca);
|
|
401
|
+
if (result.ok) {
|
|
402
|
+
return {
|
|
403
|
+
name,
|
|
404
|
+
status: "pass",
|
|
405
|
+
message: `SSL_CERT_FILE verifies ${host}:${port} (agent TLS OK)`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (result.kind === "cert") {
|
|
409
|
+
return {
|
|
410
|
+
name,
|
|
411
|
+
status: "fail",
|
|
412
|
+
message: `SSL_CERT_FILE is set (${sslCertFile}) but does not verify ${host}:${port}: ${result.reason}`,
|
|
413
|
+
hint: unsetHint,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
return {
|
|
417
|
+
name,
|
|
418
|
+
status: "info",
|
|
419
|
+
message: `could not reach ${host}:${port} to test SSL_CERT_FILE trust: ${result.reason}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
306
422
|
async function checkCurrentStatus(deviceId, deps) {
|
|
307
423
|
const name = "device status";
|
|
308
424
|
try {
|
|
@@ -399,6 +515,7 @@ export async function runDeviceDoctor(options = {}, deps) {
|
|
|
399
515
|
}
|
|
400
516
|
if (!options.skipRelay) {
|
|
401
517
|
checks.push(await checkRelayReachable());
|
|
518
|
+
checks.push(await checkAgentTlsTrust());
|
|
402
519
|
}
|
|
403
520
|
if (config?.deviceId && !options.skipApi && checks[0]?.status !== "fail") {
|
|
404
521
|
checks.push(await checkCurrentStatus(config.deviceId, deps));
|
|
@@ -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 { HttpResponse, http, server, TEST_BASE_URL } from "../../test-utils.js";
|
|
6
|
-
import { relayProbeUrl, runDeviceDoctor } from "./doctor.js";
|
|
6
|
+
import { checkAgentTlsTrust, relayProbeUrl, runDeviceDoctor } from "./doctor.js";
|
|
7
7
|
let tempHome;
|
|
8
8
|
beforeAll(() => {
|
|
9
9
|
tempHome = mkdtempSync(join(tmpdir(), "aximo-doctor-"));
|
|
@@ -93,3 +93,63 @@ describe("relayProbeUrl", () => {
|
|
|
93
93
|
expect(relayProbeUrl()).toBe(`${wsBase}/devices/tunnel/?EIO=4&transport=websocket`);
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
|
+
describe("checkAgentTlsTrust", () => {
|
|
97
|
+
const HTTPS = "https://relay.test:8443";
|
|
98
|
+
it("is not applicable for a plain-http base (relay is ws://)", async () => {
|
|
99
|
+
const c = await checkAgentTlsTrust({ baseUrl: "http://relay.test:8080", env: {} });
|
|
100
|
+
expect(c.status).toBe("info");
|
|
101
|
+
expect(c.message).toContain("plain ws");
|
|
102
|
+
});
|
|
103
|
+
it("reports info when SSL_CERT_FILE is unset (CLI builds the bundle)", async () => {
|
|
104
|
+
const c = await checkAgentTlsTrust({
|
|
105
|
+
baseUrl: HTTPS,
|
|
106
|
+
env: { NODE_EXTRA_CA_CERTS: "/x/ca.pem" },
|
|
107
|
+
});
|
|
108
|
+
expect(c.status).toBe("info");
|
|
109
|
+
expect(c.message).toContain("NODE_EXTRA_CA_CERTS");
|
|
110
|
+
});
|
|
111
|
+
it("passes when SSL_CERT_FILE verifies the relay host", async () => {
|
|
112
|
+
const bundle = join(tempHome, "bundle.pem");
|
|
113
|
+
writeFileSync(bundle, "x");
|
|
114
|
+
const c = await checkAgentTlsTrust({
|
|
115
|
+
baseUrl: HTTPS,
|
|
116
|
+
env: { SSL_CERT_FILE: bundle },
|
|
117
|
+
probe: () => Promise.resolve({ ok: true }),
|
|
118
|
+
});
|
|
119
|
+
expect(c.status).toBe("pass");
|
|
120
|
+
});
|
|
121
|
+
it("fails when SSL_CERT_FILE is stale and can't verify the relay (the AT-577 trap)", async () => {
|
|
122
|
+
const bundle = join(tempHome, "bundle.pem");
|
|
123
|
+
writeFileSync(bundle, "x");
|
|
124
|
+
const c = await checkAgentTlsTrust({
|
|
125
|
+
baseUrl: HTTPS,
|
|
126
|
+
env: { SSL_CERT_FILE: bundle },
|
|
127
|
+
probe: () => Promise.resolve({
|
|
128
|
+
ok: false,
|
|
129
|
+
kind: "cert",
|
|
130
|
+
reason: "self signed certificate in certificate chain",
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
expect(c.status).toBe("fail");
|
|
134
|
+
expect(c.message).toContain("does not verify");
|
|
135
|
+
expect(c.hint).toContain("Unset SSL_CERT_FILE");
|
|
136
|
+
});
|
|
137
|
+
it("reports info (not fail) when the relay host is unreachable", async () => {
|
|
138
|
+
const bundle = join(tempHome, "bundle.pem");
|
|
139
|
+
writeFileSync(bundle, "x");
|
|
140
|
+
const c = await checkAgentTlsTrust({
|
|
141
|
+
baseUrl: HTTPS,
|
|
142
|
+
env: { SSL_CERT_FILE: bundle },
|
|
143
|
+
probe: () => Promise.resolve({ ok: false, kind: "conn", reason: "ECONNREFUSED" }),
|
|
144
|
+
});
|
|
145
|
+
expect(c.status).toBe("info");
|
|
146
|
+
});
|
|
147
|
+
it("fails when SSL_CERT_FILE points at a missing file", async () => {
|
|
148
|
+
const c = await checkAgentTlsTrust({
|
|
149
|
+
baseUrl: HTTPS,
|
|
150
|
+
env: { SSL_CERT_FILE: join(tempHome, "nope.pem") },
|
|
151
|
+
});
|
|
152
|
+
expect(c.status).toBe("fail");
|
|
153
|
+
expect(c.message).toContain("missing file");
|
|
154
|
+
});
|
|
155
|
+
});
|