Skip to content
Merged
1 change: 1 addition & 0 deletions apps/account-directory/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"compatibility_date": "2026-06-30",
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true, "head_sampling_rate": 1 },
"triggers": { "crons": ["* * * * *"] },
"vars": {
"ONLINE_WINDOW_MS": "90000",
Expand Down
29 changes: 16 additions & 13 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ ade machines list --text
ade machines connect <machine-key> --project ADE
ade machines hop <device-id> --session chat-1
ade doctor --json
ade doctor --online --text # also check the latest desktop release over the network
ade projects list --text
ade projects inspect /path/to/checkout --json # classify a path (repo root vs linked/ADE-managed worktree) and find its owning project + existing lane
ade init
Expand Down Expand Up @@ -516,19 +517,21 @@ Provider credentials, GitHub tokens, Linear tokens, and computer-use policy
remain separate and are read from ADE project settings and their existing
secure stores.

`ade doctor` reports local-only readiness metadata by default:

- CLI version, Node/runtime version, project root, workspace root, `.ade` initialization, and config file presence.
- Machine endpoint path, whether the endpoint exists, and whether this invocation is using an attached runtime, desktop bridge, or headless mode.
- RPC tool count, ADE action count, and action counts by domain.
- Git repository readiness and GitHub readiness signals from local remotes, `gh` availability, and token environment presence.
- Linear readiness from the active project's `.ade/secrets` credential store (`linear.token.v1`), a legacy project-scoped encrypted token file, or headless environment variables.
- Provider/model readiness from local ADE config, API-key provider references, and provider CLI availability.
- Computer-use readiness from local platform capabilities.
- Sync and Relay readiness, including a relay end-to-end self-probe verdict (`relay self-probe`). When a local ADE brain is running the probe performs one round-trip through ADE Relay and reports `ready`, a `FAILED:` verdict, or a graceful skip; without a running brain (or without a validated relay bridge) it reports skipped/unavailable and never fails the run. `ade sync status` surfaces the same verdict on its `relay end-to-end` line.
- Packaged/PATH status for the `ade` binary and concrete next actions.

Default doctor / auth checks do not call provider, GitHub, or Linear networks. They report presence and local readiness only, without printing secret values. The one network touch is the optional relay end-to-end self-probe above, which runs only when a local brain and validated relay bridge are present.
`ade doctor` inspects the installed app and machine-brain health and prints one
status row (`ok` / `warn` / `fail`) per check. It exits non-zero when any row is
`fail`. The rows are:

- **App** — the installed ADE desktop version (read from the `.app` bundle on disk) against the latest known version. Latest-known comes from the on-disk `update-status.json` by default; pass `--online` to also fetch the latest release from GitHub (short timeout, best-effort). `warn` when the install is behind or missing.
- **Brain** — whether the machine brain responds on its socket, plus its version, pid, and uptime. `fail` when it is not responding or when its build identity does not match the expected runtime for this CLI/role.
- **Wedge history** — the last brain-loop watchdog wedge that was recovered (blocking command and how long it blocked), read from the runtime dir or the brain's reported `lastWedge`. `warn` when the most recent wedge is within the last 24h.
- **Sync port** — the sync host port the brain bound. `ok` on the default port, `warn` when bound elsewhere (with the base-port holders it found), `fail` when the brain is up but reported no port.
- **Publish health** — account-directory publish state from the brain's sync route health. `ok` when a publish succeeded recently, `fail` when it has been failing for ≥2 min, otherwise `warn`, with the slowest publish leg annotated.
- **Relay** — relay route health as already computed by the brain. `ok` when the relay control is connected, the bridge is validated, and the end-to-end round-trip is verified; `fail` when the route is not fully validated; `warn` when relay is disabled or route health is unavailable.
- **Account** — whether this machine's brain is signed in to an ADE account (and the credential source), read via the brain's `account.call status`. `warn` when signed out or unavailable.

Default doctor does not call provider, GitHub, or Linear networks — it talks only
to the local brain over its socket, and never prints secret values. The one
optional network touch is the `--online` desktop-release lookup above.

Agents starting an unfamiliar ADE session should begin with:

Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { resolveLaneCreateRemoteBase } from "./services/laneCreateRemoteBase";
import { BUILT_IN_BROWSER_ACTOR_CAPABILITY_PARAM } from "./services/builtInBrowser/desktopBridgeMethods";
import { resolveCodexComputerUseMcpConfig } from "../../desktop/src/main/utils/codexComputerUse";
import { parseTrackedCliLaunchConfig } from "../../desktop/src/main/utils/terminalSessionSignals";
import { RUNTIME_COMPAT_LEVEL } from "../../desktop/src/shared/adeRuntimeProtocol";

// Cross-surface (desktop + TUI + iOS) model picker favorites & recents.
// Backed by the per-project cr-sqlite CRR DB (runtime.db) so the three surfaces
Expand Down Expand Up @@ -5177,6 +5178,8 @@ export function createAdeRpcRequestHandler(args: {
runtimeInfo: {
name: "ade-rpc",
version: serverVersion,
minCompatibleProtocol: RUNTIME_COMPAT_LEVEL,
protocolVersion: RUNTIME_COMPAT_LEVEL,
buildHash:
typeof process.env.ADE_RUNTIME_BUILD_HASH === "string" &&
process.env.ADE_RUNTIME_BUILD_HASH.trim()
Expand Down
1 change: 1 addition & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,7 @@ export async function createAdeRuntime(args: {
productAnalyticsService,
logger,
getAccountDirectoryHealth: resolvedArgs.syncRuntime.getAccountDirectoryHealth,
requestAccountMachinePublish: resolvedArgs.syncRuntime.requestAccountMachinePublish,
accountAuthService,
projectId: resolvedArgs.syncRuntime.registryProjectId ?? projectId,
runtimeProjectId: projectId,
Expand Down
151 changes: 32 additions & 119 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import { createServer } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -848,6 +849,7 @@ describe("ADE CLI", () => {
packageChannel: null,
projectRoot: null,
pid: 123,
uptimeMs: null,
};

expect(
Expand Down Expand Up @@ -4992,130 +4994,41 @@ describe("ADE CLI", () => {
expect(output).toContain("Git repository detected");
});

it("adds sync route health to doctor and names a loopback listener mismatch", () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-doctor-sync-"));
fs.mkdirSync(path.join(projectRoot, ".ade"), { recursive: true });
try {
const plan = expectExecutePlan(buildCliPlan(["doctor"]));
expect(plan.steps).toContainEqual({
key: "syncStatus",
method: "sync.getStatus",
params: { includeTransferReadiness: false },
optional: true,
});
expect(plan.steps).toContainEqual({
key: "relaySelfProbe",
method: "sync.runSelfProbe",
optional: true,
});
const summary = summarizeExecution({
plan,
connection: {
mode: "runtime-socket",
projectRoot,
workspaceRoot: projectRoot,
socketPath: path.join(projectRoot, ".ade", "ade.sock"),
},
values: {
rpcActions: { actions: [{}] },
actions: { actions: [{}] },
syncStatus: {
pairingConnectInfo: { port: 8787 },
routeHealth: {
listener: {
listenerBound: true,
loopbackAdeValidated: false,
reason: "Expected ADE 426 Upgrade Required; received 404 Not Found.",
},
tailscale: {
enabled: true,
tailscaleReachable: false,
reason: "Tailscale route points at the listener mismatch.",
},
relay: {
enabled: true,
relayControlConnected: false,
relayBridgeValidated: false,
skipReason: "Relay control upgrade failed with HTTP 401: token expired",
lastControlError: "Relay control closed (1006).",
},
},
},
relaySelfProbe: {
ok: false,
detail: "Relay self-probe closed before ready (4501): host offline.",
},
},
} as any) as Record<string, any>;

expect(summary.sync).toMatchObject({
enabled: true,
usable: false,
status: "warning",
});
expect(summary.sync.failingRoutes).toEqual([
expect.stringContaining("listener"),
expect.stringContaining("tailscale"),
"relay: Relay control upgrade failed with HTTP 401: token expired",
]);
expect(summary.sync.message).toContain("404 Not Found");
expect(summary.sync.message).toContain("HTTP 401: token expired");
expect(summary.relaySelfProbe).toMatchObject({
ready: false,
status: "warning",
message: expect.stringContaining("FAILED"),
});
const output = formatOutput(summary, {
projectRoot,
workspaceRoot: projectRoot,
role: "agent",
headless: false,
requireSocket: false,
socketPath: null,
pretty: true,
text: true,
timeoutMs: 1000,
}, "doctor");
expect(output).toContain("Sync route failure");
expect(output).toContain("listener");
expect(output).toContain("relay self-probe");
expect(output).toContain("host offline");
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
it("builds doctor as a read-only local command and keeps online checks explicit", () => {
expect(buildCliPlan(["doctor"])).toEqual({ kind: "doctor", online: false });
expect(buildCliPlan(["doctor", "--online"])).toEqual({ kind: "doctor", online: true });
});

it("marks the doctor Relay self-probe skipped when no brain is running", () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-doctor-no-brain-"));
fs.mkdirSync(path.join(projectRoot, ".ade"), { recursive: true });
it("bounds doctor when a dead socket accepts a connection but never responds", async () => {
if (process.platform === "win32") return;
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-doctor-dead-sock-"));
const socketPath = path.join(root, "ade.sock");
const acceptedSockets = new Set<net.Socket>();
const server = net.createServer((socket) => {
acceptedSockets.add(socket);
socket.once("close", () => acceptedSockets.delete(socket));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(socketPath, resolve);
});
const startedAt = Date.now();
try {
const summary = summarizeExecution({
plan: expectExecutePlan(buildCliPlan(["doctor"])),
connection: {
mode: "headless",
projectRoot,
workspaceRoot: projectRoot,
socketPath: path.join(projectRoot, ".ade", "ade.sock"),
},
values: {
rpcActions: { actions: [{}] },
actions: { actions: [{}] },
relaySelfProbe: {
ok: false,
detail: "Relay self-probe skipped because the control socket is not connected.",
},
},
} as any) as Record<string, any>;

expect(summary.relaySelfProbe).toEqual(expect.objectContaining({
ready: true,
status: "unavailable",
message: "Skipped — no running ADE brain.",
}));
const result = await runCli(["--socket", socketPath, "doctor", "--json"]);
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.output)).toMatchObject({
ok: false,
rows: expect.arrayContaining([
expect.objectContaining({ key: "brain", status: "fail" }),
]),
});
expect(Date.now() - startedAt).toBeLessThan(2_750);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
for (const socket of acceptedSockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
fs.rmSync(root, { recursive: true, force: true });
}
});
}, 5_000);

it("detects project-local Linear credentials in doctor readiness", () => {
const previousAdeLinearApi = process.env.ADE_LINEAR_API;
Expand Down
Loading
Loading