From 6c3be8f1e28270b56a86ec75360acf08c9912672 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:41:14 -0400 Subject: [PATCH 1/5] feat(account): Clerk identity + ade login (OAuth PKCE loopback) Adds an optional, machine-scoped ADE account layer. `ade login` runs an OAuth Authorization-Code + PKCE (S256) flow via the system browser and a 127.0.0.1 loopback; the daemon owns the encrypted session (account.session.v1) and refreshes it (authEpoch-guarded, deduped), so desktop, CLI, and TUI share one login. New `account` daemon action domain (startLogin/pollLogin/status/signOut/getToken); getToken + login/signout are gated CTO-only, `status` is token-free. Projectless `account.call` machine RPC so `ade login`/`ade logout`/`ade auth status` need no project. Local-first is preserved: no local path gains a login gate; login only unlocks reaching remote machines + the account directory. PIN pairing and local `ade code` keep working with zero login. 343 tests, ade-cli + desktop typecheck, built-binary verification green. Manual browser `ade login` smoke pending. Phase 2 of the accounts/machine-directory initiative. Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/bootstrap.ts | 12 + apps/ade-cli/src/cli.test.ts | 129 ++++ apps/ade-cli/src/cli.ts | 217 +++++- .../ade-cli/src/multiProjectRpcServer.test.ts | 52 ++ apps/ade-cli/src/multiProjectRpcServer.ts | 36 + .../account/accountAuthService.test.ts | 302 ++++++++ .../services/account/accountAuthService.ts | 678 ++++++++++++++++++ .../account/sharedAccountAuthService.ts | 83 +++ .../main/services/adeActions/registry.test.ts | 67 ++ .../src/main/services/adeActions/registry.ts | 33 + 10 files changed, 1595 insertions(+), 14 deletions(-) create mode 100644 apps/ade-cli/src/services/account/accountAuthService.test.ts create mode 100644 apps/ade-cli/src/services/account/accountAuthService.ts create mode 100644 apps/ade-cli/src/services/account/sharedAccountAuthService.ts diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index c09a16ef4..00b83f234 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -147,6 +147,11 @@ import { import { createLaneWorktreeLockService, type LaneWorktreeLockService } from "../../desktop/src/main/services/lanes/laneWorktreeLockService"; import { createHeadlessLinearServices } from "./headlessLinearServices"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import type { AccountAuthService } from "./services/account/accountAuthService"; +import { + getSharedAccountAuthService, + registerAccountConfigProjectRoot, +} from "./services/account/sharedAccountAuthService"; import { createEventBuffer, type BufferedEvent, type EventBuffer } from "./eventBuffer"; import { readAutomationsEnvOverride } from "../../desktop/src/shared/automationAvailability"; @@ -244,6 +249,7 @@ export type AdeRuntime = { linearIssueTracker?: ReturnType | null; processService?: ReturnType | null; githubService?: ReturnType | null; + accountAuthService?: AccountAuthService | null; automationService?: ReturnType | null; automationPlannerService?: ReturnType | null; computerUseArtifactBrokerService: ComputerUseArtifactBrokerService; @@ -681,6 +687,11 @@ export async function createAdeRuntime(args: { logger, }); const projectSecretService = createProjectSecretService(projectRoot); + registerAccountConfigProjectRoot(projectRoot); + const accountAuthService = getSharedAccountAuthService({ + projectRoots: () => [projectRoot], + logger, + }); const onboardingService = createOnboardingService({ db, logger, @@ -1707,6 +1718,7 @@ export async function createAdeRuntime(args: { ctoMemoryService, adeProjectService, githubService: headlessLinearServices.githubService, + accountAuthService, linearCredentialService: headlessLinearServices.linearCredentialService, linearOAuthService, prService: headlessLinearServices.prService, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 7d527082f..4bbeb495e 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -127,6 +127,73 @@ function writeSyncHostSingletonLock(args: { } describe("ADE CLI", () => { + it("builds projectless account commands and reports the signed-out local-first message", () => { + const statusPlan = expectExecutePlan(buildCliPlan(["auth", "status"])); + expect(statusPlan).toMatchObject({ + label: "auth status", + formatter: "account-auth", + machineOnly: true, + machineAutoStart: true, + steps: [{ + method: "account.call", + params: { action: "status", args: {} }, + }], + }); + expect(shouldAutoRegisterProjectForPlan(statusPlan)).toBe(false); + + const logoutPlan = expectExecutePlan(buildCliPlan(["logout"])); + expect(logoutPlan.steps[0]).toMatchObject({ + method: "account.call", + params: { action: "signOut", args: {} }, + }); + expect(shouldAutoRegisterProjectForPlan(logoutPlan)).toBe(false); + + expect(buildCliPlan(["login", "--max-wait", "42"])).toEqual({ + kind: "account-login", + maxWaitSec: 42, + }); + const rawActionPlan = expectExecutePlan(buildCliPlan(["actions", "run", "account.status"])); + expect(rawActionPlan.steps[0]).toMatchObject({ + method: "account.call", + params: { action: "status", args: {} }, + }); + + const connection = { + mode: "runtime-socket" as const, + projectRoot: "/unused", + workspaceRoot: "/unused", + socketPath: "/tmp/ade.sock", + request: async () => null, + close: () => {}, + }; + const summarized = summarizeExecution({ + plan: statusPlan, + connection, + values: { + result: { + domain: "account", + action: "status", + result: { + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + }, + statusHints: {}, + }, + }, + }); + expect(formatOutput(summarized, { + ...baseResolveOpts(), + projectRoot: null, + workspaceRoot: null, + text: true, + }, inferFormatter(statusPlan))).toBe( + "Not signed in — local use does not require an account.\n", + ); + }); + it("parses global options without stealing command flags", () => { const parsed = parseCliArgs([ "--project-root", @@ -2619,6 +2686,68 @@ describe("ADE CLI", () => { } }); + posixIt("reports signed-out account status over the machine socket in headless mode", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-account-status-sock-")); + const socketPath = path.join(root, "ade.sock"); + const requests: Array<{ method: string; params?: unknown }> = []; + const stop = await startHeadlessRpcSocketServer({ + socketPath, + createHandler: () => (async (request: any) => { + requests.push({ method: request.method, params: request.params }); + if (request.method === "ade/initialize") { + return { + runtimeInfo: { + version: process.env.ADE_CLI_VERSION?.trim() || "0.0.0", + buildHash: null, + defaultRole: "cto", + packageChannel: null, + projectRoot: null, + pid: process.pid, + }, + }; + } + if (request.method === "account.call") { + return { + domain: "account", + action: "status", + result: { + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + }, + statusHints: {}, + }; + } + throw new Error(`Unexpected method: ${request.method}`); + }) as any, + }); + + try { + const result = await runCli([ + "--socket", + socketPath, + "--headless", + "auth", + "status", + "--text", + ]); + expect(result).toEqual({ + output: "Not signed in — local use does not require an account.\n", + exitCode: 0, + }); + expect(requests.at(-1)).toEqual({ + method: "account.call", + params: { action: "status", args: {} }, + }); + expect(requests.some((request) => request.method === "projects.add")).toBe(false); + } finally { + stop?.(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + posixIt("advises starting the machine brain when a personal chat connection fails", async () => { const socketPath = path.join( fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-personal-chat-missing-")), diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index db6236e8f..1c5ccebf6 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -160,6 +160,7 @@ type FormatterId = | "status" | "doctor" | "auth" + | "account-auth" | "projects-list" | "linear-quick-view" | "lanes" @@ -232,6 +233,7 @@ type CliPlan = formatter?: FormatterId; preferHeadless?: boolean; machineOnly?: boolean; + machineAutoStart?: boolean; historyOperationId?: string; historyStatusFilter?: string; historyListFilters?: { @@ -266,7 +268,8 @@ type CliPlan = timeoutMs: number; pollIntervalMs: number; } - | { kind: "github-app-login"; maxWaitSec: number | null }; + | { kind: "github-app-login"; maxWaitSec: number | null } + | { kind: "account-login"; maxWaitSec: number | null }; type CliConnection = { mode: "desktop-socket" | "runtime-socket" | "headless"; @@ -511,7 +514,9 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} catalog, sync endpoint, and execution authority for the channel. $ ade help Display help for a command - $ ade auth status Check local ADE CLI readiness + $ ade login [--max-wait ] Sign in to the optional ADE account + $ ade logout Sign out of the ADE account + $ ade auth status Show ADE account sign-in status $ ade code Open ADE Work chat in the terminal $ ade new chat --mode chat|cli --prompt "fix" Start an ADE Work chat or tracked CLI session $ ade desktop Launch the installed desktop app @@ -1019,6 +1024,20 @@ const IOS_SIMULATOR_HELP_ALIASES: Record = { }; const HELP_BY_COMMAND: Record = { + auth: `${ADE_BANNER} + ADE Account + + ADE accounts are optional. Signing in unlocks remote-machine and account + directory features; every local ADE workflow continues to work signed out. + + $ ade login Open Clerk sign-in in the system browser + $ ade logout Clear the shared machine account session + $ ade auth status --text Show the shared machine account status + + Flags (login): + --max-wait Give up waiting before the five-minute OAuth + session expires. +`, search: `${ADE_BANNER} ADE Search @@ -3311,6 +3330,18 @@ function actionStep( return actionCallStep(key, "run_ade_action", { domain, action, args }); } +function accountActionStep( + key: string, + action: string, + args: JsonObject = {}, +): InvocationStep { + return { + key, + method: "account.call", + params: { action, args }, + }; +} + function actionArgsListStep( key: string, domain: string, @@ -3357,6 +3388,13 @@ function buildActionRunStep(args: string[]): InvocationStep { const argsList = parseJson(argsListJson, "--args-list-json"); if (!Array.isArray(argsList)) throw new CliUsageError("--args-list-json must be a JSON array."); + if (domain === "account") { + if (action === "pollLogin" && typeof argsList[0] === "string") { + return accountActionStep("result", action, { sessionId: argsList[0] }); + } + if (argsList.length === 0) return accountActionStep("result", action); + throw new CliUsageError("account actions accept object input; pollLogin also accepts [sessionId]."); + } return actionCallStep("result", "run_ade_action", { domain, action, @@ -3366,6 +3404,13 @@ function buildActionRunStep(args: string[]): InvocationStep { const scalarJson = readValue(args, ["--scalar-json", "--arg-value-json"]); if (scalarJson != null) { + if (domain === "account") { + const scalar = parseJson(scalarJson, "--scalar-json"); + if (action === "pollLogin" && typeof scalar === "string") { + return accountActionStep("result", action, { sessionId: scalar }); + } + throw new CliUsageError("Only account.pollLogin accepts scalar input."); + } return actionCallStep("result", "run_ade_action", { domain, action, @@ -3375,6 +3420,12 @@ function buildActionRunStep(args: string[]): InvocationStep { const scalar = readValue(args, ["--scalar", "--arg-value"]); if (scalar != null) { + if (domain === "account") { + if (action === "pollLogin") { + return accountActionStep("result", action, { sessionId: scalar }); + } + throw new CliUsageError("Only account.pollLogin accepts scalar input."); + } return actionCallStep("result", "run_ade_action", { domain, action, @@ -3382,7 +3433,10 @@ function buildActionRunStep(args: string[]): InvocationStep { }); } - return actionStep("result", domain, action, collectGenericObjectArgs(args)); + const objectArgs = collectGenericObjectArgs(args); + return domain === "account" + ? accountActionStep("result", action, objectArgs) + : actionStep("result", domain, action, objectArgs); } function buildLanePlan(args: string[]): CliPlan { @@ -11284,6 +11338,8 @@ function buildCliPlan( skills: "skill", gh: "github", create: "new", + login: "auth", + logout: "auth", }; const primaryHelpKey = aliases[primary] ?? primary; if (hasHelpFlag(args)) { @@ -11416,6 +11472,23 @@ function buildCliPlan( steps: [{ key: "ping", method: "ping" }], }; } + if (primary === "login") { + const maxWaitSec = readIntOption(args, ["--max-wait", "--timeout-sec"]); + return { + kind: "account-login", + maxWaitSec: typeof maxWaitSec === "number" ? maxWaitSec : null, + }; + } + if (primary === "logout") { + return { + kind: "execute", + label: "account logout", + formatter: "account-auth", + machineOnly: true, + machineAutoStart: true, + steps: [accountActionStep("result", "signOut")], + }; + } if (primary === "doctor") { return { kind: "execute", @@ -11439,14 +11512,10 @@ function buildCliPlan( return { kind: "execute", label: "auth status", - summary: "auth", - steps: [ - { key: "actions", method: "ade/actions/list" }, - { - ...actionStep("projectConfig", "project_config", "get"), - optional: true, - }, - ], + formatter: "account-auth", + machineOnly: true, + machineAutoStart: true, + steps: [accountActionStep("result", "status")], }; } if (primary === "lanes" || primary === "lane") return buildLanePlan(args); @@ -12856,6 +12925,7 @@ function isMachineRuntimeScopedMethod(method: string): boolean { method === "exit" || method === "runtime/info" || method === "machineInfo.get" || + method.startsWith("account.") || method.startsWith("sync.") || method.startsWith("projects.") || method.startsWith("personalChats.") @@ -13177,7 +13247,7 @@ function withProjectId( async function createConnection( options: GlobalOptions, - args: { autoRegisterProject?: boolean } = {}, + args: { autoRegisterProject?: boolean; machineRuntimeOnly?: boolean } = {}, ): Promise { const roots = resolveRoots(options); const { resolveAdeLayout } = @@ -13230,6 +13300,7 @@ async function createConnection( try { socketClient?.close(); } catch {} + if (args.machineRuntimeOnly) throw error; if ( options.requireSocket && !shouldAttemptDesktopSocketConnection(legacySocketPath) @@ -17039,6 +17110,16 @@ function formatTextOutput( ["note", isRecord(value) ? value.note : null], ]); } + case "account-auth": { + if (!isRecord(value) || value.signedIn !== true) { + return "Not signed in — local use does not require an account."; + } + const identity = asString(value.email) + ?? asString(value.name) + ?? asString(value.userId) + ?? "ADE account"; + return `Signed in as ${identity}`; + } case "projects-list": return formatProjectsList(value); case "linear-quick-view": @@ -17472,6 +17553,101 @@ function graphWaitState(value: unknown): { }; } +/** + * Interactive machine-account authorization. The daemon owns the loopback + * listener, PKCE verifier, token exchange, and credential persistence; the CLI + * only opens the returned URL and polls the daemon over one live connection. + */ +async function runAccountLogin( + plan: CliPlan & { kind: "account-login" }, + options: GlobalOptions, +): Promise<{ output: string; exitCode: number }> { + let connection: CliConnection; + try { + connection = await createConnection( + { ...options, headless: false }, + { autoRegisterProject: false, machineRuntimeOnly: true }, + ); + } catch (error) { + throw new CliExecutionError( + "Failed to initialize the ADE brain for account login.", + { + cause: error instanceof Error ? error.message : String(error), + nextAction: "Start the machine ADE brain with `ade brain start`, then retry `ade login`.", + }, + ); + } + + const runAccountAction = async ( + action: string, + actionArgs: JsonObject = {}, + ): Promise => { + const raw = await connection.request("account.call", { action, args: actionArgs }); + const result = unwrapActionEnvelope(raw); + if (!isRecord(result)) { + throw new CliExecutionError(`account.${action} returned an unexpected result.`, { action }); + } + return result; + }; + + try { + const start = await runAccountAction("startLogin"); + const sessionId = asString(start.sessionId); + const authorizeUrl = asString(start.authorizeUrl); + const expiresAt = asString(start.expiresAt); + if (!sessionId || !authorizeUrl) { + throw new CliExecutionError("ADE account login did not start.", { start }); + } + + const openResult = openUrlViaOs(authorizeUrl); + process.stderr.write( + `\nSign in to ADE in your browser. If it did not open, visit:\n ${authorizeUrl}\n\nWaiting for sign-in…\n`, + ); + if (openResult.failed) { + process.stderr.write(`Could not open the browser automatically: ${openResult.message}\n`); + } + + 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, + ); + + while (true) { + if (Number.isFinite(deadlineMs) && Date.now() >= deadlineMs) { + process.stderr.write("ADE account sign-in timed out.\n"); + const status = await runAccountAction("status"); + return { output: formatOutput(status, options, "account-auth"), exitCode: 1 }; + } + await sleep( + Number.isFinite(deadlineMs) + ? Math.min(500, Math.max(1, deadlineMs - Date.now())) + : 500, + ); + const poll = await runAccountAction("pollLogin", { sessionId }); + const pollStatus = asString(poll.status); + const authStatus = isRecord(poll.authStatus) ? poll.authStatus : poll; + if (pollStatus === "signed_in") { + const identity = asString(authStatus.email) + ?? asString(authStatus.name) + ?? asString(authStatus.userId) + ?? "ADE account"; + process.stderr.write(`Signed in as ${identity}\n`); + return { output: formatOutput(authStatus, options, "account-auth"), exitCode: 0 }; + } + if (pollStatus === "pending") continue; + const message = asString(poll.message) ?? "ADE account sign-in failed."; + process.stderr.write(`${message}\n`); + return { output: formatOutput(authStatus, options, "account-auth"), exitCode: 1 }; + } + } finally { + await connection.close(); + } +} + /** * Interactive GitHub App (device-flow) authorization for headless / brain * setups that have no Settings panel. Device-auth session state lives in the @@ -17689,13 +17865,18 @@ async function executePlan( let connection: CliConnection; const connectionOptions = plan.machineOnly - ? { ...options, headless: false, requireSocket: true } + ? { + ...options, + headless: false, + requireSocket: plan.machineAutoStart ? false : true, + } : plan.preferHeadless && !options.requireSocket ? { ...options, headless: true } : options; try { connection = await createConnection(connectionOptions, { autoRegisterProject: shouldAutoRegisterProjectForPlan(plan), + machineRuntimeOnly: plan.machineAutoStart === true, }); } catch (error) { const roots = resolveRoots(options); @@ -17924,7 +18105,12 @@ async function runCli( output: formatOutput(plan.value, parsed.options, plan.formatter), exitCode: 0, }; - if (plan.kind === "execute" && plan.machineOnly && parsed.options.headless) { + if ( + plan.kind === "execute" + && plan.machineOnly + && !plan.machineAutoStart + && parsed.options.headless + ) { throw new CliUsageError( "Personal chats require the machine-owned ADE brain; remove --headless and run `ade brain start` if the brain is not already available.", ); @@ -18058,6 +18244,9 @@ async function runCli( if (plan.kind === "ade-code") { return await runAdeCode(plan.rest, parsed.options); } + if (plan.kind === "account-login") { + return await runAccountLogin(plan, parsed.options); + } if (plan.kind === "github-app-login") { return await runGithubAppLogin(plan, parsed.options); } diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index bf979361a..fdfb94b97 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -50,6 +50,58 @@ function makeRuntime(label: string) { } describe("multi-project RPC server", () => { + it("exposes the machine account action domain without a project id", async () => { + const { registry } = createRegistry(); + const accountAuthService = { + startLogin: vi.fn(), + pollLogin: vi.fn(), + getStatus: vi.fn(() => ({ + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + })), + getAccessToken: vi.fn(), + signOut: vi.fn(), + dispose: vi.fn(), + }; + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + accountAuthService, + }); + + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + const result = await handler({ + jsonrpc: "2.0", + id: 2, + method: "account.call", + params: { action: "status" }, + }); + + expect(result).toEqual({ + domain: "account", + action: "status", + result: { + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + }, + statusHints: {}, + }); + expect(accountAuthService.getStatus).toHaveBeenCalledTimes(1); + expect(registry.list()).toHaveLength(0); + handler.dispose(); + }); + it("reports a build hash for manually-started CLI entrypoints", async () => { const { registry, root } = createRegistry(); const cliPath = path.join(root, "manual-cli.cjs"); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 31af2ca0b..7a9ca29da 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -36,6 +36,14 @@ import { PersonalChatScope } from "./services/personalChats/personalChatScope"; import { createHeadlessGitHubService } from "./headlessLinearServices"; import { normalizeAdeRuntimeRole } from "./runtimeRoles"; import type { SyncPeerDeviceType } from "../../desktop/src/shared/types"; +import { + callAccountAction, + type AccountAuthService, +} from "./services/account/accountAuthService"; +import { + getSharedAccountAuthService, + registerAccountConfigProjectRoot, +} from "./services/account/sharedAccountAuthService"; type HandlerEntry = { handler: JsonRpcHandler & { dispose?: () => void }; @@ -56,6 +64,7 @@ export type MultiProjectRpcHandlerOptions = { disposeScopesOnDispose?: boolean; onShutdown?: (() => void) | null; personalChatScope?: Pick; + accountAuthService?: AccountAuthService; }; const RUNTIME_METHODS = new Set([ @@ -65,6 +74,7 @@ const RUNTIME_METHODS = new Set([ "shutdown", "exit", "runtime/info", + "account.call", "personalChats.call", "personalChats.streamEvents", "machineInfo.get", @@ -437,6 +447,15 @@ export function createMultiProjectRpcRequestHandler( setNotifier: (notify: JsonRpcNotifier | null) => void; } { const projectRegistry = options.projectRegistry ?? new ProjectRegistry(); + const registerAccountProjects = (): void => { + for (const project of projectRegistry.list()) { + registerAccountConfigProjectRoot(project.rootPath); + } + }; + registerAccountProjects(); + const accountAuthService = options.accountAuthService ?? getSharedAccountAuthService({ + projectRoots: () => projectRegistry.list().map((project) => project.rootPath), + }); const ownsPersonalChatScope = options.personalChatScope == null; const personalChatScope = options.personalChatScope ?? new PersonalChatScope(); const handlers = new Map>(); @@ -674,6 +693,7 @@ export function createMultiProjectRpcRequestHandler( listMyGitHubRepos: true, }, personalChats: personalChatScope.capabilities(), + account: true, }, }; } @@ -714,6 +734,22 @@ export function createMultiProjectRpcRequestHandler( }; } + if (method === "account.call") { + const action = typeof params.action === "string" ? params.action.trim() : ""; + if (!action) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "account.call requires action.", + ); + } + registerAccountProjects(); + return await callAccountAction({ + service: accountAuthService, + action, + actionArgs: isRecord(params.args) ? params.args : {}, + }); + } + if (method === "personalChats.call") { return await personalChatScope.call(params.action, params.args); } diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts new file mode 100644 index 000000000..ae7e5877e --- /dev/null +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -0,0 +1,302 @@ +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SyncCredentialStore } from "../credentials/credentialStore"; +import { + ACCOUNT_SESSION_CREDENTIAL_KEY, + createAccountAuthService, + derivePkceChallenge, + type AccountAuthService, + type AccountSessionRecord, +} from "./accountAuthService"; + +class MemoryCredentialStore implements SyncCredentialStore { + readonly values = new Map(); + + async get(key: string): Promise { + return this.getSync(key); + } + + async set(key: string, value: string): Promise { + this.setSync(key, value); + } + + async delete(key: string): Promise { + this.deleteSync(key); + } + + getSync(key: string): string | null { + return this.values.get(key) ?? null; + } + + setSync(key: string, value: string): void { + this.values.set(key, value); + } + + deleteSync(key: string): void { + this.values.delete(key); + } +} + +function jwt(claims: Record): string { + return [ + Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"), + Buffer.from(JSON.stringify(claims)).toString("base64url"), + "signature", + ].join("."); +} + +function jsonResponse(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function storedSession(overrides: Partial = {}): AccountSessionRecord { + return { + accessToken: jwt({ sub: "user_old", email: "old@example.com", name: "Old User" }), + refreshToken: "refresh-old", + tokenType: "Bearer", + expiresAt: "2026-07-14T12:01:00.000Z", + obtainedAt: "2026-07-14T11:00:00.000Z", + userId: "user_old", + email: "old@example.com", + name: "Old User", + ...overrides, + }; +} + +const activeServices: AccountAuthService[] = []; + +afterEach(() => { + for (const service of activeServices.splice(0)) service.dispose(); +}); + +describe("AccountAuthService OAuth PKCE login", () => { + it("derives the S256 challenge and constructs the exact loopback authorize URL", async () => { + const verifierBytes = Buffer.alloc(32, 0x11); + const stateBytes = Buffer.alloc(32, 0x22); + const randomBytes = vi.fn() + .mockReturnValueOnce(verifierBytes) + .mockReturnValueOnce(stateBytes); + const service = createAccountAuthService({ + credentialStore: new MemoryCredentialStore(), + getOAuthConfig: () => ({ issuer: "https://clerk.example.test/", clientId: "client-public" }), + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + randomBytes, + randomUUID: () => "login-session-1", + fetchImpl: vi.fn(), + }); + activeServices.push(service); + + const start = await service.startLogin(); + const authorizeUrl = new URL(start.authorizeUrl); + const verifier = verifierBytes.toString("base64url"); + + expect(start.sessionId).toBe("login-session-1"); + expect(start.expiresAt).toBe("2026-07-14T12:05:00.000Z"); + expect(authorizeUrl.origin + authorizeUrl.pathname).toBe("https://clerk.example.test/oauth/authorize"); + expect(Object.fromEntries(authorizeUrl.searchParams)).toEqual({ + response_type: "code", + client_id: "client-public", + redirect_uri: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/callback$/), + code_challenge: createHash("sha256").update(verifier, "ascii").digest("base64url"), + code_challenge_method: "S256", + state: stateBytes.toString("base64url"), + scope: "openid profile email offline_access", + }); + expect(start.authorizeUrl).toContain("scope=openid%20profile%20email%20offline_access"); + expect(derivePkceChallenge(verifier)).toBe( + createHash("sha256").update(verifier, "ascii").digest("base64url"), + ); + }); + + it("exchanges the callback code, persists the session, and returns the close-tab page", async () => { + const store = new MemoryCredentialStore(); + const accessToken = jwt({ + sub: "user_123", + email: "person@example.com", + name: "Person Example", + }); + const fetchImpl = vi.fn(async (_input: string, _init?: RequestInit): Promise => jsonResponse({ + access_token: accessToken, + refresh_token: "refresh-123", + expires_in: 3600, + token_type: "Bearer", + })); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + randomBytes: (size) => Buffer.alloc(size, 0x33), + randomUUID: () => "login-session-success", + fetchImpl, + }); + activeServices.push(service); + + expect(service.getStatus()).toEqual({ + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + }); + const start = await service.startLogin(); + const authorizeUrl = new URL(start.authorizeUrl); + const redirectUri = authorizeUrl.searchParams.get("redirect_uri")!; + const state = authorizeUrl.searchParams.get("state")!; + const callback = await fetch(`${redirectUri}?code=oauth-code-123&state=${encodeURIComponent(state)}`); + const html = await callback.text(); + + expect(callback.status).toBe(200); + expect(html).toContain("You can close this tab — signed in to ADE"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [tokenUrl, init] = fetchImpl.mock.calls[0]!; + expect(tokenUrl).toBe("https://clerk.example.test/oauth/token"); + expect(init?.headers).toMatchObject({ + "content-type": "application/x-www-form-urlencoded", + }); + expect(Object.fromEntries(new URLSearchParams(String(init?.body)))).toEqual({ + grant_type: "authorization_code", + code: "oauth-code-123", + code_verifier: Buffer.alloc(32, 0x33).toString("base64url"), + client_id: "client-public", + redirect_uri: redirectUri, + }); + + const persisted = JSON.parse(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)!) as AccountSessionRecord; + expect(persisted).toMatchObject({ + accessToken, + refreshToken: "refresh-123", + expiresAt: "2026-07-14T13:00:00.000Z", + obtainedAt: "2026-07-14T12:00:00.000Z", + userId: "user_123", + email: "person@example.com", + name: "Person Example", + }); + await expect(service.pollLogin(start.sessionId)).resolves.toEqual({ + status: "signed_in", + message: null, + authStatus: { + signedIn: true, + userId: "user_123", + email: "person@example.com", + name: "Person Example", + expiresAt: "2026-07-14T13:00:00.000Z", + }, + }); + }); + + it("rejects a state mismatch without exchanging or resolving the pending login", async () => { + const store = new MemoryCredentialStore(); + const fetchImpl = vi.fn(); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + randomBytes: (size) => Buffer.alloc(size, 0x44), + fetchImpl, + }); + activeServices.push(service); + + const start = await service.startLogin(); + const redirectUri = new URL(start.authorizeUrl).searchParams.get("redirect_uri")!; + const callback = await fetch(`${redirectUri}?code=stolen-code&state=wrong-state`); + + expect(callback.status).toBe(400); + expect(await callback.text()).toContain("ADE sign-in failed"); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); + await expect(service.pollLogin(start.sessionId)).resolves.toMatchObject({ + status: "pending", + authStatus: { signedIn: false }, + }); + }); +}); + +describe("AccountAuthService refresh and sign-out", () => { + it("refreshes inside the two-minute skew and retains identity plus a non-rotated refresh token", async () => { + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const store = new MemoryCredentialStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession())); + const refreshedAccessToken = jwt({ sub: "user_old", email: "old@example.com" }); + const fetchImpl = vi.fn(async (_input: string, _init?: RequestInit): Promise => jsonResponse({ + access_token: refreshedAccessToken, + expires_in: 86_400, + token_type: "Bearer", + })); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl, + now: () => nowMs, + }); + activeServices.push(service); + + await expect(service.getAccessToken()).resolves.toBe(refreshedAccessToken); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [, init] = fetchImpl.mock.calls[0]!; + expect(Object.fromEntries(new URLSearchParams(String(init?.body)))).toEqual({ + grant_type: "refresh_token", + refresh_token: "refresh-old", + client_id: "client-public", + }); + expect(JSON.parse(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)!)).toMatchObject({ + accessToken: refreshedAccessToken, + refreshToken: "refresh-old", + userId: "user_old", + email: "old@example.com", + name: "Old User", + expiresAt: "2026-07-15T12:00:00.000Z", + }); + }); + + it("uses authEpoch so sign-out cannot be overwritten by an in-flight refresh", async () => { + const store = new MemoryCredentialStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession())); + let resolveRefresh: ((response: Response) => void) | null = null; + const fetchImpl = vi.fn(() => new Promise((resolve) => { + resolveRefresh = resolve; + })); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl, + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + }); + activeServices.push(service); + + const refresh = service.getAccessToken(); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)); + expect(service.signOut()).toEqual({ + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + }); + resolveRefresh!(jsonResponse({ + access_token: jwt({ sub: "user_new" }), + refresh_token: "refresh-new", + expires_in: 3600, + })); + + await expect(refresh).rejects.toThrow("ADE is not signed in"); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); + }); + + it("clears the shared credential on sign-out", () => { + const store = new MemoryCredentialStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ + expiresAt: "2026-07-15T12:00:00.000Z", + }))); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + }); + activeServices.push(service); + + expect(service.getStatus()).toMatchObject({ signedIn: true, email: "old@example.com" }); + expect(service.signOut()).toMatchObject({ signedIn: false, email: null }); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); + }); +}); diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts new file mode 100644 index 000000000..995f83030 --- /dev/null +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -0,0 +1,678 @@ +import { createHash, randomBytes as nodeRandomBytes, randomUUID as nodeRandomUUID, timingSafeEqual } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { SyncCredentialStore } from "../credentials/credentialStore"; + +export const ACCOUNT_SESSION_CREDENTIAL_KEY = "account.session.v1"; + +const LOOPBACK_HOST = "127.0.0.1"; +const LOGIN_SESSION_TTL_MS = 5 * 60_000; +const ACCESS_TOKEN_REFRESH_SKEW_MS = 2 * 60_000; +const MAX_PENDING_LOGIN_SESSIONS = 5; +const SUCCESS_HTML = ` + + + + + Signed in to ADE + + +
+

Signed in to ADE

+

You can close this tab — signed in to ADE.

+
+ +`; +const FAILURE_HTML = ` + + + + + ADE sign-in failed + + +
+

ADE sign-in failed

+

Return to ADE and try signing in again.

+
+ +`; + +export type AccountOAuthConfig = { + issuer: string; + clientId: string; +}; + +export type AccountSessionRecord = { + accessToken: string; + refreshToken: string | null; + tokenType: string; + expiresAt: string; + obtainedAt: string; + userId: string | null; + email: string | null; + name: string | null; +}; + +export type AccountAuthStatus = { + signedIn: boolean; + userId: string | null; + email: string | null; + name: string | null; + expiresAt: string | null; +}; + +export type AccountLoginStartResult = { + sessionId: string; + authorizeUrl: string; + expiresAt: string; +}; + +export type AccountLoginPollResult = { + status: "pending" | "signed_in" | "expired" | "error"; + message: string | null; + authStatus: AccountAuthStatus; +}; + +type AccountAuthLogger = { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; +}; + +type PendingLoginSession = { + sessionId: string; + codeVerifier: string; + oauthState: string; + redirectUri: string; + expiresAtMs: number; + server: Server; + expiryTimer: NodeJS.Timeout; + phase: "pending" | "exchanging" | "signed_in" | "expired" | "error"; + message: string | null; +}; + +type TokenResponse = { + accessToken: string; + refreshToken: string | null; + tokenType: string; + expiresInSec: number; +}; + +export type AccountAuthService = { + startLogin(): Promise; + pollLogin(sessionId: string): Promise; + getStatus(): AccountAuthStatus; + getAccessToken(): Promise; + signOut(): AccountAuthStatus; + dispose(): void; +}; + +export type AccountActionDomainService = { + startLogin(): Promise; + pollLogin(args: { sessionId?: string }): Promise; + status(): AccountAuthStatus; + signOut(): AccountAuthStatus; + getToken(): Promise; +}; + +export const ACCOUNT_ACTION_NAMES = [ + "startLogin", + "pollLogin", + "status", + "signOut", + "getToken", +] as const; + +type AccountActionName = (typeof ACCOUNT_ACTION_NAMES)[number]; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function readPositiveNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && value > 0) return value; + if (typeof value !== "string" || !value.trim()) return null; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +function normalizeOAuthConfig(config: AccountOAuthConfig): AccountOAuthConfig { + const issuer = config.issuer.trim().replace(/\/+$/, ""); + const clientId = config.clientId.trim(); + if (!issuer || !clientId) { + throw new Error( + "ADE account login is not configured. Set CLERK_ISSUER and CLERK_OAUTH_CLIENT_ID in ADE project secrets or the daemon environment.", + ); + } + let parsed: URL; + try { + parsed = new URL(issuer); + } catch { + throw new Error("CLERK_ISSUER must be a valid HTTP(S) URL."); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("CLERK_ISSUER must be a valid HTTP(S) URL."); + } + return { issuer, clientId }; +} + +export function derivePkceChallenge(codeVerifier: string): string { + return createHash("sha256").update(codeVerifier, "ascii").digest("base64url"); +} + +function encodeAuthorizeQuery(entries: Array<[string, string]>): string { + return entries + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join("&"); +} + +function buildAuthorizeUrl(args: { + config: AccountOAuthConfig; + redirectUri: string; + codeChallenge: string; + state: string; +}): string { + const query = encodeAuthorizeQuery([ + ["response_type", "code"], + ["client_id", args.config.clientId], + ["redirect_uri", args.redirectUri], + ["code_challenge", args.codeChallenge], + ["code_challenge_method", "S256"], + ["state", args.state], + ["scope", "openid profile email offline_access"], + ]); + return `${args.config.issuer}/oauth/authorize?${query}`; +} + +function isMatchingState(actual: string | null, expected: string): boolean { + if (!actual) return false; + const actualBytes = Buffer.from(actual, "utf8"); + const expectedBytes = Buffer.from(expected, "utf8"); + return actualBytes.length === expectedBytes.length + && timingSafeEqual(actualBytes, expectedBytes); +} + +function closeServer(server: Server): void { + try { + server.close(); + } catch { + // The listener may already be closed by a completed callback. + } +} + +function respondHtml(response: ServerResponse, statusCode: number, html: string): void { + response.writeHead(statusCode, { + "cache-control": "no-store", + connection: "close", + "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", + "content-type": "text/html; charset=utf-8", + "x-content-type-options": "nosniff", + }); + response.end(html); +} + +function decodeAccountClaims(accessToken: string): { + userId: string | null; + email: string | null; + name: string | null; +} { + try { + const payload = accessToken.split(".")[1]; + if (!payload) return { userId: null, email: null, name: null }; + const claims = asRecord(JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))); + const givenName = readNonEmptyString(claims.given_name ?? claims.first_name); + const familyName = readNonEmptyString(claims.family_name ?? claims.last_name); + const derivedName = [givenName, familyName].filter(Boolean).join(" ") || null; + return { + userId: readNonEmptyString(claims.sub), + email: readNonEmptyString(claims.email ?? claims.primary_email ?? claims.email_address), + name: readNonEmptyString(claims.name) ?? derivedName, + }; + } catch { + return { userId: null, email: null, name: null }; + } +} + +function parseStoredSession(raw: string | null | undefined): AccountSessionRecord | null { + if (!raw?.trim()) return null; + try { + const parsed = asRecord(JSON.parse(raw)); + const accessToken = readNonEmptyString(parsed.accessToken); + const expiresAt = readNonEmptyString(parsed.expiresAt); + const obtainedAt = readNonEmptyString(parsed.obtainedAt); + if (!accessToken || !expiresAt || !obtainedAt) return null; + return { + accessToken, + refreshToken: readNonEmptyString(parsed.refreshToken), + tokenType: readNonEmptyString(parsed.tokenType) ?? "Bearer", + expiresAt, + obtainedAt, + userId: readNonEmptyString(parsed.userId), + email: readNonEmptyString(parsed.email), + name: readNonEmptyString(parsed.name), + }; + } catch { + return null; + } +} + +async function postTokenForm(args: { + fetchImpl: (input: string, init?: RequestInit) => Promise; + tokenUrl: string; + body: Record; +}): Promise { + const response = await args.fetchImpl(args.tokenUrl, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(args.body).toString(), + }); + const payload = asRecord(await response.json().catch(() => ({}))); + if (!response.ok) { + const message = readNonEmptyString(payload.error_description) + ?? readNonEmptyString(payload.error) + ?? `ADE account token request failed (${response.status}).`; + throw new Error(message); + } + const accessToken = readNonEmptyString(payload.access_token); + const expiresInSec = readPositiveNumber(payload.expires_in); + if (!accessToken || expiresInSec == null) { + throw new Error("ADE account token response was missing required fields."); + } + return { + accessToken, + refreshToken: readNonEmptyString(payload.refresh_token), + tokenType: readNonEmptyString(payload.token_type) ?? "Bearer", + expiresInSec, + }; +} + +function toStatus(record: AccountSessionRecord | null): AccountAuthStatus { + return { + signedIn: Boolean(record?.accessToken), + userId: record?.userId ?? null, + email: record?.email ?? null, + name: record?.name ?? null, + expiresAt: record?.expiresAt ?? null, + }; +} + +export function createAccountActionDomainService( + service: AccountAuthService, +): AccountActionDomainService { + return { + startLogin: () => service.startLogin(), + pollLogin: (args) => service.pollLogin(readNonEmptyString(args?.sessionId) ?? ""), + status: () => service.getStatus(), + signOut: () => service.signOut(), + getToken: () => service.getAccessToken(), + }; +} + +export async function callAccountAction(args: { + service: AccountAuthService; + action: string; + actionArgs?: Record; +}): Promise<{ + domain: "account"; + action: string; + result: unknown; + statusHints: Record; +}> { + const action = args.action as AccountActionName; + const domain = createAccountActionDomainService(args.service); + if (!ACCOUNT_ACTION_NAMES.includes(action)) { + throw new Error(`Action 'account.${args.action}' is not callable.`); + } + const actionArgs = args.actionArgs ?? {}; + let result: unknown; + if (action === "pollLogin") { + result = await domain.pollLogin({ sessionId: readNonEmptyString(actionArgs.sessionId) ?? undefined }); + } else if (action === "startLogin") { + result = await domain.startLogin(); + } else if (action === "status") { + result = domain.status(); + } else if (action === "signOut") { + result = domain.signOut(); + } else { + result = await domain.getToken(); + } + return { domain: "account", action: args.action, result, statusHints: {} }; +} + +export function createAccountAuthService(args: { + credentialStore: SyncCredentialStore; + getOAuthConfig: () => AccountOAuthConfig | Promise; + fetchImpl?: (input: string, init?: RequestInit) => Promise; + now?: () => number; + randomBytes?: (size: number) => Buffer; + randomUUID?: () => string; + logger?: AccountAuthLogger; +}): AccountAuthService { + const fetchImpl = args.fetchImpl ?? ((input, init) => fetch(input, init)); + const now = args.now ?? Date.now; + const randomBytes = args.randomBytes ?? nodeRandomBytes; + const randomUUID = args.randomUUID ?? nodeRandomUUID; + const logger = args.logger ?? { info: () => {}, warn: () => {} }; + const pendingSessions = new Map(); + let refreshInFlight: Promise | null = null; + let authEpoch = 0; + + const readSession = (): AccountSessionRecord | null => { + try { + return parseStoredSession(args.credentialStore.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)); + } catch (error) { + logger.warn("account.session_read_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + }; + + const persistSession = (record: AccountSessionRecord | null): void => { + if (record) { + args.credentialStore.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(record)); + } else { + args.credentialStore.deleteSync(ACCOUNT_SESSION_CREDENTIAL_KEY); + } + }; + + const finishPendingSession = ( + session: PendingLoginSession, + phase: PendingLoginSession["phase"], + message: string | null, + ): void => { + session.phase = phase; + session.message = message; + clearTimeout(session.expiryTimer); + closeServer(session.server); + }; + + const expirePendingSession = (session: PendingLoginSession): void => { + if (session.phase !== "pending" && session.phase !== "exchanging") return; + finishPendingSession(session, "expired", "ADE account sign-in expired."); + }; + + const pruneFinishedSessions = (): void => { + for (const [sessionId, session] of pendingSessions) { + if (session.phase === "pending" || session.phase === "exchanging") { + if (session.expiresAtMs <= now()) expirePendingSession(session); + continue; + } + pendingSessions.delete(sessionId); + } + }; + + const buildSessionRecord = ( + token: TokenResponse, + previous?: AccountSessionRecord | null, + ): AccountSessionRecord => { + const obtainedAtMs = now(); + const claims = decodeAccountClaims(token.accessToken); + return { + accessToken: token.accessToken, + refreshToken: token.refreshToken ?? previous?.refreshToken ?? null, + tokenType: token.tokenType, + expiresAt: new Date(obtainedAtMs + Math.trunc(token.expiresInSec * 1000)).toISOString(), + obtainedAt: new Date(obtainedAtMs).toISOString(), + userId: claims.userId ?? previous?.userId ?? null, + email: claims.email ?? previous?.email ?? null, + name: claims.name ?? previous?.name ?? null, + }; + }; + + const exchangeAuthorizationCode = async ( + session: PendingLoginSession, + code: string, + ): Promise => { + const config = normalizeOAuthConfig(await args.getOAuthConfig()); + const token = await postTokenForm({ + fetchImpl, + tokenUrl: `${config.issuer}/oauth/token`, + body: { + grant_type: "authorization_code", + code, + code_verifier: session.codeVerifier, + client_id: config.clientId, + redirect_uri: session.redirectUri, + }, + }); + return buildSessionRecord(token); + }; + + const handleLoopbackRequest = async ( + session: PendingLoginSession, + request: IncomingMessage, + response: ServerResponse, + ): Promise => { + const requestUrl = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`); + if (request.method !== "GET" || requestUrl.pathname !== "/callback") { + respondHtml(response, 404, FAILURE_HTML); + return; + } + if (session.expiresAtMs <= now()) { + expirePendingSession(session); + respondHtml(response, 410, FAILURE_HTML); + return; + } + if (!isMatchingState(requestUrl.searchParams.get("state"), session.oauthState)) { + respondHtml(response, 400, FAILURE_HTML); + return; + } + if (session.phase !== "pending") { + respondHtml(response, 409, session.phase === "signed_in" ? SUCCESS_HTML : FAILURE_HTML); + return; + } + const oauthError = requestUrl.searchParams.get("error"); + const code = readNonEmptyString(requestUrl.searchParams.get("code")); + if (oauthError || !code) { + finishPendingSession(session, "error", "ADE account sign-in was not completed."); + respondHtml(response, 400, FAILURE_HTML); + return; + } + + session.phase = "exchanging"; + const epochAtExchange = authEpoch; + try { + const record = await exchangeAuthorizationCode(session, code); + if ( + authEpoch !== epochAtExchange + || pendingSessions.get(session.sessionId) !== session + || session.phase !== "exchanging" + || session.expiresAtMs <= now() + ) { + finishPendingSession(session, "error", "ADE account sign-in was cancelled."); + respondHtml(response, 409, FAILURE_HTML); + return; + } + persistSession(record); + authEpoch += 1; + finishPendingSession(session, "signed_in", null); + logger.info("account.login_completed"); + respondHtml(response, 200, SUCCESS_HTML); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + finishPendingSession(session, "error", message); + logger.warn("account.login_exchange_failed", { error: message }); + respondHtml(response, 502, FAILURE_HTML); + } + }; + + const startLogin = async (): Promise => { + pruneFinishedSessions(); + while (pendingSessions.size >= MAX_PENDING_LOGIN_SESSIONS) { + const oldestId = pendingSessions.keys().next().value as string | undefined; + if (!oldestId) break; + const oldest = pendingSessions.get(oldestId); + if (oldest) { + finishPendingSession(oldest, "error", "ADE account sign-in was replaced by a newer attempt."); + } + pendingSessions.delete(oldestId); + } + + const config = normalizeOAuthConfig(await args.getOAuthConfig()); + const codeVerifier = randomBytes(32).toString("base64url"); + const oauthState = randomBytes(32).toString("base64url"); + const sessionId = randomUUID(); + const expiresAtMs = now() + LOGIN_SESSION_TTL_MS; + let session: PendingLoginSession | null = null; + const server = createServer((request, response) => { + if (!session) { + respondHtml(response, 503, FAILURE_HTML); + return; + } + void handleLoopbackRequest(session, request, response); + }); + + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, LOOPBACK_HOST); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + closeServer(server); + throw new Error("ADE account loopback listener did not provide a TCP port."); + } + const redirectUri = `http://${LOOPBACK_HOST}:${address.port}/callback`; + const expiryTimer = setTimeout(() => { + if (session) expirePendingSession(session); + }, LOGIN_SESSION_TTL_MS); + expiryTimer.unref?.(); + session = { + sessionId, + codeVerifier, + oauthState, + redirectUri, + expiresAtMs, + server, + expiryTimer, + phase: "pending", + message: null, + }; + pendingSessions.set(sessionId, session); + + return { + sessionId, + authorizeUrl: buildAuthorizeUrl({ + config, + redirectUri, + codeChallenge: derivePkceChallenge(codeVerifier), + state: oauthState, + }), + expiresAt: new Date(expiresAtMs).toISOString(), + }; + }; + + const pollLogin = async (sessionId: string): Promise => { + const normalizedSessionId = sessionId.trim(); + if (!normalizedSessionId) { + return { + status: "error", + message: "ADE account sign-in session id is required.", + authStatus: toStatus(readSession()), + }; + } + const session = pendingSessions.get(normalizedSessionId); + if (!session) { + return { + status: "error", + message: "ADE account sign-in session was not found.", + authStatus: toStatus(readSession()), + }; + } + if (session.expiresAtMs <= now()) expirePendingSession(session); + if (session.phase === "pending" || session.phase === "exchanging") { + return { status: "pending", message: null, authStatus: toStatus(readSession()) }; + } + pendingSessions.delete(normalizedSessionId); + if (session.phase === "signed_in") { + return { status: "signed_in", message: null, authStatus: toStatus(readSession()) }; + } + return { + status: session.phase, + message: session.message, + authStatus: toStatus(readSession()), + }; + }; + + const getStatus = (): AccountAuthStatus => toStatus(readSession()); + + const getAccessToken = async (): Promise => { + const record = readSession(); + if (!record?.accessToken) { + throw new Error("ADE is not signed in. Run `ade login` to sign in."); + } + const expiresAtMs = Date.parse(record.expiresAt); + if (Number.isFinite(expiresAtMs) && expiresAtMs > now() + ACCESS_TOKEN_REFRESH_SKEW_MS) { + return record.accessToken; + } + if (!record.refreshToken) { + throw new Error("ADE account session expired. Run `ade login` again."); + } + + const epochAtJoin = authEpoch; + if (!refreshInFlight) { + refreshInFlight = (async () => { + const config = normalizeOAuthConfig(await args.getOAuthConfig()); + const token = await postTokenForm({ + fetchImpl, + tokenUrl: `${config.issuer}/oauth/token`, + body: { + grant_type: "refresh_token", + refresh_token: record.refreshToken!, + client_id: config.clientId, + }, + }); + const refreshed = buildSessionRecord(token, record); + if (authEpoch === epochAtJoin) persistSession(refreshed); + return refreshed; + })().finally(() => { + refreshInFlight = null; + }); + } + + const refreshed = await refreshInFlight; + if (authEpoch !== epochAtJoin) { + return getAccessToken(); + } + return refreshed.accessToken; + }; + + const signOut = (): AccountAuthStatus => { + authEpoch += 1; + persistSession(null); + for (const session of pendingSessions.values()) { + finishPendingSession(session, "error", "ADE account sign-in was cancelled."); + } + pendingSessions.clear(); + logger.info("account.signed_out"); + return toStatus(null); + }; + + const dispose = (): void => { + for (const session of pendingSessions.values()) { + clearTimeout(session.expiryTimer); + closeServer(session.server); + } + pendingSessions.clear(); + }; + + return { startLogin, pollLogin, getStatus, getAccessToken, signOut, dispose }; +} diff --git a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts new file mode 100644 index 000000000..cb613bc45 --- /dev/null +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts @@ -0,0 +1,83 @@ +import path from "node:path"; +import { createProjectSecretService } from "../../../../desktop/src/main/services/secrets/projectSecretService"; +import { EncryptedFileCredentialStore } from "../credentials/credentialStore"; +import { resolveMachineAdeLayout } from "../projects/machineLayout"; +import { + createAccountAuthService, + type AccountAuthService, + type AccountOAuthConfig, +} from "./accountAuthService"; + +const sharedServices = new Map(); +const configProjectRoots = new Map>(); + +function rootsFor(secretsDir: string): Set { + const key = path.resolve(secretsDir); + let roots = configProjectRoots.get(key); + if (!roots) { + roots = new Set(); + configProjectRoots.set(key, roots); + } + return roots; +} + +export function registerAccountConfigProjectRoot( + projectRoot: string, + secretsDir = resolveMachineAdeLayout().secretsDir, +): void { + const normalized = projectRoot.trim(); + if (!normalized) return; + rootsFor(secretsDir).add(path.resolve(normalized)); +} + +function readProjectSecret(projectRoot: string, name: string): string | null { + try { + return createProjectSecretService(projectRoot).get({ name }).value.trim() || null; + } catch { + return null; + } +} + +function resolveOAuthConfig(args: { + env: NodeJS.ProcessEnv; + projectRoots: Iterable; +}): AccountOAuthConfig { + let issuer: string | null = null; + let clientId: string | null = null; + for (const projectRoot of args.projectRoots) { + issuer ??= readProjectSecret(projectRoot, "CLERK_ISSUER"); + clientId ??= readProjectSecret(projectRoot, "CLERK_OAUTH_CLIENT_ID"); + if (issuer && clientId) break; + } + issuer ??= args.env.CLERK_ISSUER?.trim() || null; + clientId ??= args.env.CLERK_OAUTH_CLIENT_ID?.trim() || null; + return { issuer: issuer ?? "", clientId: clientId ?? "" }; +} + +export function getSharedAccountAuthService(args: { + secretsDir?: string; + projectRoots?: () => Iterable; + env?: NodeJS.ProcessEnv; + logger?: { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + }; +} = {}): AccountAuthService { + const secretsDir = path.resolve(args.secretsDir ?? resolveMachineAdeLayout().secretsDir); + for (const projectRoot of args.projectRoots?.() ?? []) { + registerAccountConfigProjectRoot(projectRoot, secretsDir); + } + const existing = sharedServices.get(secretsDir); + if (existing) return existing; + + const service = createAccountAuthService({ + credentialStore: new EncryptedFileCredentialStore({ secretsDir }), + getOAuthConfig: () => resolveOAuthConfig({ + env: args.env ?? process.env, + projectRoots: rootsFor(secretsDir), + }), + logger: args.logger, + }); + sharedServices.set(secretsDir, service); + return service; +} diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index e842626d7..8b4dafacb 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -1534,6 +1534,73 @@ describe("runtime AI actions", () => { }); }); +describe("runtime account actions", () => { + it("registers the account domain and delegates every allowlisted action", async () => { + const accountAuthService = { + startLogin: vi.fn(async () => ({ + sessionId: "account-session", + authorizeUrl: "https://clerk.example.test/oauth/authorize", + expiresAt: "2026-07-14T12:05:00.000Z", + })), + pollLogin: vi.fn(async (sessionId: string) => ({ + status: "pending" as const, + message: null, + authStatus: { signedIn: false, userId: null, email: null, name: null, expiresAt: null }, + sessionId, + })), + getStatus: vi.fn(() => ({ + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + })), + signOut: vi.fn(() => ({ + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + })), + getAccessToken: vi.fn(async () => "account-token"), + dispose: vi.fn(), + }; + const service = getAdeActionDomainServices({ + accountAuthService, + } as never).account as { + startLogin(): Promise; + pollLogin(args: { sessionId: string }): Promise; + status(): unknown; + signOut(): unknown; + getToken(): Promise; + }; + + expect(listAllowedAdeActionNames("account", service as unknown as Record)).toEqual([ + "getToken", + "pollLogin", + "signOut", + "startLogin", + "status", + ]); + await service.startLogin(); + await service.pollLogin({ sessionId: "account-session" }); + service.status(); + service.signOut(); + await expect(service.getToken()).resolves.toBe("account-token"); + + expect(accountAuthService.startLogin).toHaveBeenCalledTimes(1); + expect(accountAuthService.pollLogin).toHaveBeenCalledWith("account-session"); + expect(accountAuthService.getStatus).toHaveBeenCalledTimes(1); + expect(accountAuthService.signOut).toHaveBeenCalledTimes(1); + expect(accountAuthService.getAccessToken).toHaveBeenCalledTimes(1); + expect(isCtoOnlyAdeAction("account", "startLogin")).toBe(true); + expect(isCtoOnlyAdeAction("account", "pollLogin")).toBe(true); + expect(isCtoOnlyAdeAction("account", "signOut")).toBe(true); + expect(isCtoOnlyAdeAction("account", "getToken")).toBe(true); + expect(isCtoOnlyAdeAction("account", "status")).toBe(false); + }); +}); + describe("runtime GitHub actions", () => { it("allowlists github.detectRepo when the runtime service exposes it", () => { const runtime = { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 9d947007b..270c9bec4 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -74,8 +74,10 @@ import { parseLinearGraphQLInput } from "../cto/linearGraphQLInput"; import { launchAgentChatCli } from "../chat/agentChatCliLaunch"; import { deleteTerminalSessionWithRuntimeCleanup } from "../sessions/deleteTerminalSession"; import { createOrchestrationDomainService } from "../orchestration/orchestrationDomain"; +import { createAccountActionDomainService } from "../../../../../ade-cli/src/services/account/accountAuthService"; export const ADE_ACTION_DOMAIN_NAMES = [ + "account", "lane", "git", "diff", @@ -134,6 +136,7 @@ export type AdeActionRole = "cto" | "orchestrator" | "agent" | "external" | "eva * must be listed here. */ export const ADE_ACTION_CTO_ONLY: Partial> = { + account: ["startLogin", "pollLogin", "signOut", "getToken"], // The CTO's durable memory is injected into every CTO session; only the CTO // itself (and the user's own UI, which connects at cto role) may rewrite it. cto_memory: ["updateMemory"], @@ -180,6 +183,7 @@ export function callerHasRoleAtLeast(role: AdeActionRole | undefined | null, min } export const ADE_ACTION_ALLOWLIST: Partial> = { + account: ["startLogin", "pollLogin", "status", "signOut", "getToken"], lane: [ "adoptAttached", "archive", @@ -727,6 +731,32 @@ export type AdeActionInputContract = { }; const ADE_ACTION_INPUT_CONTRACTS: Partial>>> = { + account: { + startLogin: { + description: "Start the machine-owned ADE account OAuth PKCE login flow.", + input: "no input", + example: "ade login", + }, + pollLogin: { + description: "Poll an in-memory ADE account login session.", + input: "object { sessionId: string }", + example: "ade actions run account.pollLogin --input-json '{\"sessionId\":\"...\"}'", + }, + status: { + description: "Read the machine-owned ADE account sign-in status without exposing tokens.", + input: "no input", + example: "ade auth status --text", + }, + signOut: { + description: "Clear the machine-owned ADE account session.", + input: "no input", + example: "ade logout", + }, + getToken: { + description: "Internal bearer-token accessor for ADE remote services; refreshes near expiry.", + input: "no input", + }, + }, project_secret: { list: { description: "List ADE project secret names and metadata without revealing values.", @@ -3089,6 +3119,9 @@ export function getAdeActionDomainServices( ): Partial> { const automationsEnabled = areAutomationsEnabledForPackagedState(Boolean(runtime.isPackaged)); return { + account: runtime.accountAuthService + ? toService(createAccountActionDomainService(runtime.accountAuthService)) + : null, lane: toService(buildLaneDomainService(runtime)), git: toService(runtime.gitService), diff: toService(runtime.diffService), From 61c8aa898159cf6a6289b54795c68195dd43e584 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:22:34 -0400 Subject: [PATCH 2/5] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20fix=20t?= =?UTF-8?q?est-ade-cli=20flake=20+=20gate=20account.call=20to=20cto=20(Gre?= =?UTF-8?q?ptile=20P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - multiProjectRpcServer: move cachedRuntimeBuildHash into the handler factory (per-handler memoization) so tests mutating process.argv[1] recompute a fresh hash — fixes the order-dependent test-ade-cli build-hash failure. - multiProjectRpcServer: gate cto-only account actions (getToken/startLogin/ pollLogin/signOut) on account.call, resolving caller role from the initialize identity clamped to the ADE_DEFAULT_ROLE ceiling — mirrors the run_ade_action gate; status stays open. Closes the token-exfiltration bypass Greptile flagged. - runtimeRoles: hoist resolveSessionRole + canDefaultRoleServeRequestedRole out of adeRpcServer so both RPC servers share one role-ceiling source of truth. - cli: ade login/logout connect at cto (their startLogin/signOut are cto-only); wire plan.connectRole through executePlan. auth status (open) unchanged. - Add gate regression tests (status open for agent; getToken/startLogin/signOut rejected for agent + no-identity; getToken allowed for cto). Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/adeRpcServer.ts | 26 +-- apps/ade-cli/src/cli.ts | 24 ++- .../ade-cli/src/multiProjectRpcServer.test.ts | 176 ++++++++++++++++++ apps/ade-cli/src/multiProjectRpcServer.ts | 41 +++- apps/ade-cli/src/runtimeRoles.ts | 37 ++++ 5 files changed, 271 insertions(+), 33 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 5e125f870..d59743faf 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -66,7 +66,7 @@ import { usageClientSurfaceFromRpcName, } from "../../desktop/src/main/services/usage/usageStatsStore"; import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcRequest } from "./jsonrpc"; -import { normalizeAdeRuntimeRole } from "./runtimeRoles"; +import { normalizeAdeRuntimeRole, resolveSessionRole } from "./runtimeRoles"; import { getSharedModelPickerStore } from "./services/modelPickerStore"; import { resolveLaneCreateRemoteBase } from "./services/laneCreateRemoteBase"; import { BUILT_IN_BROWSER_ACTOR_CAPABILITY_PARAM } from "./services/builtInBrowser/desktopBridgeMethods"; @@ -2952,30 +2952,6 @@ function isLocalComputerUseAllowed(callerCtx: CallerContext): boolean { || callerCtx.role === "agent"; } -function canDefaultRoleServeRequestedRole( - defaultRole: SessionIdentity["role"] | null, - requestedRole: SessionIdentity["role"], -): boolean { - if (requestedRole === "external") return true; - if (!defaultRole) return false; - if (defaultRole === "cto") return true; - if (defaultRole === "orchestrator") return requestedRole !== "cto"; - if (defaultRole === "agent") return requestedRole === "agent"; - if (defaultRole === "evaluator") return requestedRole === "evaluator"; - return false; -} - -function resolveSessionRole( - defaultRole: SessionIdentity["role"] | null, - requestedRole: SessionIdentity["role"] | null, -): SessionIdentity["role"] { - if (!defaultRole) return "external"; - if (!requestedRole) return defaultRole; - return canDefaultRoleServeRequestedRole(defaultRole, requestedRole) - ? requestedRole - : defaultRole; -} - async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionState): Promise { const callerCtx = await resolveEffectiveCallerContext(runtime, session); const externalComputerUseAvailable = runtime.computerUseArtifactBrokerService diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 1c5ccebf6..0517f508d 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -234,6 +234,12 @@ type CliPlan = preferHeadless?: boolean; machineOnly?: boolean; machineAutoStart?: boolean; + /** + * Force the connection for this plan to assert a specific runtime role + * instead of the global CLI default. Used by `ade logout`, whose `signOut` + * account action is CTO-only, so it must connect as the machine operator. + */ + connectRole?: GlobalOptions["role"]; historyOperationId?: string; historyStatusFilter?: string; historyListFilters?: { @@ -11486,6 +11492,8 @@ function buildCliPlan( formatter: "account-auth", machineOnly: true, machineAutoStart: true, + // signOut is a CTO-only account action; connect as the machine operator. + connectRole: "cto", steps: [accountActionStep("result", "signOut")], }; } @@ -17564,8 +17572,13 @@ async function runAccountLogin( ): Promise<{ output: string; exitCode: number }> { let connection: CliConnection; try { + // `ade login` drives the CTO-only account actions (startLogin/pollLogin), + // so it connects as the machine operator at cto role. This also ensures the + // machine brain it attaches to runs at defaultRole cto (the runtime-role + // mismatch check respawns an under-privileged brain), so the account gate + // resolves the caller to cto. connection = await createConnection( - { ...options, headless: false }, + { ...options, headless: false, role: "cto" }, { autoRegisterProject: false, machineRuntimeOnly: true }, ); } catch (error) { @@ -17863,7 +17876,7 @@ async function executePlan( options: GlobalOptions, ): Promise { let connection: CliConnection; - const connectionOptions = + const baseConnectionOptions = plan.machineOnly ? { ...options, @@ -17873,6 +17886,13 @@ async function executePlan( : plan.preferHeadless && !options.requireSocket ? { ...options, headless: true } : options; + // A plan may force a specific runtime role for its connection (e.g. `ade + // logout`, whose signOut account action is CTO-only). Honor it so the caller + // asserts the operator role and the machine account gate resolves to cto. + const connectionOptions = + plan.connectRole + ? { ...baseConnectionOptions, role: plan.connectRole } + : baseConnectionOptions; try { connection = await createConnection(connectionOptions, { autoRegisterProject: shouldAutoRegisterProjectForPlan(plan), diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index fdfb94b97..c71cfbc6e 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -28,6 +28,48 @@ function createRegistry() { return { root, projectRoot, expectedProjectRoot, registry }; } +function makeAccountAuthServiceMock() { + return { + startLogin: vi.fn(async () => ({ + sessionId: "test-session", + authorizeUrl: "https://accounts.example/authorize", + expiresAt: "2026-05-10T00:05:00.000Z", + })), + pollLogin: vi.fn(async () => ({ + status: "pending" as const, + message: null, + authStatus: { + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + }, + })), + getStatus: vi.fn(() => ({ + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + })), + getAccessToken: vi.fn(async () => "test-access-token"), + signOut: vi.fn(() => ({ + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + })), + dispose: vi.fn(), + }; +} + +function restoreEnvVar(key: string, previous: string | undefined) { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; +} + function makeRuntime(label: string) { return { operationService: { @@ -102,6 +144,140 @@ describe("multi-project RPC server", () => { handler.dispose(); }); + it("allows the open account.status action for a non-cto caller", async () => { + const { registry } = createRegistry(); + const accountAuthService = makeAccountAuthServiceMock(); + const previousDefaultRole = process.env.ADE_DEFAULT_ROLE; + process.env.ADE_DEFAULT_ROLE = "agent"; + try { + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + accountAuthService, + }); + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: { identity: { role: "agent" } }, + }); + const result = await handler({ + jsonrpc: "2.0", + id: 2, + method: "account.call", + params: { action: "status" }, + }); + expect(result).toMatchObject({ domain: "account", action: "status" }); + expect(accountAuthService.getStatus).toHaveBeenCalledTimes(1); + handler.dispose(); + } finally { + restoreEnvVar("ADE_DEFAULT_ROLE", previousDefaultRole); + } + }); + + it("rejects cto-only account actions for a non-cto caller", async () => { + const { registry } = createRegistry(); + const accountAuthService = makeAccountAuthServiceMock(); + const previousDefaultRole = process.env.ADE_DEFAULT_ROLE; + process.env.ADE_DEFAULT_ROLE = "agent"; + try { + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + accountAuthService, + }); + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: { identity: { role: "agent" } }, + }); + for (const action of ["getToken", "startLogin", "signOut"]) { + await expect( + handler({ + jsonrpc: "2.0", + id: 2, + method: "account.call", + params: { action }, + }), + ).rejects.toThrow(/requires the cto role/); + } + expect(accountAuthService.getAccessToken).not.toHaveBeenCalled(); + expect(accountAuthService.startLogin).not.toHaveBeenCalled(); + expect(accountAuthService.signOut).not.toHaveBeenCalled(); + handler.dispose(); + } finally { + restoreEnvVar("ADE_DEFAULT_ROLE", previousDefaultRole); + } + }); + + it("rejects cto-only account actions when the caller sends no identity", async () => { + const { registry } = createRegistry(); + const accountAuthService = makeAccountAuthServiceMock(); + const previousDefaultRole = process.env.ADE_DEFAULT_ROLE; + delete process.env.ADE_DEFAULT_ROLE; + try { + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + accountAuthService, + }); + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + await expect( + handler({ + jsonrpc: "2.0", + id: 2, + method: "account.call", + params: { action: "getToken" }, + }), + ).rejects.toThrow(/requires the cto role/); + expect(accountAuthService.getAccessToken).not.toHaveBeenCalled(); + handler.dispose(); + } finally { + restoreEnvVar("ADE_DEFAULT_ROLE", previousDefaultRole); + } + }); + + it("allows cto-only account actions for a cto caller", async () => { + const { registry } = createRegistry(); + const accountAuthService = makeAccountAuthServiceMock(); + const previousDefaultRole = process.env.ADE_DEFAULT_ROLE; + process.env.ADE_DEFAULT_ROLE = "cto"; + try { + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + accountAuthService, + }); + await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: { identity: { role: "cto" } }, + }); + const result = await handler({ + jsonrpc: "2.0", + id: 2, + method: "account.call", + params: { action: "getToken" }, + }); + expect(result).toMatchObject({ + domain: "account", + action: "getToken", + result: "test-access-token", + }); + expect(accountAuthService.getAccessToken).toHaveBeenCalledTimes(1); + handler.dispose(); + } finally { + restoreEnvVar("ADE_DEFAULT_ROLE", previousDefaultRole); + } + }); + it("reports a build hash for manually-started CLI entrypoints", async () => { const { registry, root } = createRegistry(); const cliPath = path.join(root, "manual-cli.cjs"); diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 7a9ca29da..91b8ef5c6 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -34,7 +34,11 @@ import { import { ProjectScopeRegistry } from "./services/projects/projectScope"; import { PersonalChatScope } from "./services/personalChats/personalChatScope"; import { createHeadlessGitHubService } from "./headlessLinearServices"; -import { normalizeAdeRuntimeRole } from "./runtimeRoles"; +import { + callerHasRoleAtLeast, + isCtoOnlyAdeAction, +} from "../../desktop/src/main/services/adeActions/registry"; +import { normalizeAdeRuntimeRole, resolveSessionRole } from "./runtimeRoles"; import type { SyncPeerDeviceType } from "../../desktop/src/shared/types"; import { callAccountAction, @@ -435,11 +439,6 @@ function readLimit(value: unknown): number { : 100; } -// The entrypoint cannot change during the process lifetime, so hash it once and -// reuse the result. `undefined` means "not computed yet"; `null` is a cached -// failure (missing/unreadable entrypoint) that must not retry on every call. -let cachedRuntimeBuildHash: string | null | undefined; - export function createMultiProjectRpcRequestHandler( options: MultiProjectRpcHandlerOptions, ): JsonRpcHandler & { @@ -623,6 +622,13 @@ export function createMultiProjectRpcRequestHandler( return typeof value === "string" && value.trim() ? value.trim() : null; }; + // The entrypoint cannot change during the process lifetime, so hash it once + // and reuse the result. Kept per-handler (not module-scoped) so tests that + // mutate `process.argv[1]` between handlers each recompute a fresh hash. + // `undefined` means "not computed yet"; `null` is a cached failure + // (missing/unreadable entrypoint) that must not retry on every call. + let cachedRuntimeBuildHash: string | null | undefined; + const computeRuntimeBuildHash = (): string | null => { if (cachedRuntimeBuildHash !== undefined) return cachedRuntimeBuildHash; const entrypoint = process.argv[1]; @@ -742,6 +748,29 @@ export function createMultiProjectRpcRequestHandler( "account.call requires action.", ); } + // Gate credential-bearing account actions (getToken/startLogin/pollLogin/ + // signOut) to cto-role callers, mirroring the run_ade_action gate in + // adeRpcServer. The caller's requested role (from ade/initialize identity) + // is clamped to the brain's ADE_DEFAULT_ROLE ceiling, so a subagent that + // honestly asserts a non-cto role cannot reach these actions. `status` + // stays open to any role. + const identityRecord = + isRecord(initializedParams) && isRecord(initializedParams.identity) + ? (initializedParams.identity as Record) + : null; + const requestedRole = normalizeAdeRuntimeRole( + identityRecord ? identityRecord.role : null, + ); + const callerRole = resolveSessionRole( + normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE), + requestedRole, + ); + if (isCtoOnlyAdeAction("account", action) && !callerHasRoleAtLeast(callerRole, "cto")) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidRequest, + `account.${action} requires the cto role.`, + ); + } registerAccountProjects(); return await callAccountAction({ service: accountAuthService, diff --git a/apps/ade-cli/src/runtimeRoles.ts b/apps/ade-cli/src/runtimeRoles.ts index faa6885ad..ecb4e3a04 100644 --- a/apps/ade-cli/src/runtimeRoles.ts +++ b/apps/ade-cli/src/runtimeRoles.ts @@ -22,3 +22,40 @@ export function resolveAdeDefaultRole( ): AdeRuntimeRole { return normalizeAdeRuntimeRole(value) ?? fallback; } + +/** + * Whether a runtime spawned at `defaultRole` (its ADE_DEFAULT_ROLE ceiling) is + * allowed to serve a client that requested `requestedRole`. The default role is + * a ceiling: a caller may assert an equal-or-lower role, never a higher one. + * `external` is always serviceable (it is the lowest, unprivileged role). + */ +export function canDefaultRoleServeRequestedRole( + defaultRole: AdeRuntimeRole | null, + requestedRole: AdeRuntimeRole, +): boolean { + if (requestedRole === "external") return true; + if (!defaultRole) return false; + if (defaultRole === "cto") return true; + if (defaultRole === "orchestrator") return requestedRole !== "cto"; + if (defaultRole === "agent") return requestedRole === "agent"; + if (defaultRole === "evaluator") return requestedRole === "evaluator"; + return false; +} + +/** + * Resolve the effective session role from the runtime's default-role ceiling + * and the client's requested role. With no default role the caller is + * unprivileged (`external`); with no requested role the caller inherits the + * default; otherwise the requested role is honored only when the ceiling + * permits it, and clamped down to the default role when it does not. + */ +export function resolveSessionRole( + defaultRole: AdeRuntimeRole | null, + requestedRole: AdeRuntimeRole | null, +): AdeRuntimeRole { + if (!defaultRole) return "external"; + if (!requestedRole) return defaultRole; + return canDefaultRoleServeRequestedRole(defaultRole, requestedRole) + ? requestedRole + : defaultRole; +} From 8f2fc018da737100a2e5d7d86eae6eb5198f2eb1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:43:31 -0400 Subject: [PATCH 3/5] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20cancel?= =?UTF-8?q?=20pending=20login=20on=20max-wait=20timeout=20(Codex=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ade login --max-wait` timing out left the daemon's loopback login session alive, so a browser tab completing after the CLI gave up could still exchange the code and silently sign the machine in. Add a gated `cancelLogin` account action that closes just that pending loopback listener (no signOut, no authEpoch bump, no persisted-session wipe), and call it best-effort on the timeout path. - accountAuthService: cancelLogin(sessionId) → finishPendingSession + delete; wired through domain service, ACCOUNT_ACTION_NAMES, callAccountAction. - registry: cancelLogin is cto-only (ADE_ACTION_CTO_ONLY.account) and allowlisted. - cli: runAccountLogin timeout branch cancels the session before returning. - Tests: cancelLogin closes the loopback + leaves any signed-in account intact; gate rejects cancelLogin for non-cto callers. Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/cli.ts | 9 +++ .../ade-cli/src/multiProjectRpcServer.test.ts | 5 +- .../account/accountAuthService.test.ts | 77 +++++++++++++++++++ .../services/account/accountAuthService.ts | 24 +++++- .../src/main/services/adeActions/registry.ts | 4 +- 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 0517f508d..f72b8ea60 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -17632,6 +17632,15 @@ async function runAccountLogin( while (true) { if (Number.isFinite(deadlineMs) && Date.now() >= deadlineMs) { process.stderr.write("ADE account sign-in timed out.\n"); + // Cancel the pending loopback session so a browser tab that completes + // after this timeout cannot silently exchange the code and sign the + // machine in later. Best-effort: a cancel failure must not mask the + // timeout result, so swallow it and still report the timed-out status. + try { + await runAccountAction("cancelLogin", { sessionId }); + } catch { + // The pending session may already be gone; the timeout is what matters. + } const status = await runAccountAction("status"); return { output: formatOutput(status, options, "account-auth"), exitCode: 1 }; } diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index c71cfbc6e..faff6fe14 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -54,6 +54,7 @@ function makeAccountAuthServiceMock() { expiresAt: null, })), getAccessToken: vi.fn(async () => "test-access-token"), + cancelLogin: vi.fn(), signOut: vi.fn(() => ({ signedIn: false, userId: null, @@ -105,6 +106,7 @@ describe("multi-project RPC server", () => { expiresAt: null, })), getAccessToken: vi.fn(), + cancelLogin: vi.fn(), signOut: vi.fn(), dispose: vi.fn(), }; @@ -192,7 +194,7 @@ describe("multi-project RPC server", () => { method: "ade/initialize", params: { identity: { role: "agent" } }, }); - for (const action of ["getToken", "startLogin", "signOut"]) { + for (const action of ["getToken", "startLogin", "cancelLogin", "signOut"]) { await expect( handler({ jsonrpc: "2.0", @@ -204,6 +206,7 @@ describe("multi-project RPC server", () => { } expect(accountAuthService.getAccessToken).not.toHaveBeenCalled(); expect(accountAuthService.startLogin).not.toHaveBeenCalled(); + expect(accountAuthService.cancelLogin).not.toHaveBeenCalled(); expect(accountAuthService.signOut).not.toHaveBeenCalled(); handler.dispose(); } finally { diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index ae7e5877e..5991bd38c 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -187,6 +187,83 @@ describe("AccountAuthService OAuth PKCE login", () => { }); }); + it("cancelLogin closes the loopback listener so a late completion cannot sign in", async () => { + const store = new MemoryCredentialStore(); + const fetchImpl = vi.fn(); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + randomBytes: (size) => Buffer.alloc(size, 0x55), + randomUUID: () => "login-session-cancel", + fetchImpl, + }); + activeServices.push(service); + + const start = await service.startLogin(); + const authorizeUrl = new URL(start.authorizeUrl); + const redirectUri = authorizeUrl.searchParams.get("redirect_uri")!; + const state = authorizeUrl.searchParams.get("state")!; + + service.cancelLogin(start.sessionId); + + // The loopback listener is closed, so a browser tab that completes AFTER the + // CLI timed out can no longer reach it to exchange the authorization code. + await expect( + fetch(`${redirectUri}?code=late-code&state=${encodeURIComponent(state)}`), + ).rejects.toThrow(); + + // Cancel must not exchange a token, must not persist a session, and must + // leave the machine signed out. + expect(fetchImpl).not.toHaveBeenCalled(); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); + expect(service.getStatus()).toEqual({ + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + }); + + // The pending session is gone; polling reports it was not found rather than + // ever transitioning to signed_in. + await expect(service.pollLogin(start.sessionId)).resolves.toMatchObject({ + status: "error", + authStatus: { signedIn: false }, + }); + + // Idempotent: cancelling again, or an unknown id, is a harmless no-op. + expect(() => service.cancelLogin(start.sessionId)).not.toThrow(); + expect(() => service.cancelLogin("unknown-session")).not.toThrow(); + }); + + it("cancelLogin leaves an already signed-in account untouched", async () => { + const store = new MemoryCredentialStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ + expiresAt: "2026-07-15T12:00:00.000Z", + }))); + const fetchImpl = vi.fn(); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + randomBytes: (size) => Buffer.alloc(size, 0x66), + randomUUID: () => "login-session-cancel-existing", + fetchImpl, + }); + activeServices.push(service); + + const before = store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY); + const start = await service.startLogin(); + service.cancelLogin(start.sessionId); + + // Cancelling a pending login must NOT sign out the existing account: the + // persisted session is byte-for-byte intact and still reported signed in. + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(before); + expect(service.getStatus()).toMatchObject({ signedIn: true, email: "old@example.com" }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("rejects a state mismatch without exchanging or resolving the pending login", async () => { const store = new MemoryCredentialStore(); const fetchImpl = vi.fn(); diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts index 995f83030..8e0d29c0a 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -102,6 +102,7 @@ export type AccountAuthService = { pollLogin(sessionId: string): Promise; getStatus(): AccountAuthStatus; getAccessToken(): Promise; + cancelLogin(sessionId: string): void; signOut(): AccountAuthStatus; dispose(): void; }; @@ -110,6 +111,7 @@ export type AccountActionDomainService = { startLogin(): Promise; pollLogin(args: { sessionId?: string }): Promise; status(): AccountAuthStatus; + cancelLogin(args: { sessionId?: string }): void; signOut(): AccountAuthStatus; getToken(): Promise; }; @@ -118,6 +120,7 @@ export const ACCOUNT_ACTION_NAMES = [ "startLogin", "pollLogin", "status", + "cancelLogin", "signOut", "getToken", ] as const; @@ -311,6 +314,7 @@ export function createAccountActionDomainService( startLogin: () => service.startLogin(), pollLogin: (args) => service.pollLogin(readNonEmptyString(args?.sessionId) ?? ""), status: () => service.getStatus(), + cancelLogin: (args) => service.cancelLogin(readNonEmptyString(args?.sessionId) ?? ""), signOut: () => service.signOut(), getToken: () => service.getAccessToken(), }; @@ -339,6 +343,9 @@ export async function callAccountAction(args: { result = await domain.startLogin(); } else if (action === "status") { result = domain.status(); + } else if (action === "cancelLogin") { + domain.cancelLogin({ sessionId: readNonEmptyString(actionArgs.sessionId) ?? undefined }); + result = domain.status(); } else if (action === "signOut") { result = domain.signOut(); } else { @@ -655,6 +662,21 @@ export function createAccountAuthService(args: { return refreshed.accessToken; }; + // Cancel a single pending login (e.g. `ade login --max-wait` timed out) without + // signing the machine out. This closes the loopback listener so a browser tab + // that completes AFTER the CLI gave up can no longer exchange the code and + // silently persist a session. Unlike signOut it MUST NOT bump authEpoch or wipe + // the persisted account. Idempotent: a no-op if the session is unknown or done. + const cancelLogin = (sessionId: string): void => { + const normalizedSessionId = sessionId.trim(); + if (!normalizedSessionId) return; + const session = pendingSessions.get(normalizedSessionId); + if (!session) return; + finishPendingSession(session, "error", "ADE account sign-in was cancelled."); + pendingSessions.delete(normalizedSessionId); + logger.info("account.login_cancelled"); + }; + const signOut = (): AccountAuthStatus => { authEpoch += 1; persistSession(null); @@ -674,5 +696,5 @@ export function createAccountAuthService(args: { pendingSessions.clear(); }; - return { startLogin, pollLogin, getStatus, getAccessToken, signOut, dispose }; + return { startLogin, pollLogin, getStatus, getAccessToken, cancelLogin, signOut, dispose }; } diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 270c9bec4..e2a049ca7 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -136,7 +136,7 @@ export type AdeActionRole = "cto" | "orchestrator" | "agent" | "external" | "eva * must be listed here. */ export const ADE_ACTION_CTO_ONLY: Partial> = { - account: ["startLogin", "pollLogin", "signOut", "getToken"], + account: ["startLogin", "pollLogin", "cancelLogin", "signOut", "getToken"], // The CTO's durable memory is injected into every CTO session; only the CTO // itself (and the user's own UI, which connects at cto role) may rewrite it. cto_memory: ["updateMemory"], @@ -183,7 +183,7 @@ export function callerHasRoleAtLeast(role: AdeActionRole | undefined | null, min } export const ADE_ACTION_ALLOWLIST: Partial> = { - account: ["startLogin", "pollLogin", "status", "signOut", "getToken"], + account: ["startLogin", "pollLogin", "status", "cancelLogin", "signOut", "getToken"], lane: [ "adoptAttached", "archive", From 2ef21cbc9f8a38cea8ebf53fb07d04cd64796210 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:13:53 -0400 Subject: [PATCH 4/5] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20sweep?= =?UTF-8?q?=20login-flow=20review=20findings=20+=20fix=20registry=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: update registry.test.ts allowlist assertion for the new cancelLogin action. Review sweep (Codex + CodeRabbit, login-flow class): - cli: cancel the pending loopback session on EVERY unsuccessful login exit (timeout/poll-error/throw) via a tracked sessionId cleared on success, not just the timeout path (CodeRabbit Major, security). - cli + multiProjectRpcServer: pass the invoking project root to startLogin and register it as an account-config source (no projects.add) so `ade login` can read that project's CLERK_* secrets (Codex P2). - sharedAccountAuthService: resolve CLERK issuer+clientId as an atomic pair per project root, never cross-mixing halves from different projects (CodeRabbit Major, functional). New atomic-pair tests. - accountAuthService: require https for CLERK_ISSUER except loopback/local-dev hosts, so a misconfigured http issuer can't leak the code/tokens in plaintext (CodeRabbit Major, security). New issuer-scheme tests. - registry: add cancelLogin input-contract doc entry (CodeRabbit Minor). Skipped: CodeRabbit "duplicate const requests" (cli.test.ts:2692) — false positive, the two declarations are in separate posixIt scopes. Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/cli.ts | 36 +++++++--- apps/ade-cli/src/multiProjectRpcServer.ts | 12 ++++ .../account/accountAuthService.test.ts | 33 ++++++++++ .../services/account/accountAuthService.ts | 19 ++++++ .../account/sharedAccountAuthService.test.ts | 65 +++++++++++++++++++ .../account/sharedAccountAuthService.ts | 23 ++++--- .../main/services/adeActions/registry.test.ts | 6 ++ .../src/main/services/adeActions/registry.ts | 5 ++ 8 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index f72b8ea60..6239223db 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -17603,14 +17603,27 @@ async function runAccountLogin( return result; }; + // Register the invoking project's root so the daemon can read this project's + // CLERK_* secrets when it starts the login. `ade login` connects with + // autoRegisterProject:false, so the config root is otherwise never registered + // and startLogin reports "unconfigured" even when the project has the secrets. + const { projectRoot } = resolveRoots(options); + + // Track the pending loopback session so EVERY non-success exit (timeout, poll + // error, thrown error) cancels it in `finally`; a browser tab that completes + // after the CLI gave up must not silently exchange the code and sign the + // machine in later. Cleared on success so a completed login is never cancelled. + let pendingSessionId: string | null = null; + try { - const start = await runAccountAction("startLogin"); + const start = await runAccountAction("startLogin", { projectRoot }); const sessionId = asString(start.sessionId); const authorizeUrl = asString(start.authorizeUrl); const expiresAt = asString(start.expiresAt); if (!sessionId || !authorizeUrl) { throw new CliExecutionError("ADE account login did not start.", { start }); } + pendingSessionId = sessionId; const openResult = openUrlViaOs(authorizeUrl); process.stderr.write( @@ -17632,15 +17645,6 @@ async function runAccountLogin( while (true) { if (Number.isFinite(deadlineMs) && Date.now() >= deadlineMs) { process.stderr.write("ADE account sign-in timed out.\n"); - // Cancel the pending loopback session so a browser tab that completes - // after this timeout cannot silently exchange the code and sign the - // machine in later. Best-effort: a cancel failure must not mask the - // timeout result, so swallow it and still report the timed-out status. - try { - await runAccountAction("cancelLogin", { sessionId }); - } catch { - // The pending session may already be gone; the timeout is what matters. - } const status = await runAccountAction("status"); return { output: formatOutput(status, options, "account-auth"), exitCode: 1 }; } @@ -17658,6 +17662,8 @@ async function runAccountLogin( ?? asString(authStatus.userId) ?? "ADE account"; process.stderr.write(`Signed in as ${identity}\n`); + // Success: do not cancel the session we just completed. + pendingSessionId = null; return { output: formatOutput(authStatus, options, "account-auth"), exitCode: 0 }; } if (pollStatus === "pending") continue; @@ -17666,6 +17672,16 @@ async function runAccountLogin( return { output: formatOutput(authStatus, options, "account-auth"), exitCode: 1 }; } } finally { + // Cancel the still-live loopback session on any non-success exit (timeout, + // poll error, thrown error). Best-effort and idempotent: a cancel failure + // must not mask the real result. Cleared to null on success above. + if (pendingSessionId) { + try { + await runAccountAction("cancelLogin", { sessionId: pendingSessionId }); + } catch { + // The pending session may already be gone; the exit result is what matters. + } + } await connection.close(); } } diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 91b8ef5c6..6817c73f4 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -771,6 +771,18 @@ export function createMultiProjectRpcRequestHandler( `account.${action} requires the cto role.`, ); } + // `ade login` connects with autoRegisterProject:false, so the invoking + // project is never in projects.json. Register its root as an account-config + // source (WITHOUT projects.add) so startLogin can read that project's + // CLERK_* secrets; this preserves the "login does no projects.add" invariant. + if (action === "startLogin") { + const startArgs = isRecord(params.args) ? params.args : {}; + const startProjectRoot = + typeof startArgs.projectRoot === "string" ? startArgs.projectRoot.trim() : ""; + if (startProjectRoot) { + registerAccountConfigProjectRoot(startProjectRoot); + } + } registerAccountProjects(); return await callAccountAction({ service: accountAuthService, diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index 5991bd38c..97462b1f2 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -72,6 +72,39 @@ afterEach(() => { for (const service of activeServices.splice(0)) service.dispose(); }); +describe("AccountAuthService CLERK_ISSUER scheme enforcement", () => { + function serviceForIssuer(issuer: string): AccountAuthService { + const service = createAccountAuthService({ + credentialStore: new MemoryCredentialStore(), + getOAuthConfig: () => ({ issuer, clientId: "client-public" }), + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + randomBytes: (size) => Buffer.alloc(size, 0x11), + randomUUID: () => "login-session-scheme", + fetchImpl: vi.fn(), + }); + activeServices.push(service); + return service; + } + + it("rejects a non-loopback http issuer (plaintext would leak the code/token)", async () => { + await expect(serviceForIssuer("http://clerk.example.test").startLogin()).rejects.toThrow( + /https/i, + ); + }); + + it("accepts an https issuer", async () => { + const start = await serviceForIssuer("https://clerk.example.test").startLogin(); + expect(new URL(start.authorizeUrl).protocol).toBe("https:"); + }); + + it("accepts an http://localhost issuer for local development", async () => { + const start = await serviceForIssuer("http://localhost:3000").startLogin(); + const authorizeUrl = new URL(start.authorizeUrl); + expect(authorizeUrl.protocol).toBe("http:"); + expect(authorizeUrl.host).toBe("localhost:3000"); + }); +}); + describe("AccountAuthService OAuth PKCE login", () => { it("derives the S256 challenge and constructs the exact loopback authorize URL", async () => { const verifierBytes = Buffer.alloc(32, 0x11); diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts index 8e0d29c0a..6d845a4f0 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -144,6 +144,19 @@ function readPositiveNumber(value: unknown): number | null { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function isLoopbackIssuerHost(hostname: string): boolean { + // `new URL("http://[::1]/").hostname` returns "[::1]" (brackets included), so + // accept both bracketed and bare IPv6 loopback forms. + const host = hostname.toLowerCase(); + return ( + host === "localhost" || + host === "127.0.0.1" || + host === "::1" || + host === "[::1]" || + host.endsWith(".localhost") + ); +} + function normalizeOAuthConfig(config: AccountOAuthConfig): AccountOAuthConfig { const issuer = config.issuer.trim().replace(/\/+$/, ""); const clientId = config.clientId.trim(); @@ -161,6 +174,12 @@ function normalizeOAuthConfig(config: AccountOAuthConfig): AccountOAuthConfig { if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { throw new Error("CLERK_ISSUER must be a valid HTTP(S) URL."); } + // Plaintext http exposes the authorization code / bearer tokens on the wire, + // so only permit it against a loopback/local-dev issuer; everything else must + // use https. + if (parsed.protocol === "http:" && !isLoopbackIssuerHost(parsed.hostname)) { + throw new Error("CLERK_ISSUER must use https (http is only allowed for localhost)."); + } return { issuer, clientId }; } diff --git a/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts b/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts new file mode 100644 index 000000000..61d9b64b3 --- /dev/null +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createProjectSecretService } from "../../../../desktop/src/main/services/secrets/projectSecretService"; +import type { AccountAuthService } from "./accountAuthService"; +import { getSharedAccountAuthService } from "./sharedAccountAuthService"; + +const tempPaths: string[] = []; +const activeServices: AccountAuthService[] = []; + +function makeProjectRoot(secrets: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-shared-account-")); + tempPaths.push(root); + const service = createProjectSecretService(root); + for (const [name, value] of Object.entries(secrets)) { + service.set({ name, value }); + } + return root; +} + +function uniqueSecretsDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-shared-account-store-")); + tempPaths.push(dir); + return dir; +} + +afterEach(() => { + for (const service of activeServices.splice(0)) service.dispose(); + for (const target of tempPaths.splice(0)) fs.rmSync(target, { recursive: true, force: true }); +}); + +describe("getSharedAccountAuthService resolves CLERK OAuth config as an atomic pair", () => { + it("does not combine an issuer from one project with a clientId from another", async () => { + const issuerRoot = makeProjectRoot({ CLERK_ISSUER: "https://issuer-a.example.test" }); + const clientRoot = makeProjectRoot({ CLERK_OAUTH_CLIENT_ID: "client-from-b" }); + const service = getSharedAccountAuthService({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [issuerRoot, clientRoot], + env: {} as NodeJS.ProcessEnv, + }); + activeServices.push(service); + + // The first root yields only an issuer; its clientId half must come from that + // same root or env — never cross-mixed from clientRoot. With no env fallback + // the pair is incomplete, so login is reported unconfigured rather than + // silently pairing issuer-a with client-from-b. + await expect(service.startLogin()).rejects.toThrow(/not configured/i); + }); + + it("fills the missing half of the winning root's pair from env", async () => { + const issuerRoot = makeProjectRoot({ CLERK_ISSUER: "https://issuer-a.example.test" }); + const service = getSharedAccountAuthService({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [issuerRoot], + env: { CLERK_OAUTH_CLIENT_ID: "client-from-env" } as NodeJS.ProcessEnv, + }); + activeServices.push(service); + + const start = await service.startLogin(); + const authorizeUrl = new URL(start.authorizeUrl); + expect(authorizeUrl.origin).toBe("https://issuer-a.example.test"); + expect(authorizeUrl.searchParams.get("client_id")).toBe("client-from-env"); + }); +}); diff --git a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts index cb613bc45..9871dab47 100644 --- a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts @@ -42,16 +42,23 @@ function resolveOAuthConfig(args: { env: NodeJS.ProcessEnv; projectRoots: Iterable; }): AccountOAuthConfig { - let issuer: string | null = null; - let clientId: string | null = null; + // Resolve issuer+clientId as an ATOMIC pair. The first project root that + // yields either half wins the pair (filling only the missing half from env), + // so we never combine an issuer from project A with a clientId from project B. for (const projectRoot of args.projectRoots) { - issuer ??= readProjectSecret(projectRoot, "CLERK_ISSUER"); - clientId ??= readProjectSecret(projectRoot, "CLERK_OAUTH_CLIENT_ID"); - if (issuer && clientId) break; + const issuer = readProjectSecret(projectRoot, "CLERK_ISSUER"); + const clientId = readProjectSecret(projectRoot, "CLERK_OAUTH_CLIENT_ID"); + if (issuer || clientId) { + return { + issuer: issuer ?? args.env.CLERK_ISSUER?.trim() ?? "", + clientId: clientId ?? args.env.CLERK_OAUTH_CLIENT_ID?.trim() ?? "", + }; + } } - issuer ??= args.env.CLERK_ISSUER?.trim() || null; - clientId ??= args.env.CLERK_OAUTH_CLIENT_ID?.trim() || null; - return { issuer: issuer ?? "", clientId: clientId ?? "" }; + return { + issuer: args.env.CLERK_ISSUER?.trim() || "", + clientId: args.env.CLERK_OAUTH_CLIENT_ID?.trim() || "", + }; } export function getSharedAccountAuthService(args: { diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 8b4dafacb..5e55ded5c 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -1555,6 +1555,7 @@ describe("runtime account actions", () => { name: null, expiresAt: null, })), + cancelLogin: vi.fn(), signOut: vi.fn(() => ({ signedIn: false, userId: null, @@ -1571,11 +1572,13 @@ describe("runtime account actions", () => { startLogin(): Promise; pollLogin(args: { sessionId: string }): Promise; status(): unknown; + cancelLogin(args: { sessionId?: string }): void; signOut(): unknown; getToken(): Promise; }; expect(listAllowedAdeActionNames("account", service as unknown as Record)).toEqual([ + "cancelLogin", "getToken", "pollLogin", "signOut", @@ -1585,16 +1588,19 @@ describe("runtime account actions", () => { await service.startLogin(); await service.pollLogin({ sessionId: "account-session" }); service.status(); + service.cancelLogin({ sessionId: "account-session" }); service.signOut(); await expect(service.getToken()).resolves.toBe("account-token"); expect(accountAuthService.startLogin).toHaveBeenCalledTimes(1); expect(accountAuthService.pollLogin).toHaveBeenCalledWith("account-session"); expect(accountAuthService.getStatus).toHaveBeenCalledTimes(1); + expect(accountAuthService.cancelLogin).toHaveBeenCalledWith("account-session"); expect(accountAuthService.signOut).toHaveBeenCalledTimes(1); expect(accountAuthService.getAccessToken).toHaveBeenCalledTimes(1); expect(isCtoOnlyAdeAction("account", "startLogin")).toBe(true); expect(isCtoOnlyAdeAction("account", "pollLogin")).toBe(true); + expect(isCtoOnlyAdeAction("account", "cancelLogin")).toBe(true); expect(isCtoOnlyAdeAction("account", "signOut")).toBe(true); expect(isCtoOnlyAdeAction("account", "getToken")).toBe(true); expect(isCtoOnlyAdeAction("account", "status")).toBe(false); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index e2a049ca7..3ce5fee07 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -747,6 +747,11 @@ const ADE_ACTION_INPUT_CONTRACTS: Partial Date: Tue, 14 Jul 2026 20:31:04 -0400 Subject: [PATCH 5/5] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20priorit?= =?UTF-8?q?ize=20invoking=20project=20for=20account=20OAuth=20config=20(Co?= =?UTF-8?q?dex=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes iteration 3's project-root wiring: registerAccountConfigProjectRoot now takes a { prioritize } option that re-seats the root at the FRONT of the ordered config set, and `ade login`'s startLogin path uses it. resolveOAuthConfig walks the set in insertion order, so without this a project registered earlier (via registerAccountProjects) would shadow the invoking project's CLERK_* secrets in a multi-project brain — building the authorize URL with the wrong Clerk app. New prioritization test proves the invoking root wins over an earlier-registered one. Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/multiProjectRpcServer.ts | 6 +++- .../account/sharedAccountAuthService.test.ts | 33 ++++++++++++++++++- .../account/sharedAccountAuthService.ts | 20 ++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 6817c73f4..e9168cd66 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -780,7 +780,11 @@ export function createMultiProjectRpcRequestHandler( const startProjectRoot = typeof startArgs.projectRoot === "string" ? startArgs.projectRoot.trim() : ""; if (startProjectRoot) { - registerAccountConfigProjectRoot(startProjectRoot); + // Prioritize the invoking project's root so its CLERK_* secrets win + // over any project registered earlier in a multi-project brain. + registerAccountConfigProjectRoot(startProjectRoot, undefined, { + prioritize: true, + }); } } registerAccountProjects(); diff --git a/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts b/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts index 61d9b64b3..718613d58 100644 --- a/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts @@ -4,7 +4,10 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createProjectSecretService } from "../../../../desktop/src/main/services/secrets/projectSecretService"; import type { AccountAuthService } from "./accountAuthService"; -import { getSharedAccountAuthService } from "./sharedAccountAuthService"; +import { + getSharedAccountAuthService, + registerAccountConfigProjectRoot, +} from "./sharedAccountAuthService"; const tempPaths: string[] = []; const activeServices: AccountAuthService[] = []; @@ -62,4 +65,32 @@ describe("getSharedAccountAuthService resolves CLERK OAuth config as an atomic p expect(authorizeUrl.origin).toBe("https://issuer-a.example.test"); expect(authorizeUrl.searchParams.get("client_id")).toBe("client-from-env"); }); + + it("prioritizes the invoking project root over a project registered earlier", async () => { + const otherRoot = makeProjectRoot({ + CLERK_ISSUER: "https://other.example.test", + CLERK_OAUTH_CLIENT_ID: "other-client", + }); + const invokingRoot = makeProjectRoot({ + CLERK_ISSUER: "https://invoking.example.test", + CLERK_OAUTH_CLIENT_ID: "invoking-client", + }); + const secretsDir = uniqueSecretsDir(); + // `other` is registered first (as registerAccountProjects would for the + // machine's registered projects); `ade login` then prioritizes its invoking + // root so that project's Clerk app wins, not `other`'s. + registerAccountConfigProjectRoot(otherRoot, secretsDir); + registerAccountConfigProjectRoot(invokingRoot, secretsDir, { prioritize: true }); + const service = getSharedAccountAuthService({ + secretsDir, + projectRoots: () => [], + env: {} as NodeJS.ProcessEnv, + }); + activeServices.push(service); + + const start = await service.startLogin(); + const authorizeUrl = new URL(start.authorizeUrl); + expect(authorizeUrl.origin).toBe("https://invoking.example.test"); + expect(authorizeUrl.searchParams.get("client_id")).toBe("invoking-client"); + }); }); diff --git a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts index 9871dab47..f22aee119 100644 --- a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts @@ -24,10 +24,28 @@ function rootsFor(secretsDir: string): Set { export function registerAccountConfigProjectRoot( projectRoot: string, secretsDir = resolveMachineAdeLayout().secretsDir, + options: { prioritize?: boolean } = {}, ): void { const normalized = projectRoot.trim(); if (!normalized) return; - rootsFor(secretsDir).add(path.resolve(normalized)); + const resolved = path.resolve(normalized); + const key = path.resolve(secretsDir); + const roots = rootsFor(secretsDir); + if (!options.prioritize) { + roots.add(resolved); + return; + } + // `ade login` passes its invoking project root here so that project's CLERK_* + // secrets win OAuth config resolution. resolveOAuthConfig walks the set in + // insertion order, so re-seat this root at the FRONT — otherwise a project + // registered earlier (e.g. via registerAccountProjects) would shadow it in a + // multi-project brain. + if (roots.size === 0 || [...roots][0] === resolved) { + roots.add(resolved); + return; + } + const rest = [...roots].filter((root) => root !== resolved); + configProjectRoots.set(key, new Set([resolved, ...rest])); } function readProjectSecret(projectRoot: string, name: string): string | null {