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
3 changes: 3 additions & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,9 @@ ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts
ade actions run pty.resumeSession --arg sessionId=session-id
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 --role cto github app-auth clear # remove the stored GitHub App authorization
ade open ade://lane/<lane-uuid>
ade open --linear-issue ADE-123 --branch arul/ade-123-fix
ade link lane <lane-uuid>
Expand Down
213 changes: 212 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ type CliPlan =
| { kind: "init"; targetPath: string | null }
| { kind: "cursor-cloud"; rest: string[] }
| { kind: "deeplink"; rest: string[] }
| { kind: "skill"; rest: string[] };
| { kind: "skill"; rest: string[] }
| { kind: "github-app-login"; maxWaitSec: number | null };

type CliConnection = {
mode: "desktop-socket" | "runtime-socket" | "headless";
Expand Down Expand Up @@ -481,6 +482,7 @@ const TOP_LEVEL_HELP = `${ADE_BANNER}
$ ade agent spawn --lane <id> --prompt <text> Launch an agent session in ADE
$ ade cto state | chats Operate CTO state and Work chats
$ ade linear graphql | workflows | run | sync Operate Linear GraphQL, routing, and sync workflows
$ ade github app-auth login | status | clear Authorize the machine ADE GitHub App (device flow)
$ ade automations list | create | run | runs Manage automation rules
$ ade coordinator <tool> Call coordinator runtime tools
$ ade tests list | run | stop | runs | logs Run configured test suites
Expand Down Expand Up @@ -970,6 +972,30 @@ const HELP_BY_COMMAND: Record<string, string> = {
Flags:
--app-name <name> macOS app name to open. Defaults to ADE, ADE Beta,
or ADE Alpha based on the installed CLI wrapper.
`,
github: `${ADE_BANNER}
ADE GitHub

Authorize the machine-scoped ADE GitHub App so headless / brain setups (which
have no Settings panel) can use the hosted PR-sync webhook relay. Uses GitHub's
device flow: ADE prints a short user code and a verification URL, you approve
in a browser, and ADE stores the resulting user token in the machine credential
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 --role cto github app-auth clear Remove the stored authorization
$ ade github actions --text List raw github service actions

Notes:
- login, clear (and the raw start/poll actions) require --role cto.
- login keeps one connection open for the whole device flow because the
device-auth session lives in runtime memory; do not split start and poll
across separate invocations in headless mode.

Flags (login):
--max-wait <seconds> Give up waiting after N seconds (default: GitHub's
device-code expiry, ~15 min).
`,
open: `${ADE_BANNER}
ADE Open
Expand Down Expand Up @@ -10046,6 +10072,7 @@ function buildCliPlan(
quota: "usage",
quotas: "usage",
skills: "skill",
gh: "github",
};
const primaryHelpKey = aliases[primary] ?? primary;
if (hasHelpFlag(args)) {
Expand Down Expand Up @@ -10289,9 +10316,55 @@ function buildCliPlan(
)
return buildUpdatePlan(args);
if (primary === "cursor") return buildCursorPlan(args);
if (primary === "github" || primary === "gh") return buildGithubPlan(args);
throw new CliUsageError(`Unknown command '${primary}'. Run 'ade help'.`);
}

function buildGithubPlan(args: string[]): CliPlan {
const sub = firstPositional(args) ?? "app-auth";
if (sub === "help") {
return { kind: "help", text: HELP_BY_COMMAND.github ?? topLevelHelpText() };
}
if (sub === "actions") {
return {
kind: "execute",
label: "github actions",
formatter: "actions-list",
steps: [listActionsStep("actions", "github")],
};
}
if (sub === "app-auth" || sub === "app" || sub === "auth") {
const mode = firstPositional(args) ?? "status";
if (mode === "status" || mode === "show") {
return {
kind: "execute",
label: "github app-auth status",
steps: [actionStep("result", "github", "getAppUserAuthStatus")],
};
}
if (mode === "login" || mode === "authorize" || mode === "start") {
const maxWaitSec = readIntOption(args, ["--max-wait", "--timeout-sec"]);
return {
kind: "github-app-login",
maxWaitSec: typeof maxWaitSec === "number" ? maxWaitSec : null,
};
}
if (mode === "clear" || mode === "logout" || mode === "sign-out") {
return {
kind: "execute",
label: "github app-auth clear",
steps: [actionStep("result", "github", "clearAppUserAuth")],
};
}
throw new CliUsageError(
"github app-auth supports status, login, or clear.",
);
}
throw new CliUsageError(
"github supports app-auth (status | login | clear) and actions.",
);
}

function buildCursorPlan(args: string[]): CliPlan {
// ade cursor <surface> <group> <sub> ... — only "cloud" is wired today.
const surface = firstPositional(args);
Expand Down Expand Up @@ -15464,6 +15537,141 @@ function graphWaitState(value: unknown): {
};
}

/**
* Interactive GitHub App (device-flow) authorization for headless / brain
* setups that have no Settings panel. Device-auth session state lives in the
* runtime process memory, so start-then-poll must happen over a single live
* connection — a two-process `start` + `poll` split cannot share the session in
* headless mode. start/poll are CTO-only, so run this with `--role cto`.
* Progress is written to stderr; only the final auth status (never the token)
* is emitted on stdout.
*/
async function runGithubAppLogin(
plan: CliPlan & { kind: "github-app-login" },
options: GlobalOptions,
): Promise<{ output: string; exitCode: number }> {
let connection: CliConnection;
try {
connection = await createConnection(options);
} catch (error) {
throw new CliExecutionError(
"Failed to initialize ADE CLI connection for github app-auth login.",
{
cause: error instanceof Error ? error.message : String(error),
nextAction:
"Verify --project-root points at an ADE project and run ade doctor --json.",
},
);
}
const runGithubAction = async (
action: string,
actionArgs: JsonObject = {},
): Promise<JsonObject> => {
let result: unknown;
try {
const raw = await connection.request("ade/actions/call", {
name: "run_ade_action",
arguments: { domain: "github", action, args: actionArgs },
});
// `ade/actions/call` returns an `{ ok: false, error }` envelope on the
// CTO gate rather than throwing; unwrapToolResult converts that to a throw.
result = unwrapActionEnvelope(unwrapToolResult(raw));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/elevated role/i.test(message)) {
throw new CliUsageError(
"github app-auth login authorizes the machine GitHub App and requires --role cto (e.g. `ade --role cto github app-auth login`).",
);
}
throw error;
}
if (!isRecord(result)) {
throw new CliExecutionError(
`github.${action} returned an unexpected result.`,
{ action },
);
}
return result;
};
try {
const start = await runGithubAction("startAppUserDeviceAuth");
const sessionId = asString(start.sessionId);
const userCode = asString(start.userCode);
const verificationUri = asString(start.verificationUri);
const verificationUriComplete = asString(start.verificationUriComplete);
const expiresAt = asString(start.expiresAt);
if (!sessionId || !userCode || !verificationUri) {
throw new CliExecutionError(
"GitHub device authorization did not start.",
{ start },
);
}
let intervalSec =
typeof start.intervalSec === "number" && start.intervalSec > 0
? start.intervalSec
: 5;
const expiresAtMs = expiresAt ? Date.parse(expiresAt) : Number.NaN;
const maxWaitDeadlineMs =
plan.maxWaitSec != null ? Date.now() + plan.maxWaitSec * 1000 : Number.NaN;
const deadlineMs = Math.min(
Number.isFinite(expiresAtMs) ? expiresAtMs : Number.POSITIVE_INFINITY,
Number.isFinite(maxWaitDeadlineMs)
? maxWaitDeadlineMs
: Number.POSITIVE_INFINITY,
);
process.stderr.write(
`\nAuthorize the ADE GitHub App:\n` +
` 1. Open ${verificationUri}\n` +
` 2. Enter code: ${userCode}\n` +
(verificationUriComplete
? ` (or open ${verificationUriComplete} to skip step 2)\n`
: "") +
`\nWaiting for authorization…\n`,
);

while (true) {
if (Number.isFinite(deadlineMs) && Date.now() >= deadlineMs) {
process.stderr.write("GitHub device authorization timed out.\n");
const status = await runGithubAction("getAppUserAuthStatus");
return {
output: formatOutput(
{ ...status, status: "expired", error: "timed_out" },
options,
),
exitCode: 1,
};
}
const sleepMs = Math.max(1, intervalSec) * 1000;
await sleep(
Number.isFinite(deadlineMs)
? Math.min(sleepMs, Math.max(1, deadlineMs - Date.now()))
: sleepMs,
);
const poll = await runGithubAction("pollAppUserDeviceAuth", { sessionId });
Comment on lines +15632 to +15650

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Bound the sleep to the remaining deadline.

With --max-wait shorter than GitHub’s polling interval, Line 15644 sleeps the full interval and can poll after the configured deadline instead of timing out at N seconds.

🐛 Proposed fix
-      await sleep(Math.max(1, intervalSec) * 1000);
+      const sleepMs = Math.max(1, intervalSec) * 1000;
+      const waitMs = Number.isFinite(deadlineMs)
+        ? Math.min(sleepMs, Math.max(0, deadlineMs - Date.now()))
+        : sleepMs;
+      if (waitMs > 0) await sleep(waitMs);
+      if (Number.isFinite(deadlineMs) && Date.now() >= deadlineMs) {
+        continue;
+      }
       const poll = await runGithubAction("pollAppUserDeviceAuth", { sessionId });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while (true) {
if (Number.isFinite(deadlineMs) && Date.now() >= deadlineMs) {
process.stderr.write("GitHub device authorization timed out.\n");
const status = await runGithubAction("getAppUserAuthStatus");
return {
output: formatOutput(
{ ...status, status: "expired", error: "timed_out" },
options,
),
exitCode: 1,
};
}
await sleep(Math.max(1, intervalSec) * 1000);
const poll = await runGithubAction("pollAppUserDeviceAuth", { sessionId });
while (true) {
if (Number.isFinite(deadlineMs) && Date.now() >= deadlineMs) {
process.stderr.write("GitHub device authorization timed out.\n");
const status = await runGithubAction("getAppUserAuthStatus");
return {
output: formatOutput(
{ ...status, status: "expired", error: "timed_out" },
options,
),
exitCode: 1,
};
}
const sleepMs = Math.max(1, intervalSec) * 1000;
const waitMs = Number.isFinite(deadlineMs)
? Math.min(sleepMs, Math.max(0, deadlineMs - Date.now()))
: sleepMs;
if (waitMs > 0) await sleep(waitMs);
if (Number.isFinite(deadlineMs) && Date.now() >= deadlineMs) {
continue;
}
const poll = await runGithubAction("pollAppUserDeviceAuth", { sessionId });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/ade-cli/src/cli.ts` around lines 15632 - 15645, In cli.ts, the polling
loop in the device auth flow currently always sleeps for the full GitHub
interval before checking the deadline, which can push execution past --max-wait.
Update the while loop around runGithubAction("pollAppUserDeviceAuth") to cap the
sleep duration by the remaining time until deadlineMs, and keep the existing
timeout branch as the final guard. Use the existing deadlineMs, intervalSec, and
sessionId flow in this auth polling block so the loop times out exactly when the
configured wait is exceeded.

const status = asString(poll.status);
const authStatus = isRecord(poll.authStatus) ? poll.authStatus : poll;
if (status === "authorized") {
process.stderr.write("GitHub App authorized.\n");
return { output: formatOutput(authStatus, options), exitCode: 0 };
}
if (status === "pending" || status === "slow_down") {
if (typeof poll.intervalSec === "number" && poll.intervalSec > 0) {
intervalSec = poll.intervalSec;
}
continue;
}
// expired | denied | error
const message =
asString(poll.message) ??
`GitHub device authorization ${status ?? "failed"}.`;
process.stderr.write(`${message}\n`);
return { output: formatOutput(authStatus, options), exitCode: 1 };
}
} finally {
await connection.close();
}
}

async function executePlan(
plan: CliPlan & { kind: "execute" },
options: GlobalOptions,
Expand Down Expand Up @@ -15700,6 +15908,9 @@ async function runCli(
if (plan.kind === "ade-code") {
return await runAdeCode(plan.rest, parsed.options);
}
if (plan.kind === "github-app-login") {
return await runGithubAppLogin(plan, parsed.options);
}
const result = await executePlan(plan, parsed.options);
if (plan.writeResultPath) {
const payload = JSON.stringify(
Expand Down
29 changes: 28 additions & 1 deletion apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ import {
parseGitHubScopeHeaders,
} from "../../desktop/src/shared/githubScopes";
import type {
GitHubAppDeviceAuthPollResult,
GitHubAppDeviceAuthStartResult,
GitHubAppUserAuthStatus,
GitHubStatus,
LinearIngressEventRecord,
WorkerAgentRun,
Expand All @@ -61,6 +64,7 @@ import {
fetchGitHubAppInstallationStatus,
type GitHubRelaySecretReader,
} from "../../desktop/src/main/services/github/githubRelayConfig";
import { createGitHubAppUserAuthService } from "../../desktop/src/main/services/github/githubAppUserAuthService";
import type { AdeRuntimePaths } from "./bootstrap";
import { createLinearClient as createLinearClientImpl } from "../../desktop/src/main/services/cto/linearClient";
import { createLinearIssueTracker as createLinearIssueTrackerImpl } from "../../desktop/src/main/services/cto/linearIssueTracker";
Expand Down Expand Up @@ -457,6 +461,12 @@ export function createHeadlessGitHubService(
} = {},
): HeadlessGitHubService {
const credentialStore = new EncryptedFileCredentialStore();
const appUserAuth = createGitHubAppUserAuthService({
credentialStore,
logger,
fetchImpl: (input, init) => fetchGitHub(input, init ?? {}),
userAgent: "ade-cli",
});
const tokenKey = "github.token.v1";
let cachedStatus: Awaited<
ReturnType<HeadlessGitHubService["getStatus"]>
Expand Down Expand Up @@ -1006,13 +1016,27 @@ export function createHeadlessGitHubService(
const owner = args.owner?.trim();
const name = args.name?.trim();
const repo = owner && name ? { owner, name } : detectGitHubRepo(projectRoot);
const githubAppUserToken = await appUserAuth.getValidTokenForRelay().catch(() => null);
return fetchGitHubAppInstallationStatus({
repo,
secretReader: options.githubRelaySecretReader,
forceRefresh: args.forceRefresh === true,
githubToken: getToken(),
githubAppUserToken,
auditLog: appUserAuth.auditLog,
});
},
getAppUserAuthStatus(): GitHubAppUserAuthStatus {
return appUserAuth.getAuthStatus();
},
async startAppUserDeviceAuth(): Promise<GitHubAppDeviceAuthStartResult> {
return await appUserAuth.startDeviceAuth();
},
async pollAppUserDeviceAuth(args: { sessionId: string }): Promise<GitHubAppDeviceAuthPollResult> {
return await appUserAuth.pollDeviceAuth(args);
},
clearAppUserAuth(): GitHubAppUserAuthStatus {
return appUserAuth.clearAuth();
},
async getRepoOrThrow() {
const repo = detectGitHubRepo(projectRoot);
if (!repo)
Expand All @@ -1029,6 +1053,9 @@ export function createHeadlessGitHubService(
);
return token;
},
async getAppUserTokenForRelay() {
return await appUserAuth.getValidTokenForRelay();
},
parseGitHubRepoFromRemoteUrl,
parseNextLink: parseNextGitHubLink,
setToken(nextToken: string) {
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ export const ADE_ACTION_CTO_ONLY: Partial<Record<AdeActionDomain, readonly strin
"clearOAuthClientCredentials",
],
linear_oauth: ["startSession"],
github: ["setToken", "clearToken"],
github: ["setToken", "clearToken", "startAppUserDeviceAuth", "pollAppUserDeviceAuth", "clearAppUserAuth"],
update: ["quitAndInstall"],
flow_policy: ["savePolicy", "rollbackRevision"],
linear_sync: ["runSyncNow", "resolveQueueItem"],
Expand Down Expand Up @@ -590,17 +590,21 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
linear_routing: ["simulateRoute"],
github: [
"clearToken",
"clearAppUserAuth",
"detectRepo",
"getAppInstallationStatus",
"getAppUserAuthStatus",
"getRepoOrThrow",
"getRemoteStatus",
"getStatus",
"createRepoAutolink",
"listRepoAutolinks",
"listRepoCollaborators",
"listRepoLabels",
"pollAppUserDeviceAuth",
"publishCurrentProject",
"setToken",
"startAppUserDeviceAuth",
],
feedback: ["list", "prepareDraft", "submitPreparedDraft"],
usage: ["forceRefresh", "getAdeUsageStats", "getUsageSnapshot", "poll", "start", "stop"],
Expand Down Expand Up @@ -2491,6 +2495,7 @@ function buildGithubDomainService(runtime: AdeRuntime): OpaqueService | null {
return githubService.getAppInstallationStatus({
owner: typeof actionArgs.owner === "string" ? actionArgs.owner : undefined,
name: typeof actionArgs.name === "string" ? actionArgs.name : undefined,
forceRefresh: actionArgs.forceRefresh === true,
});
},
async listRepoCollaborators(args?: unknown) {
Expand Down
Loading
Loading