Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@
# Synthetic encrypted OpenSSH parser fixture. The current test constructs the
# boundary at runtime; keep this exception scoped to its original PR commit.
73f8acc461fd37996a95e4765007f2c794df13ee:apps/ios/ADETests/SSHBootstrapTests.swift:private-key:25

# ade-adopt-v1 cross-platform crypto test vector. This is the HKDF-SHA256 output
# derived from the public RFC 7748 X25519 test keys (documented in-comment); it
# is not a real secret. The same value is asserted in code (line 123) and in the
# iOS mirror to lock TS<->Swift byte-compatibility. Scope to its original commit.
4809aba24a34362fd2014774abf67c85b1251cd0:apps/desktop/src/shared/sync/adoptChannelCrypto.test.ts:generic-api-key:83
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Machine connections

- Detected "zombie" Relay control sockets with a relay-answered JSON keepalive, reconnecting within minutes when Cloudflare silently loses the host's registration (previously the host could look healthy for hours while every incoming connection was refused).
- Verified the full Relay path end-to-end by self-dialing this machine's own connect route after every control open and bridge validation, and published the Relay endpoint to the account directory only after that probe passes; `ade sync status` and `ade doctor` now report the honest end-to-end verdict.
- Introduced the sealed `ade-adopt-v1` adoption handshake: machines publish an ed25519 identity key in their directory row, sign the adopting client's challenge over an ephemeral X25519 exchange, and exchange account credentials ChaCha20-Poly1305-sealed — enabling account adoption to fall back from Relay to Tailscale and LAN routes on desktop, `ade code remote`, and iOS.
- Surfaced account-machine connect failures in the desktop Connections panel and iOS (previously silent), with live route stage text, a connected-via route/latency note, and a one-tap jump into Nearby + PIN pairing when the machine is discoverable locally.

## [1.2.35] - 2026-07-22

### Relay recovery
Expand Down
5 changes: 5 additions & 0 deletions apps/account-directory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ Device codes and approval-attempt rate limits are stored in D1. The daemon
secret is stored only as a SHA-256 digest; approved token pairs are cleared by
the one-time redemption update or when the device code expires.

Machine registration and list records may carry a `pubkey` string. Current ADE
hosts publish `ed25519:<raw-32-byte-base64>` so clients can verify and seal
account adoption on direct or relay routes. The Worker treats the value as
opaque metadata and rejects values longer than 128 characters.

## Local checks

```sh
Expand Down
2 changes: 2 additions & 0 deletions apps/account-directory/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type AccountRoute =
| { kind: "delete"; machineKey: string };

export const DEFAULT_ONLINE_WINDOW_MS = 90_000;
const MAX_PUBKEY_CHARS = 128;
const remoteJwksByUrl = new Map<string, ReturnType<typeof createRemoteJWKSet>>();

type CallerTokenFailureReason =
Expand Down Expand Up @@ -181,6 +182,7 @@ function parseRegisterInput(value: unknown): RegisterInput | null {
|| !platform
|| !deviceType
|| pubkey === undefined
|| (pubkey !== null && pubkey.length > MAX_PUBKEY_CHARS)
|| !reachableEndpoints
|| typeof retainRelayEndpoints !== "boolean"
) {
Expand Down
31 changes: 31 additions & 0 deletions apps/account-directory/test/directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,37 @@ describe("machine directory", () => {
expect(await response.json()).toEqual({ error: "invalid request body" });
});

it("stores and lists a bounded Ed25519 machine public key", async () => {
const env = makeEnv();
const token = await mintToken();
const pubkey = `ed25519:${Buffer.alloc(32, 4).toString("base64")}`;
const registered = await handleRequest(request(
"POST",
"/account/machines/register",
token,
{ ...registerBody("machine-keyed"), pubkey },
), env);
expect(registered.status).toBe(200);
expect(await registered.json()).toEqual(expect.objectContaining({ pubkey }));

const listed = await handleRequest(
request("GET", "/account/machines", token),
env,
);
expect(await listed.json()).toMatchObject({
machines: [expect.objectContaining({ pubkey })],
});

const oversized = await handleRequest(request(
"POST",
"/account/machines/register",
token,
{ ...registerBody("machine-oversized-key"), pubkey: "x".repeat(129) },
), env);
expect(oversized.status).toBe(400);
expect(await oversized.json()).toEqual({ error: "invalid request body" });
});

it("returns online and offline machines, online first and newest first", async () => {
const env = makeEnv();
const token = await mintToken({ sub: "user_1" });
Expand Down
10 changes: 8 additions & 2 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Default routing for typed commands: prefer the machine brain endpoint if reachab
| `\\.\pipe\ade-runtime` | ADE runtime named-pipe endpoint (Windows). |
| `$ADE_HOME/projects.json` | Project catalog. |
| `$ADE_HOME/personal-chats/` | Machine-owned projectless chat runtime state, hidden workspace, transcripts, and attachments. |
| `~/.ade/secrets/` | Machine credential store (`credentials.safe.enc` for desktop safeStorage, `credentials.json.enc` plus `.machine-key` for headless fallback storage, and per-store `*.lock` files). |
| `~/.ade/secrets/` | Machine credential store (`credentials.safe.enc` for desktop safeStorage, `credentials.json.enc` plus `.machine-key` for headless fallback storage, per-store `*.lock` files, and the Ed25519 `machine-identity-signing.json` used by account adoption). |
| `~/.ade/bin/ade` | Bundled static runtime binary (release installs / remote uploads). |
| `~/.ade/agent-skills/` | Bundled, version-locked ADE agent skills. Desktop remote bootstrap uploads this beside the remote runtime; CLI launch then re-seeds ADE-managed skills into runtime-native home skill directories. |
| `~/.ade/runtime/<platform-arch>/` | Native node modules for that runtime binary. |
Expand Down Expand Up @@ -234,6 +234,7 @@ The `sync.connectToBrain`, `sync.disconnectFromBrain`, and `sync.transferBrainTo
- Desktop uses `ElectronSafeStorageCredentialStore`, which encrypts `credentials.safe.enc` with Electron `safeStorage` and migrates legacy file-encrypted stores on first read.
- Headless CLI fallback uses `EncryptedFileCredentialStore`, which keeps `credentials.json.enc` encrypted with AES-256-GCM and serializes read-modify-write access with `credentials.json.enc.lock`.
- Secret directories are created with mode `0700`; credential blobs, lock files, and legacy machine keys are written with mode `0600`.
- Account-machine adoption uses a long-lived Ed25519 identity in `machine-identity-signing.json`. The file is created atomically with mode `0600`; only its raw 32-byte public key is published to the account directory.

`ade login`, `ade logout`, and `ade auth status` operate on the daemon-owned ADE
account session in that store. Installed ADE uses the production Clerk
Expand All @@ -247,6 +248,10 @@ unambiguous; otherwise the command prints the matching stable machine keys.
Directory presence is a short-lived hint: a machine with a directory-verified
relay endpoint remains connectable after its most recent heartbeat expires.
Machines without a verified route remain listed but unavailable.
When a directory row contains `pubkey: "ed25519:<raw-base64>"`, account
adoption verifies the host signature and seals the bearer, DPoP proof, and
returned paired secret over verified LAN, tailnet, or relay routes. Rows
without a published key retain the legacy WSS-relay-only account path.
Targets and paired credentials created through `ade machines connect` belong to
that account and are removed on sign-out or account switch. Direct PIN, SSH, and
explicit-address pairings remain local and are not converted into account-owned
Expand Down Expand Up @@ -507,9 +512,10 @@ secure stores.
- 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.
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.

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 @@ -5203,6 +5203,9 @@ export function createAdeRpcRequestHandler(args: {
forceTransferReadiness: params.forceTransferReadiness === true,
});
}
if (method === "sync.runSelfProbe") {
return await syncService.runSelfProbe();
}
if (method === "sync.refreshDiscovery") {
return await syncService.refreshDiscovery();
}
Expand Down
65 changes: 64 additions & 1 deletion apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,9 @@ describe("ADE CLI", () => {
lastControlError: "Relay control closed (1011): upstream restart",
lastControlOpenAt: "2026-07-16T11:58:00.000Z",
lastBridgeValidationAt: "2026-07-16T11:57:30.000Z",
relayEndToEndVerifiedAt: new Date(Date.now() - 60_000).toISOString(),
relayEndToEndFailure: null,
relayEndToEndRoundTripMs: 37,
},
accountDirectory: {
state: "http_error",
Expand Down Expand Up @@ -1171,6 +1174,7 @@ describe("ADE CLI", () => {
expect(output).toContain("Relay control closed (1011): upstream restart");
expect(output).toContain("2026-07-16T11:58:00.000Z");
expect(output).toContain("2026-07-16T11:57:30.000Z");
expect(output).toMatch(/relay end-to-end\s+verified 1 minute ago \(37ms\)/);
expect(output).toContain("account directory");
expect(output).toContain("http_error · 2 reachable endpoints · HTTP 401");
expect(output).toContain("The account directory returned HTTP 401: invalid issuer");
Expand All @@ -1183,7 +1187,10 @@ describe("ADE CLI", () => {

it("formats the authoritative relay blocker without mistaking historical control errors for one", () => {
const plan = expectExecutePlan(buildCliPlan(["sync", "status"]));
const formatRelay = (skipReason: string | null) => formatOutput({
const formatRelay = (
skipReason: string | null,
relayEndToEndFailure: string | null = null,
) => formatOutput({
mode: "brain",
role: "brain",
runtimeRole: "host",
Expand All @@ -1198,6 +1205,9 @@ describe("ADE CLI", () => {
relayBridgeValidated: true,
skipReason,
lastControlError: "Relay control closed (1012): historical restart",
relayEndToEndVerifiedAt: relayEndToEndFailure ? null : new Date().toISOString(),
relayEndToEndFailure,
relayEndToEndRoundTripMs: relayEndToEndFailure ? null : 21,
},
accountDirectory: {
state: "published",
Expand All @@ -1213,6 +1223,11 @@ describe("ADE CLI", () => {
const recovered = formatRelay(null);
expect(recovered).toMatch(/relay\s+reachable/);
expect(recovered).toContain("Relay control closed (1012): historical restart");

const failed = formatRelay(null, "Relay self-probe closed before ready (4501): host offline.");
expect(failed).toMatch(
/relay end-to-end\s+FAILED: Relay self-probe closed before ready \(4501\): host offline\./,
);
});

it("formats sync web pairing info from sync status", () => {
Expand Down Expand Up @@ -4774,6 +4789,11 @@ describe("ADE CLI", () => {
params: { includeTransferReadiness: false },
optional: true,
});
expect(plan.steps).toContainEqual({
key: "relaySelfProbe",
method: "sync.runSelfProbe",
optional: true,
});
const summary = summarizeExecution({
plan,
connection: {
Expand Down Expand Up @@ -4807,6 +4827,10 @@ describe("ADE CLI", () => {
},
},
},
relaySelfProbe: {
ok: false,
detail: "Relay self-probe closed before ready (4501): host offline.",
},
},
} as any) as Record<string, any>;

Expand All @@ -4822,6 +4846,11 @@ describe("ADE CLI", () => {
]);
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,
Expand All @@ -4835,6 +4864,40 @@ describe("ADE CLI", () => {
}, "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("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 });
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.",
}));
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
Expand Down
Loading
Loading