Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
74 changes: 74 additions & 0 deletions 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 Down Expand Up @@ -1307,6 +1308,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
88 changes: 84 additions & 4 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ import {
sessionCanonicalUiState,
sessionStatusDisplay,
} from "../../desktop/src/renderer/lib/terminalAttention";
import { deriveGithubAccountAuthState } from "../../desktop/src/renderer/lib/githubIntegrationStatus";
import type { GitHubAppUserAuthStatus } from "../../desktop/src/shared/types";
import {
ADE_USAGE_RANGE_PRESETS,
ADE_USAGE_SCOPES,
Expand Down Expand Up @@ -324,6 +326,7 @@ type FormatterId =
| "history-show"
| "actions-list"
| "action-result"
| "github-app-auth"
| "automation-run-detail"
| "automation-ingress"
| "automation-linear-ingress"
Expand Down Expand Up @@ -1366,7 +1369,7 @@ const HELP_BY_COMMAND: Record<string, string> = {
store. The token itself is never printed.

$ ade --role cto github app-auth login Start device flow and wait for approval
$ ade github app-auth status --text Show whether a token is stored (login, expiry)
$ ade github app-auth status --text Show the credential state, login, and expiry
$ ade --role cto github app-auth clear Remove the stored authorization
$ ade github actions --text List raw github service actions
$ ade actions run github.getStatus --input-json '{"forceRefresh":true}' --text
Expand All @@ -1381,6 +1384,11 @@ const HELP_BY_COMMAND: Record<string, string> = {
GitHub CLI, then a stored PAT. Writes skip the read-only GitHub App.
Authentication failures and rate limits can fall through to the next
healthy credential while the failed source is in cooldown.
- Read "state" from app-auth status, not "access token expires". An access
token lives 8 hours and renews on use, so a lapsed expiry with state
"authorized" is healthy. State "blocked" means ADE paused its own retries
until "retry after" — wait, do not re-authorize. Only "needs_reauth" and
"missing" call for login.

Flags (login):
--max-wait <seconds> Give up waiting after N seconds (default: GitHub's
Expand Down Expand Up @@ -12852,6 +12860,7 @@ function buildGithubPlan(args: string[]): CliPlan {
return {
kind: "execute",
label: "github app-auth status",
formatter: "github-app-auth",
steps: [actionStep("result", "github", "getAppUserAuthStatus")],
};
}
Expand All @@ -12866,6 +12875,7 @@ function buildGithubPlan(args: string[]): CliPlan {
return {
kind: "execute",
label: "github app-auth clear",
formatter: "github-app-auth",
steps: [actionStep("result", "github", "clearAppUserAuth")],
};
}
Expand Down Expand Up @@ -20958,6 +20968,68 @@ export function formatBrainStatus(value: unknown): string {
]);
}

/**
* `ade github app-auth status | clear | login` in --text mode.
*
* An agent reads this to answer one question: re-authorize now, or wait? Only
* `credentialState` answers it. A stored token whose 8-hour access token has
* lapsed is still `authorized` — it renews on use — so `expiresAt` alone reads
* as broken when nothing is. `blocked` means ADE has paused its own retries
* until `refreshBlockedUntil` and re-authorizing cannot help; `needs_reauth` is
* the only state that asks for a login. The generic record renderer prints
* `lastRefreshError` as JSON truncated at 96 columns, which cuts the reason in
* half, so the reason gets its own block here.
*/
export function formatGithubAppUserAuth(value: unknown): string {
if (!isRecord(value)) return "The GitHub App authorization status is not available.";
const credentialState = asString(value.credentialState);
const userLogin = asString(value.userLogin);
const refreshBlockedUntil = asString(value.refreshBlockedUntil);
const lastRefreshError = isRecord(value.lastRefreshError) ? value.lastRefreshError : null;
// One module answers "how is this credential judged" for every surface. It
// also carries the legacy fallback: an older host sends no credentialState,
// and the refresh token — never the 8-hour access token — decides the truth.
const accountState = deriveGithubAccountAuthState(value as unknown as GitHubAppUserAuthStatus);
const headline = ((): string => {
if (value.configured !== true) {
return "The ADE GitHub App is not configured on this machine.";
}
if (accountState === "valid") {
return `Authorized${userLogin ? ` as ${userLogin}` : ""}. ADE renews this credential on its own.`;
}
if (accountState === "blocked") {
return "Authorized, but renewal is paused after a transient failure. ADE retries on its own — do not re-authorize.";
}
if (accountState === "needs_reauth") {
return "Re-authorization is needed. Run `ade --role cto github app-auth login`.";
}
return "Not authorized. Run `ade --role cto github app-auth login`.";
})();
const rows: Array<[string, unknown]> = [
["state", credentialState],
["account", userLogin],
["token", value.tokenStored === true ? "stored" : "not stored"],
["access token expires", value.expiresAt],
["refresh token expires", value.refreshTokenExpiresAt],
["retry after", refreshBlockedUntil],
["checked", value.checkedAt],
["error", value.error],
];
const sections = [headline, "", renderKeyValues("GitHub App authorization", rows)];
if (lastRefreshError) {
const kind = asString(lastRefreshError.kind) ?? "unknown";
const status = typeof lastRefreshError.status === "number" ? ` (HTTP ${lastRefreshError.status})` : "";
const at = asString(lastRefreshError.at);
sections.push(
"",
"Last renewal failure",
` ${kind}${status}${at ? ` at ${at}` : ""}`,
` ${asString(lastRefreshError.message) ?? "No detail was reported."}`,
);
}
return sections.join("\n");
}

function formatTextOutput(
value: unknown,
formatter: FormatterId | undefined,
Expand Down Expand Up @@ -21280,6 +21352,8 @@ function formatTextOutput(
return formatStorageMaintenance(value);
case "update-status":
return formatUpdateStatus(value);
case "github-app-auth":
return formatGithubAppUserAuth(value);
case "action-result":
default:
if (isRecord(value))
Expand Down Expand Up @@ -22032,6 +22106,7 @@ async function runGithubAppLogin(
output: formatOutput(
{ ...status, status: "expired", error: "timed_out" },
options,
"github-app-auth",
),
exitCode: 1,
};
Expand All @@ -22044,10 +22119,15 @@ async function runGithubAppLogin(
);
const poll = await runGithubAction("pollAppUserDeviceAuth", { sessionId });
const status = asString(poll.status);
const authStatus = isRecord(poll.authStatus) ? poll.authStatus : poll;
// The typed printer reads an auth status. When the host returned none,
// `authStatus` is the poll envelope instead, and printing that as an auth
// status would report "not configured" for a machine that is configured.
const polledAuthStatus = isRecord(poll.authStatus) ? poll.authStatus : null;
const authStatus = polledAuthStatus ?? poll;
const authFormatter: FormatterId | undefined = polledAuthStatus ? "github-app-auth" : undefined;
if (status === "authorized") {
process.stderr.write("GitHub App authorized.\n");
return { output: formatOutput(authStatus, options), exitCode: 0 };
return { output: formatOutput(authStatus, options, authFormatter), exitCode: 0 };
}
if (status === "pending" || status === "slow_down") {
if (typeof poll.intervalSec === "number" && poll.intervalSec > 0) {
Expand All @@ -22060,7 +22140,7 @@ async function runGithubAppLogin(
asString(poll.message) ??
`GitHub device authorization ${status ?? "failed"}.`;
process.stderr.write(`${message}\n`);
return { output: formatOutput(authStatus, options), exitCode: 1 };
return { output: formatOutput(authStatus, options, authFormatter), exitCode: 1 };
}
} finally {
await connection.close();
Expand Down
Loading
Loading