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
9 changes: 8 additions & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ ade --role cto actions run ai.piLoginCancel --input-json '{"providerId":"anthrop
ade cursor cloud agents list --text
ade cursor cloud agents create --repo https://github.com/owner/repo --prompt "fix flaky test" --auto-pr
ade --role cto github app-auth login # device-flow authorize the machine ADE GitHub App (headless/brain)
ade github app-auth status --text # show whether a GitHub App user token is stored (login, expiry)
ade github app-auth status --text # show the GitHub App credential state, login, expiry, and any renewal failure
ade --role cto github app-auth clear # remove the stored GitHub App authorization
ade actions run github.getStatus --input-json '{"forceRefresh":true}' --text # show active read/write sources and cooldowns
ade open ade://lane/<lane-uuid>
Expand All @@ -663,6 +663,13 @@ ade skill list --text
ade skill show ade-browser --text
```

`github app-auth status` answers "re-authorize, or wait?" from `credentialState`
alone — never from `expiresAt`. An access token lives 8 hours and renews on use,
so a lapsed `expiresAt` with `credentialState: "authorized"` is healthy.
`"blocked"` means ADE paused its own refresh retries until `refreshBlockedUntil`
after a transient failure (`lastRefreshError` carries the reason): wait, do not
re-authorize. Only `"needs_reauth"` and `"missing"` call for `app-auth login`.

GitHub reads try credentials in environment → ADE GitHub App → GitHub CLI →
stored PAT order. Writes skip the read-only GitHub App. `github.getStatus`
reports the active read/write sources, per-credential failure/cooldown state,
Expand Down
27 changes: 27 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ import {
import { createLaneWorktreeLockService, type LaneWorktreeLockService } from "../../desktop/src/main/services/lanes/laneWorktreeLockService";
import { createHeadlessLinearServices } from "./headlessLinearServices";
import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore";
import { watchCredentialsForRelayRepair } from "./services/credentials/credentialChangeRelayRepair";
import {
getSignedInAccountAccessToken,
type AccountAuthService,
Expand Down Expand Up @@ -846,6 +847,9 @@ export async function createAdeRuntime(args: {
processRegistry.start();
let runtimeCreated = false;
let staleSessionReconcileTimer: ReturnType<typeof setTimeout> | null = null;
// Declared out here so the failure path below can release it: the watcher is
// installed long before `runtime` exists, and only `runtime.dispose` stops it.
let stopCredentialWatch: (() => void) | null = null;
try {
const reconcileStaleRunningSessions = (reason: "startup" | "fresh-activity-grace-expired") => {
const reconciledSessions = sessionService.reconcileStaleRunningSessions({
Expand Down Expand Up @@ -1590,6 +1594,20 @@ export async function createAdeRuntime(args: {
error: error instanceof Error ? error.message : String(error),
});
});
// A repaired or removed GitHub App credential ends the relay's auth-pending
// cooldown at once, the way the desktop app's `onAppUserAuthChanged` does.
// The brain has no such callback — the credential is written by whichever
// process ran the device flow — so it watches the shared machine file
// instead. Best-effort: a store with no watcher leaves the behaviour as it
// was, and the cooldown expires on its own after five minutes.
//
// Installed AFTER `start()`, which marks the service started synchronously: a
// credential change during startup would otherwise poll the relay through a
// service that has not started, and the poll `start()` runs supersedes it.
stopCredentialWatch = watchCredentialsForRelayRepair({
logger,
pollNow: () => automationIngressService.pollNow(),
});

// Brain → Cloudflare push relay publisher. Owns push registration (from the
// paired phone via `push.*` sync commands) and fans agent/PR state transitions
Expand Down Expand Up @@ -2047,6 +2065,7 @@ export async function createAdeRuntime(args: {
// lease subscription, or a disposed scope could later stop the shared
// tunnel on a lease transition it no longer has any business observing.
swallow(() => relayTunnelGate.dispose());
swallow(() => stopCredentialWatch?.());
swallow(() => automationIngressService?.dispose());
swallow(() => linearIngressService?.stop());
swallow(() => cursorCloudIngressService.stop());
Expand Down Expand Up @@ -2101,6 +2120,14 @@ export async function createAdeRuntime(args: {
if (staleSessionReconcileTimer) {
clearTimeout(staleSessionReconcileTimer);
}
try {
// Only `runtime.dispose` stops this watcher, and there is no runtime.
// Left running it polls the credential file for the life of the
// process and pins the ingress service through its `pollNow` closure.
stopCredentialWatch?.();
} catch {
// Preserve the original startup failure.
}
try {
processRegistry.stop();
} catch {
Expand Down
132 changes: 131 additions & 1 deletion apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
startHeadlessRpcTcpServer,
shouldAutoRegisterProjectForPlan,
formatBrainStatus,
formatGithubAppUserAuth,
shouldBlockManualMachineRuntimeSpawn,
shouldProbeBrainStartupState,
shouldEnforceMachineRuntimeBuildCompatibility,
Expand All @@ -58,6 +59,7 @@ import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
import { generateRpcAuthToken } from "./rpcAuth";
import { JsonRpcClient } from "./tuiClient/jsonRpcClient";
import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore";
import { readBrainHeartbeat } from "./services/runtime/brainHeartbeat";
import { localIpcListenOptions } from "./services/runtime/localIpcListenOptions";

type ResolveRootsOptions = Parameters<typeof resolveRoots>[0];
Expand All @@ -67,6 +69,53 @@ process.env.ADE_ENABLE_MACOS_VM = "1";

const crdtHostIt = process.platform === "darwin" ? it : it.skip;

/**
* Waits for a DETACHED test brain to leave, and kills it if it will not.
*
* A brain owns its ADE_HOME for as long as it runs: it writes the credential
* store, that store's lock file, and the relay configuration under
* `secrets/`, plus its heartbeat under `runtime/`. `ade runtime stop` returns
* when the shutdown request is answered, not when the process is gone, and it
* reports rather than throws when it never reached a brain at all. A teardown
* that removes ADE_HOME on that signal alone races those writes: one file
* created between `rmSync`'s readdir and its rmdir fails the whole teardown
* with ENOTEMPTY.
*/
async function waitForDetachedProcessExit(
pid: number | null,
timeoutMs = 10_000,
): Promise<void> {
if (pid === null || !Number.isInteger(pid) || pid <= 0) return;
// Same rule as the brain's "self" heartbeat verdict: never signal your own
// process. A stale heartbeat naming this pid would SIGKILL the test runner.
if (pid === process.pid) return;
const isRunning = (): boolean => {
try {
process.kill(pid, 0);
return true;
} catch (error: unknown) {
// EPERM means the process exists and belongs to somebody else, which is
// still "running" for this purpose. Only ESRCH means it is gone.
return (error as NodeJS.ErrnoException | null)?.code !== "ESRCH";
}
};
const waitUntilGone = async (deadline: number): Promise<boolean> => {
while (Date.now() < deadline) {
if (!isRunning()) return true;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return !isRunning();
};
if (await waitUntilGone(Date.now() + timeoutMs)) return;
try {
process.kill(pid, "SIGKILL");
} catch {
// Already gone, or not ours to signal.
}
// SIGKILL is not synchronous either: the process still has to be reaped.
await waitUntilGone(Date.now() + 2_000);
}

function withEnv<T>(updates: Record<string, string | undefined>, run: () => T): T {
const previous = new Map<string, string | undefined>();
for (const key of Object.keys(updates)) {
Expand Down Expand Up @@ -1307,6 +1356,79 @@ describe("ADE CLI", () => {
.not.toContain("nothing to repair");
});

it("reports the GitHub App credential state, not the access-token expiry", () => {
// A lapsed 8-hour access token behind a live refresh token is healthy. An
// agent that reads `expiresAt` re-authorizes a working credential, so the
// printed verdict has to come from `credentialState`.
const authorized = formatGithubAppUserAuth({
configured: true,
tokenStored: true,
userLogin: "octocat",
expiresAt: "2026-08-20T01:00:00.000Z",
refreshTokenExpiresAt: "2026-11-18T01:00:00.000Z",
credentialState: "authorized",
refreshBlockedUntil: null,
lastRefreshError: null,
checkedAt: "2026-08-20T12:00:00.000Z",
error: null,
});
expect(authorized).toContain("Authorized as octocat");
expect(authorized).not.toContain("app-auth login");

// "blocked" must never read as a request to re-authorize, and the reason
// must survive whole — the generic record renderer truncates it as JSON.
const blocked = formatGithubAppUserAuth({
configured: true,
tokenStored: true,
userLogin: "octocat",
expiresAt: null,
refreshTokenExpiresAt: "2026-11-18T01:00:00.000Z",
credentialState: "blocked",
refreshBlockedUntil: "2026-08-20T12:05:00.000Z",
lastRefreshError: {
kind: "rate_limited",
message: "GitHub is rate-limiting ADE's sign-in requests right now. Try again in a few minutes.",
status: 429,
at: "2026-08-20T12:00:00.000Z",
},
checkedAt: "2026-08-20T12:00:00.000Z",
error: null,
});
expect(blocked).toContain("do not re-authorize");
expect(blocked).toContain("2026-08-20T12:05:00.000Z");
expect(blocked).toContain("rate_limited (HTTP 429)");
expect(blocked).toContain("Try again in a few minutes.");

// Only a dead refresh token may ask for a login.
expect(formatGithubAppUserAuth({
configured: true,
tokenStored: true,
userLogin: "octocat",
expiresAt: null,
refreshTokenExpiresAt: null,
credentialState: "needs_reauth",
refreshBlockedUntil: null,
lastRefreshError: null,
checkedAt: "2026-08-20T12:00:00.000Z",
error: null,
})).toContain("ade --role cto github app-auth login");

// An older host sends no credentialState at all. The shared derivation
// judges by the refresh token, so a lapsed 8-hour access token next to a
// live refresh token still reads as authorized — never as "log in again".
expect(formatGithubAppUserAuth({
configured: true,
tokenStored: true,
userLogin: "octocat",
expiresAt: "2026-08-20T04:00:00.000Z",
refreshTokenExpiresAt: "2099-01-01T00:00:00.000Z",
refreshBlockedUntil: null,
lastRefreshError: null,
checkedAt: "2026-08-20T12:00:00.000Z",
error: null,
})).toContain("ADE renews this credential on its own");
});

it("skips the brain-starting probe inside supervisor and handover probe children", () => {
// Those children run `ade runtime status` with the install lock set. On
// Windows the probe would ask the service manager, which spawns another
Expand Down Expand Up @@ -5517,11 +5639,17 @@ describe("ADE CLI", () => {
expect(codeRequests).toBe(1);
expect(tokenRequests).toBe(1);
} finally {
// Read the pid BEFORE the stop: a clean shutdown removes the heartbeat
// file on its way out.
const brainPid = readBrainHeartbeat(path.join(adeHome, "runtime"))?.pid ?? null;
try {
await runCli(["--socket", socketPath, "runtime", "stop", "--text"]);
} catch {
// Best-effort cleanup if the detached test runtime never became available.
}
// The brain writes into `adeHome` for as long as it lives. Removing the
// directory under a live one is what fails this teardown with ENOTEMPTY.
await waitForDetachedProcessExit(brainPid);
stderrWrite.mockRestore();
process.argv[1] = previousArgvEntry;
for (const [key, value] of previousEnv) {
Expand All @@ -5531,7 +5659,9 @@ describe("ADE CLI", () => {
await new Promise<void>((resolve) => directory.close(() => resolve()));
fs.rmSync(adeHome, { recursive: true, force: true });
}
}, 30_000);
// The teardown above waits for a detached brain to leave, which costs up to
// 12 seconds on its own before the login flow's own budget is counted.
}, 45_000);

posixIt("accepts current-session deadline success but rejects a stale signed-in account", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-account-deadline-sock-"));
Expand Down
Loading
Loading