diff --git a/apps/ade-cli/src/bootstrap.test.ts b/apps/ade-cli/src/bootstrap.test.ts index 105fbed03..6d76fddb9 100644 --- a/apps/ade-cli/src/bootstrap.test.ts +++ b/apps/ade-cli/src/bootstrap.test.ts @@ -1,6 +1,25 @@ import { describe, expect, it, vi } from "vitest"; import { createEventBuffer, type BufferedEvent } from "./eventBuffer"; import { createPrEventFanout } from "./prEventFanout"; +import { isSourceCheckoutRuntimeModule } from "./runtimePackaging"; + +describe("isSourceCheckoutRuntimeModule", () => { + it.each([ + "/Users/developer/ADE/apps/ade-cli/src/bootstrap.ts", + "/Users/developer/ADE/apps/ade-cli/dist/cli.cjs", + "/Users/developer/ADE/apps/ade-cli/dist/bootstrap.cjs", + "/Users/developer/ADE/apps/desktop/dist/main/main.cjs", + ])("classifies a source-checkout module as development: %s", (modulePath) => { + expect(isSourceCheckoutRuntimeModule(modulePath)).toBe(true); + }); + + it.each([ + "/Applications/ADE.app/Contents/Resources/app.asar/dist/main/main.cjs", + "/Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs", + ])("classifies a packaged module as packaged: %s", (modulePath) => { + expect(isSourceCheckoutRuntimeModule(modulePath)).toBe(false); + }); +}); describe("createPrEventFanout", () => { const event = { diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index be1c73425..680e4a41e 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import * as nodePty from "node-pty"; +import { isSourceCheckoutRuntimeModule } from "./runtimePackaging"; import { createFileLogger, type Logger } from "../../desktop/src/main/services/logging/logger"; import { classifySqliteOpenError, openKvDb, type AdeDb } from "../../desktop/src/main/services/state/kvDb"; import { @@ -307,13 +308,16 @@ export function ensureAdePaths(projectRoot: string): AdeRuntimePaths { }; } -function isSourceCheckoutRuntimeModule(modulePath: string): boolean { - return /[/\\]apps[/\\]ade-cli[/\\](?:src|dist)[/\\]bootstrap\.(?:ts|js|cjs)$/i.test(modulePath); -} - const currentModulePath = typeof __filename === "string" ? __filename : fileURLToPath(import.meta.url); +if ( + !isSourceCheckoutRuntimeModule(currentModulePath) + && process.env.ADE_RUNTIME_PACKAGED === undefined +) { + process.env.ADE_RUNTIME_PACKAGED = "1"; +} + function automationsEnabledForHeadlessRuntime(): boolean { const override = readAutomationsEnvOverride(process.env); if (override !== null) return override; diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 600abed68..37cf43a83 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -35,6 +35,10 @@ import { summarizeExecution, unwrapToolResult, } from "./cli"; +import { + DEVELOPMENT_ADE_CLERK_ISSUER, + DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, +} from "../../desktop/src/shared/accountDirectory"; import { generateRpcAuthToken } from "./rpcAuth"; import { JsonRpcClient } from "./tuiClient/jsonRpcClient"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; @@ -344,6 +348,41 @@ describe("ADE CLI", () => { .toBe("loopback"); }); + it("treats rejected packaged development env credentials as absent when selecting login mode", () => { + const developmentAccessToken = [ + Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"), + Buffer.from(JSON.stringify({ + iss: DEVELOPMENT_ADE_CLERK_ISSUER, + sub: "development-user", + exp: Math.floor(Date.now() / 1000) + 3_600, + })).toString("base64url"), + "signature", + ].join("."); + const developmentRefreshToken = `ade_account_v1.${Buffer.from(JSON.stringify({ + version: 1, + refreshToken: "development-refresh-token", + issuer: DEVELOPMENT_ADE_CLERK_ISSUER, + clientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }), "utf8").toString("base64url")}`; + + for (const credential of [developmentAccessToken, developmentRefreshToken]) { + const env = { + ADE_RUNTIME_PACKAGED: "1", + ADE_ACCOUNT_TOKEN: credential, + DISPLAY: ":0", + } as NodeJS.ProcessEnv; + expect(detectAccountLoginMode({ env, platform: "linux" })).toBe("loopback"); + expect(detectAccountLoginMode({ + env: { ...env, SSH_CONNECTION: "host details" }, + platform: "linux", + })).toBe("device"); + expect(detectAccountLoginMode({ + env: { ...env, ADE_ALLOW_DEVELOPMENT_CLERK: "1" }, + platform: "linux", + })).toBe("env-token"); + } + }); + it("formats account auth sources and durable-token provisioning guidance", () => { expect(formatOutput({ signedIn: true, diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index d40e2eb30..123d3fadb 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -102,6 +102,7 @@ import type { AdeRuntime } from "./bootstrap"; import { reseedBundledAdeSkillsForCli } from "./bootstrap"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; import type { AccountMachinePublisherService } from "./services/account/accountMachinePublisherService"; +import { shouldRejectDevelopmentEnvCredential } from "./services/account/accountAuthService"; import { DEFAULT_SYNC_HOST_PORT } from "./services/sync/syncProtocol"; import { runAdeCodeRemote, @@ -18213,7 +18214,13 @@ export function detectAccountLoginMode(args: { } = {}): AccountLoginMode { const env = args.env ?? process.env; if (args.explicitHeadless) return "device"; - if (env.ADE_ACCOUNT_TOKEN?.trim()) return "env-token"; + const envCredential = env.ADE_ACCOUNT_TOKEN?.trim(); + if ( + envCredential + && !shouldRejectDevelopmentEnvCredential(env, envCredential) + ) { + return "env-token"; + } if (args.browserOpenFailed) return "device"; if (env.SSH_TTY?.trim() || env.SSH_CONNECTION?.trim() || env.SSH_CLIENT?.trim()) { return "device"; diff --git a/apps/ade-cli/src/runtimePackaging.ts b/apps/ade-cli/src/runtimePackaging.ts new file mode 100644 index 000000000..602126d00 --- /dev/null +++ b/apps/ade-cli/src/runtimePackaging.ts @@ -0,0 +1,5 @@ +export function isSourceCheckoutRuntimeModule(modulePath: string): boolean { + return /(?:^|[/\\])apps[/\\](?:ade-cli|desktop)[/\\](?:src|dist)[/\\]/i.test( + modulePath, + ); +} diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index 07ea0f9f0..7b5608168 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -1,5 +1,12 @@ import { createHash } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_ADE_ACCOUNT_DIRECTORY_URL, + DEFAULT_ADE_CLERK_ISSUER, + DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + DEVELOPMENT_ADE_CLERK_ISSUER, + DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, +} from "../../../../desktop/src/shared/accountDirectory"; import type { SyncCredentialStore } from "../credentials/credentialStore"; import { ACCOUNT_SESSION_CREDENTIAL_KEY, @@ -140,6 +147,259 @@ describe("AccountAuthService persisted session notifications", () => { }); }); +describe("AccountAuthService packaged development-session policy", () => { + const now = () => Date.parse("2026-07-14T12:00:00.000Z"); + const productionAccessToken = () => jwt({ + iss: DEFAULT_ADE_CLERK_ISSUER, + sub: "user_old", + exp: now() / 1000 + 3_600, + }); + const developmentAccessToken = () => jwt({ + iss: DEVELOPMENT_ADE_CLERK_ISSUER, + sub: "user_old", + exp: now() / 1000 + 3_600, + }); + + function serviceWithStoredSession(args: { + env: NodeJS.ProcessEnv; + session: AccountSessionRecord; + }): { + service: AccountAuthService; + store: MemoryCredentialStore; + fetchImpl: ReturnType; + } { + const store = new MemoryCredentialStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(args.session)); + const fetchImpl = vi.fn(); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }), + env: args.env, + now, + fetchImpl, + }); + activeServices.push(service); + return { service, store, fetchImpl }; + } + + it("invalidates a packaged stored development-issuer session before status, token return, or refresh", async () => { + const { service, store, fetchImpl } = serviceWithStoredSession({ + env: { ADE_RUNTIME_PACKAGED: "1" } as NodeJS.ProcessEnv, + session: storedSession({ + accessToken: productionAccessToken(), + oauthConfig: { + issuer: `${DEVELOPMENT_ADE_CLERK_ISSUER}./`, + clientId: "custom-client", + }, + }), + }); + + expect(service.getStatus()).toMatchObject({ + signedIn: false, + userId: null, + source: null, + }); + expect(service.getSessionReadState()).toBe("missing"); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); + await expect(service.getAccessToken()).rejects.toThrow(/not signed in/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "development OAuth client id", + session: storedSession({ + accessToken: productionAccessToken(), + oauthConfig: { + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }, + }), + }, + { + name: "development access-token issuer claim", + session: storedSession({ + accessToken: developmentAccessToken(), + oauthConfig: { + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }, + }), + }, + ])("also invalidates a packaged session identified by $name", async ({ session }) => { + const { service, store, fetchImpl } = serviceWithStoredSession({ + env: { ADE_RUNTIME_PACKAGED: "1" } as NodeJS.ProcessEnv, + session, + }); + + expect(service.getStatus().signedIn).toBe(false); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); + await expect(service.getAccessToken()).rejects.toThrow(/not signed in/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("keeps a source-checkout development session unchanged", async () => { + const session = storedSession({ + accessToken: developmentAccessToken(), + oauthConfig: { + issuer: DEVELOPMENT_ADE_CLERK_ISSUER, + clientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }, + }); + const { service, store, fetchImpl } = serviceWithStoredSession({ + env: {} as NodeJS.ProcessEnv, + session, + }); + + expect(service.getStatus().signedIn).toBe(true); + await expect(service.getAccessToken()).resolves.toBe(session.accessToken); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(JSON.stringify(session)); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("keeps a packaged development session unchanged with the explicit escape hatch", async () => { + const session = storedSession({ + accessToken: developmentAccessToken(), + oauthConfig: { + issuer: DEVELOPMENT_ADE_CLERK_ISSUER, + clientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }, + }); + const { service, store, fetchImpl } = serviceWithStoredSession({ + env: { + ADE_RUNTIME_PACKAGED: "1", + ADE_ALLOW_DEVELOPMENT_CLERK: "1", + } as NodeJS.ProcessEnv, + session, + }); + + expect(service.getStatus().signedIn).toBe(true); + await expect(service.getAccessToken()).resolves.toBe(session.accessToken); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(JSON.stringify(session)); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("keeps a packaged production session unchanged", async () => { + const session = storedSession({ + accessToken: productionAccessToken(), + oauthConfig: { + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }, + }); + const { service, store, fetchImpl } = serviceWithStoredSession({ + env: { ADE_RUNTIME_PACKAGED: "1" } as NodeJS.ProcessEnv, + session, + }); + + expect(service.getStatus().signedIn).toBe(true); + await expect(service.getAccessToken()).resolves.toBe(session.accessToken); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(JSON.stringify(session)); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("does not clear a newer production session written while invalidating a development session", async () => { + const developmentSession = storedSession({ + accessToken: developmentAccessToken(), + oauthConfig: { + issuer: DEVELOPMENT_ADE_CLERK_ISSUER, + clientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }, + }); + const productionSession = storedSession({ + accessToken: productionAccessToken(), + refreshToken: "production-refresh-token", + obtainedAt: "2026-07-14T11:30:00.000Z", + oauthConfig: { + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }, + }); + class RacingCredentialStore extends MemoryCredentialStore { + private replacementPending = true; + accountReads = 0; + + override getSync(key: string): string | null { + if (key === ACCOUNT_SESSION_CREDENTIAL_KEY) this.accountReads += 1; + return super.getSync(key); + } + + override updateSync(updater: (values: Record) => boolean | void): void { + if (this.replacementPending) { + this.replacementPending = false; + this.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(productionSession)); + } + super.updateSync(updater); + } + } + const store = new RacingCredentialStore(); + const fetchImpl = vi.fn(); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }), + env: { ADE_RUNTIME_PACKAGED: "1" } as NodeJS.ProcessEnv, + now, + fetchImpl, + }); + activeServices.push(service); + + // Seed the development session after construction so this getStatus() is + // the call that observes it, loses the compare-delete race, and must retry. + store.accountReads = 0; + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(developmentSession)); + expect(service.getStatus()).toMatchObject({ + signedIn: true, + userId: productionSession.userId, + source: "loopback", + }); + expect(store.accountReads).toBe(2); + await expect(service.getAccessToken()).resolves.toBe(productionSession.accessToken); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(JSON.stringify(productionSession)); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("rejects but does not delete a packaged development session when the store lacks atomic compare-and-delete", async () => { + const developmentSession = storedSession({ + accessToken: developmentAccessToken(), + oauthConfig: { + issuer: DEVELOPMENT_ADE_CLERK_ISSUER, + clientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }, + }); + const store = new MemoryCredentialStore(); + // A store without atomic compare-and-delete must not get-then-delete (that + // would race a peer-written production replacement); reject on read instead. + (store as { updateSync?: unknown }).updateSync = undefined; + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(developmentSession)); + const fetchImpl = vi.fn(); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }), + env: { ADE_RUNTIME_PACKAGED: "1" } as NodeJS.ProcessEnv, + now, + fetchImpl, + }); + activeServices.push(service); + + expect(service.getStatus()).toMatchObject({ signedIn: false, source: null }); + expect(service.getSessionReadState()).toBe("missing"); + await expect(service.getAccessToken()).rejects.toThrow(/not signed in/i); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe( + JSON.stringify(developmentSession), + ); + }); +}); + describe("AccountAuthService CLERK_ISSUER scheme enforcement", () => { function serviceForIssuer(issuer: string): AccountAuthService { const service = createAccountAuthService({ @@ -319,6 +579,47 @@ describe("AccountAuthService OAuth PKCE login", () => { }); }); + it("rejects a development-issued login response in a packaged build before userinfo or persistence", async () => { + const store = new MemoryCredentialStore(); + const developmentAccessToken = jwt({ + iss: DEVELOPMENT_ADE_CLERK_ISSUER, + sub: "development-user", + exp: Date.parse("2026-07-14T13:00:00.000Z") / 1000, + }); + const fetchImpl = vi.fn(async (): Promise => jsonResponse({ + access_token: developmentAccessToken, + refresh_token: "development-refresh-token", + expires_in: 3_600, + })); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }), + env: { ADE_RUNTIME_PACKAGED: "1" } as NodeJS.ProcessEnv, + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + randomBytes: (size) => Buffer.alloc(size, 0x39), + randomUUID: () => "login-session-development-response", + fetchImpl, + }); + activeServices.push(service); + + const start = await service.startLogin(); + const authorizeUrl = new URL(start.authorizeUrl); + const callback = await fetch( + `${authorizeUrl.searchParams.get("redirect_uri")}?code=development-code&state=${encodeURIComponent(authorizeUrl.searchParams.get("state")!)}`, + ); + + expect(callback.status).toBe(502); + await expect(service.pollLogin(start.sessionId)).resolves.toMatchObject({ + status: "error", + authStatus: { signedIn: false }, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); + }); + it("pins loopback OAuth context across callback exchange, refresh, and token creation", async () => { const store = new MemoryCredentialStore(); const configA = { issuer: "https://clerk-a.example.test/", clientId: " client-a " }; @@ -1108,6 +1409,129 @@ describe("AccountAuthService ADE_ACCOUNT_TOKEN", () => { expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); }); + it.each([ + { + name: "development-issued access token", + credential: jwt({ + iss: DEVELOPMENT_ADE_CLERK_ISSUER, + sub: "env-dev-user", + exp: Date.parse("2026-07-14T13:00:00.000Z") / 1000, + }), + }, + { + name: "self-contained development refresh context", + credential: `ade_account_v1.${Buffer.from(JSON.stringify({ + version: 1, + refreshToken: "development-refresh-token", + issuer: DEVELOPMENT_ADE_CLERK_ISSUER, + clientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }), "utf8").toString("base64url")}`, + }, + ])("treats a packaged ADE_ACCOUNT_TOKEN with $name as absent across auth flows", async ({ credential }) => { + const store = new MemoryCredentialStore(); + const fetchImpl = vi.fn(async (input: string): Promise => { + expect(input).toBe(`${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/device/code`); + return jsonResponse({ + device_code: `device-code-${fetchImpl.mock.calls.length}`, + user_code: "PROD-1234", + verification_uri: `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/device`, + expires_in: 600, + interval: 5, + }); + }); + const randomUUID = vi.fn() + .mockReturnValueOnce("packaged-loopback-session") + .mockReturnValueOnce("packaged-device-session") + .mockReturnValueOnce("packaged-headless-device-session"); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }), + env: { + ADE_RUNTIME_PACKAGED: "1", + ADE_ACCOUNT_TOKEN: credential, + } as NodeJS.ProcessEnv, + getDeviceBridgeUrl: () => DEFAULT_ADE_ACCOUNT_DIRECTORY_URL, + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + randomUUID, + fetchImpl, + }); + activeServices.push(service); + + expect(service.getStatus()).toMatchObject({ + signedIn: false, + userId: null, + source: null, + }); + await expect(service.getAccessToken()).rejects.toThrow(/not signed in/i); + + const loopback = await service.startLogin(); + expect(new URL(loopback.authorizeUrl).origin).toBe(DEFAULT_ADE_CLERK_ISSUER); + await expect(service.startDeviceLogin()).resolves.toMatchObject({ + sessionId: "packaged-device-session", + verificationUri: `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/device`, + }); + await expect(service.startDeviceLogin({ ignoreEnvCredential: true })).resolves.toMatchObject({ + sessionId: "packaged-headless-device-session", + verificationUri: `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/device`, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); + }); + + it("lets a stored production session win over a rejected packaged env credential", async () => { + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const productionSession = storedSession({ + accessToken: jwt({ + iss: DEFAULT_ADE_CLERK_ISSUER, + sub: "production-user", + exp: nowMs / 1000 + 3_600, + }), + refreshToken: "production-refresh-token", + userId: "production-user", + oauthConfig: { + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }, + }); + const store = new MemoryCredentialStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(productionSession)); + const fetchImpl = vi.fn(); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }), + env: { + ADE_RUNTIME_PACKAGED: "1", + ADE_ACCOUNT_TOKEN: jwt({ + iss: DEVELOPMENT_ADE_CLERK_ISSUER, + sub: "development-user", + exp: nowMs / 1000 + 3_600, + }), + } as NodeJS.ProcessEnv, + now: () => nowMs, + fetchImpl, + }); + activeServices.push(service); + + expect(service.getStatus()).toMatchObject({ + signedIn: true, + userId: "production-user", + source: "loopback", + }); + await expect(service.getAccessToken()).resolves.toBe(productionSession.accessToken); + await expect(service.createToken()).resolves.toMatchObject({ + token: expect.stringMatching(/^ade_account_v1\./), + source: "refresh_token", + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("refreshes a newly provisioned token without local OAuth configuration", async () => { const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); const issuingStore = new MemoryCredentialStore(); diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts index 8482015ad..fcac3e543 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -1,5 +1,15 @@ import { createHash, randomBytes as nodeRandomBytes, randomUUID as nodeRandomUUID, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { + DEFAULT_ADE_ACCOUNT_DIRECTORY_URL, + DEFAULT_ADE_CLERK_ISSUER, + DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + isClerkDevelopmentIssuer, + isClerkDevelopmentOAuthClientId, + shouldIgnoreDevelopmentAccountDirectoryUrl, + shouldIgnoreDevelopmentClerkConfiguration, + warnDevelopmentClerkIgnored, +} from "../../../../desktop/src/shared/accountDirectory"; import type { SyncCredentialStore } from "../credentials/credentialStore"; export const ACCOUNT_SESSION_CREDENTIAL_KEY = "account.session.v1"; @@ -269,6 +279,31 @@ function accessTokenExpiresAt(token: string): string | null { return Number.isFinite(expiresAt.getTime()) ? expiresAt.toISOString() : null; } +function accessTokenIssuer(token: string): string | null { + return readNonEmptyString(decodeJwtPayload(token)?.iss); +} + +function isDevelopmentOAuthConfig( + config: AccountOAuthConfig | null | undefined, +): boolean { + return isClerkDevelopmentIssuer(config?.issuer) + || isClerkDevelopmentOAuthClientId(config?.clientId); +} + +function shouldRejectDevelopmentAccountMaterial(args: { + env: NodeJS.ProcessEnv; + accessToken?: string | null; + oauthConfig?: AccountOAuthConfig | null; +}): boolean { + return shouldIgnoreDevelopmentClerkConfiguration(args.env) + && ( + isDevelopmentOAuthConfig(args.oauthConfig) + || isClerkDevelopmentIssuer( + args.accessToken ? accessTokenIssuer(args.accessToken) : null, + ) + ); +} + function classifyEnvCredential(token: string): "access_token" | "refresh_token" { const payload = decodeJwtPayload(token); if (!payload) return "refresh_token"; @@ -317,6 +352,18 @@ function inspectEnvCredential(credential: string): EnvCredential { : { kind: "refresh_token", token: credential, oauthConfig: null }; } +export function shouldRejectDevelopmentEnvCredential( + env: NodeJS.ProcessEnv, + credential: string, +): boolean { + const inspected = inspectEnvCredential(credential); + return shouldRejectDevelopmentAccountMaterial({ + env, + accessToken: inspected.kind === "invalid" ? null : inspected.token, + oauthConfig: inspected.kind === "refresh_token" ? inspected.oauthConfig : null, + }); +} + function isLoopbackIssuerHost(hostname: string): boolean { // `new URL("http://[::1]/").hostname` returns "[::1]" (brackets included), so // accept both bracketed and bare IPv6 loopback forms. @@ -356,6 +403,23 @@ function normalizeOAuthConfig(config: AccountOAuthConfig): AccountOAuthConfig { return { issuer, clientId }; } +function normalizeRuntimeOAuthConfig( + config: AccountOAuthConfig, + env: NodeJS.ProcessEnv, +): AccountOAuthConfig { + if ( + shouldIgnoreDevelopmentClerkConfiguration(env) + && isDevelopmentOAuthConfig(config) + ) { + warnDevelopmentClerkIgnored(); + return normalizeOAuthConfig({ + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }); + } + return normalizeOAuthConfig(config); +} + function normalizeOptionalOAuthConfig(args: { present: boolean; issuer: unknown; @@ -662,7 +726,10 @@ export function createAccountAuthService(args: { let lastObservedSignedIn: boolean | null = null; const signedInListeners = new Set<() => void>(); - const readEnvCredential = (): string | null => readNonEmptyString(env[ACCOUNT_TOKEN_ENV_KEY]); + const readRawEnvCredential = (): string | null => readNonEmptyString(env[ACCOUNT_TOKEN_ENV_KEY]); + + const resolveOAuthConfig = async (): Promise => + normalizeRuntimeOAuthConfig(await args.getOAuthConfig(), env); const resetEnvSessionIfCredentialChanged = ( credential: string, @@ -676,9 +743,37 @@ export function createAccountAuthService(args: { envRefreshToken = inspected.kind === "refresh_token" ? inspected.token : null; }; - const envCredentialStatus = (credential: string): AccountAuthStatus => { + const rejectDevelopmentEnvCredential = (inspected: EnvCredential): boolean => { + const oauthConfig = inspected.kind === "refresh_token" ? inspected.oauthConfig : null; + if (!shouldRejectDevelopmentAccountMaterial({ + env, + accessToken: inspected.kind === "invalid" ? null : inspected.token, + oauthConfig, + })) { + return false; + } + if (envSession || envRefreshInFlight) envCredentialEpoch += 1; + envSession = null; + envRefreshInFlight = null; + envRefreshToken = null; + warnDevelopmentClerkIgnored(); + return true; + }; + + const readAcceptedEnvCredential = (): { + credential: string; + inspected: EnvCredential; + } | null => { + const credential = readRawEnvCredential(); + if (!credential) return null; const inspected = inspectEnvCredential(credential); resetEnvSessionIfCredentialChanged(credential, inspected); + return rejectDevelopmentEnvCredential(inspected) + ? null + : { credential, inspected }; + }; + + const envCredentialStatus = (inspected: EnvCredential): AccountAuthStatus => { if (envSession) return { ...toStatus(envSession), source: "env-token" }; const isAccessToken = inspected.kind === "access_token"; const accessToken = inspected.kind === "access_token" ? inspected.token : null; @@ -699,18 +794,79 @@ export function createAccountAuthService(args: { }; }; + 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); + } + lastObservedSignedIn = record != null; + }; + + const invalidateStoredSessionIfCurrent = (raw: string): void => { + const updateSync = args.credentialStore.updateSync; + if (updateSync) { + // Atomic compare-and-delete: remove only the development session we + // observed, so a production credential a peer wrote after our read is + // never clobbered. + updateSync.call(args.credentialStore, (values) => { + if (values[ACCOUNT_SESSION_CREDENTIAL_KEY] !== raw) return false; + delete values[ACCOUNT_SESSION_CREDENTIAL_KEY]; + return true; + }); + } + // Without atomic compare-and-delete we do NOT get-then-delete — that races a + // peer-written production replacement. The development session is simply + // rejected on every read instead of being erased. + authEpoch += 1; + lastObservedSignedIn = false; + sessionReadState = "missing"; + warnDevelopmentClerkIgnored(); + }; + const readSession = (): AccountSessionRecord | null => { try { - const stored = args.credentialStore.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY); - const session = parseStoredSession(stored); - sessionReadState = stored == null - ? args.credentialStore.getLastReadState?.() === "unreadable" - ? "unreadable" - : "missing" - : session - ? "available" - : "unreadable"; - return session; + // A peer process may replace the credential between the read and the + // atomic delete. Retry once so we never clear or surface that newer + // session merely because an older development session was observed. + const readStoredSession = (): { + raw: string | null; + session: AccountSessionRecord | null; + } => { + const stored = args.credentialStore.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY); + const session = parseStoredSession(stored); + sessionReadState = stored == null + ? args.credentialStore.getLastReadState?.() === "unreadable" + ? "unreadable" + : "missing" + : session + ? "available" + : "unreadable"; + return { raw: stored, session }; + }; + + const observed = readStoredSession(); + if (!observed.session || !observed.raw) return observed.session; + if (!shouldRejectDevelopmentAccountMaterial({ + env, + accessToken: observed.session.accessToken, + oauthConfig: observed.session.oauthConfig, + })) { + return observed.session; + } + + invalidateStoredSessionIfCurrent(observed.raw); + const retry = readStoredSession(); + if (!retry.session || !retry.raw) return null; + if (shouldRejectDevelopmentAccountMaterial({ + env, + accessToken: retry.session.accessToken, + oauthConfig: retry.session.oauthConfig, + })) { + sessionReadState = "missing"; + return null; + } + return retry.session; } catch (error) { sessionReadState = "unreadable"; logger.warn("account.session_read_failed", { @@ -730,15 +886,6 @@ export function createAccountAuthService(args: { } }; - 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); - } - lastObservedSignedIn = record != null; - }; - const persistRefreshedSessionIfCurrent = ( refreshed: AccountSessionRecord, refreshSource: AccountSessionRecord, @@ -796,6 +943,14 @@ export function createAccountAuthService(args: { oauthConfig: AccountOAuthConfig | null, ): Promise | null> => { if (!oauthConfig) return null; + if (shouldRejectDevelopmentAccountMaterial({ + env, + accessToken: token.accessToken, + oauthConfig, + })) { + warnDevelopmentClerkIgnored(); + return null; + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), userinfoRequestTimeoutMs); timer.unref?.(); @@ -843,6 +998,16 @@ export function createAccountAuthService(args: { authSource: AccountSessionRecord["authSource"] = previous?.authSource ?? "loopback", oauthConfig: AccountOAuthConfig | null = previous?.oauthConfig ?? null, ): Promise => { + if (shouldRejectDevelopmentAccountMaterial({ + env, + accessToken: token.accessToken, + oauthConfig, + })) { + warnDevelopmentClerkIgnored(); + throw new Error( + "ADE rejected development Clerk session material in this packaged build. Sign in again to use ADE production.", + ); + } const obtainedAtMs = now(); const claims = decodeAccountClaims(token.accessToken); const claimedExpiresAt = accessTokenExpiresAt(token.accessToken); @@ -947,7 +1112,7 @@ export function createAccountAuthService(args: { }; const startLogin = async (): Promise => { - if (readEnvCredential()) { + if (readAcceptedEnvCredential()) { throw new Error("ADE_ACCOUNT_TOKEN is already providing account authentication; no interactive sign-in is required."); } pruneFinishedSessions(); @@ -961,7 +1126,7 @@ export function createAccountAuthService(args: { pendingSessions.delete(oldestId); } - const config = normalizeOAuthConfig(await args.getOAuthConfig()); + const config = await resolveOAuthConfig(); const codeVerifier = randomBytes(32).toString("base64url"); const oauthState = randomBytes(32).toString("base64url"); const sessionId = randomUUID(); @@ -1057,11 +1222,16 @@ export function createAccountAuthService(args: { }; }; - const resolveDeviceBridgeUrl = async (): Promise => normalizeDeviceBridgeUrl( - args.getDeviceBridgeUrl + const resolveDeviceBridgeUrl = async (): Promise => { + const rawUrl = args.getDeviceBridgeUrl ? await args.getDeviceBridgeUrl() - : env.ADE_ACCOUNT_DIRECTORY_URL ?? "", - ); + : env.ADE_ACCOUNT_DIRECTORY_URL ?? ""; + if (shouldIgnoreDevelopmentAccountDirectoryUrl(rawUrl, env)) { + warnDevelopmentClerkIgnored(); + return normalizeDeviceBridgeUrl(DEFAULT_ADE_ACCOUNT_DIRECTORY_URL); + } + return normalizeDeviceBridgeUrl(rawUrl); + }; const requestDeviceBridge = ( url: string, @@ -1083,7 +1253,7 @@ export function createAccountAuthService(args: { const startDeviceLogin = async ( options: { ignoreEnvCredential?: boolean } = {}, ): Promise => { - if (readEnvCredential() && !options.ignoreEnvCredential) { + if (readAcceptedEnvCredential() && !options.ignoreEnvCredential) { throw new Error("ADE_ACCOUNT_TOKEN is already providing account authentication; no interactive sign-in is required."); } for (const [sessionId, session] of pendingDeviceSessions) { @@ -1293,12 +1463,23 @@ export function createAccountAuthService(args: { authStatus: toStatus(readSession()), }; } - const baseRecord = await buildSessionRecord({ - accessToken, - refreshToken: readNonEmptyString(payload.refresh_token), - tokenType: readNonEmptyString(payload.token_type) ?? "Bearer", - expiresInSec, - }, null, "device", oauthConfig); + let baseRecord: AccountSessionRecord; + try { + baseRecord = await buildSessionRecord({ + accessToken, + refreshToken: readNonEmptyString(payload.refresh_token), + tokenType: readNonEmptyString(payload.token_type) ?? "Bearer", + expiresInSec, + }, null, "device", oauthConfig); + } catch (error) { + pendingDeviceSessions.delete(normalizedSessionId); + return { + status: "error", + message: error instanceof Error ? error.message : "ADE account device token could not be accepted.", + intervalSec: null, + authStatus: toStatus(readSession()), + }; + } const record: AccountSessionRecord = session.suppressEnvCredential ? { ...baseRecord, suppressEnvCredential: true } : baseRecord; @@ -1361,16 +1542,15 @@ export function createAccountAuthService(args: { const getStatus = (): AccountAuthStatus => { const record = readSession(); if (record?.suppressEnvCredential) return toStatus(record); - const envCredential = readEnvCredential(); - return envCredential ? envCredentialStatus(envCredential) : toStatus(record); + const envCredential = readAcceptedEnvCredential(); + return envCredential ? envCredentialStatus(envCredential.inspected) : toStatus(record); }; const getAccessToken = async (): Promise => { const record = readSession(); - const envCredential = readEnvCredential(); - if (envCredential && !record?.suppressEnvCredential) { - const inspected = inspectEnvCredential(envCredential); - resetEnvSessionIfCredentialChanged(envCredential, inspected); + const acceptedEnvCredential = readAcceptedEnvCredential(); + if (acceptedEnvCredential && !record?.suppressEnvCredential) { + const { credential: envCredential, inspected } = acceptedEnvCredential; if (inspected.kind === "invalid") { throw new Error( "ADE_ACCOUNT_TOKEN is not a valid provisioned account token. Recreate it with `ade account token create`.", @@ -1408,7 +1588,7 @@ export function createAccountAuthService(args: { config = normalizeOAuthConfig(inspected.oauthConfig); } else { try { - config = normalizeOAuthConfig(await args.getOAuthConfig()); + config = await resolveOAuthConfig(); } catch { throw new Error( "Legacy ADE_ACCOUNT_TOKEN refresh tokens require local CLERK_ISSUER and CLERK_OAUTH_CLIENT_ID. Recreate the token with `ade account token create` to make it self-contained.", @@ -1434,7 +1614,7 @@ export function createAccountAuthService(args: { if ( envCredentialEpoch !== epochAtRefresh || envSessionCredential !== credentialAtRefresh - || readEnvCredential() !== credentialAtRefresh + || readRawEnvCredential() !== credentialAtRefresh || envRefreshInFlight !== refreshPromise ) { return getAccessToken(); @@ -1475,7 +1655,7 @@ export function createAccountAuthService(args: { for (let attempt = 0; attempt < 2; attempt += 1) { config = refreshRecord.oauthConfig ? normalizeOAuthConfig(refreshRecord.oauthConfig) - : normalizeOAuthConfig(await args.getOAuthConfig()); + : await resolveOAuthConfig(); try { token = await postTokenForm({ fetchImpl, @@ -1530,7 +1710,7 @@ export function createAccountAuthService(args: { const createToken = async (): Promise => { const record = readSession(); - if (readEnvCredential() && !record?.suppressEnvCredential) { + if (readAcceptedEnvCredential() && !record?.suppressEnvCredential) { throw new Error( "ADE is using ADE_ACCOUNT_TOKEN. Unset it and sign in interactively before creating a new durable account token.", ); @@ -1540,7 +1720,7 @@ export function createAccountAuthService(args: { } const oauthConfig = record.oauthConfig ? normalizeOAuthConfig(record.oauthConfig) - : normalizeOAuthConfig(await args.getOAuthConfig()); + : await resolveOAuthConfig(); return { token: provisionedAccountToken({ refreshToken: record.refreshToken, oauthConfig }), source: "refresh_token", diff --git a/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts b/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts index 41b9ff88f..d8d86fb0d 100644 --- a/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest"; import type { AdeAccountMachine } from "../../../../desktop/src/shared/types/account"; import type { RemoteRuntimeTarget } from "../../../../desktop/src/shared/types/remoteRuntime"; import type { DesktopPairedMachineCredentials } from "../../../../desktop/src/shared/types/pairedRuntime"; -import { DEFAULT_ADE_ACCOUNT_DIRECTORY_URL } from "../../../../desktop/src/shared/accountDirectory"; +import { + DEFAULT_ADE_ACCOUNT_DIRECTORY_URL, + DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL, +} from "../../../../desktop/src/shared/accountDirectory"; import { AccountMachineDirectoryService, reconcileAccountOwnedMachineTrust, @@ -161,6 +164,58 @@ describe("AccountMachineDirectoryService", () => { } }); + it("ignores the raw development directory environment fallback when packaged", async () => { + vi.stubEnv("ADE_RUNTIME_PACKAGED", "1"); + vi.stubEnv("ADE_ALLOW_DEVELOPMENT_CLERK", ""); + vi.stubEnv("ADE_ACCOUNT_DIRECTORY_URL", `${DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL}/tenant`); + try { + const fetchImpl = directoryFetch([]); + const service = new AccountMachineDirectoryService({ + getStatus: () => ({ signedIn: true, userId: "user", email: null, name: null, expiresAt: null }), + getAccessToken: async () => "account-token", + }, { fetchImpl }); + + await expect(service.listMachines()).resolves.toMatchObject({ state: "ok" }); + await expect(service.deleteMachine("mk-studio")).resolves.toEqual({ + ok: true, + machineKey: "mk-studio", + }); + expect((fetchImpl as ReturnType).mock.calls.map(([input]) => input)).toEqual([ + `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/account/machines`, + `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/account/machines/mk-studio`, + ]); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("ignores an explicit development directory callback when packaged", async () => { + vi.stubEnv("ADE_RUNTIME_PACKAGED", "1"); + vi.stubEnv("ADE_ALLOW_DEVELOPMENT_CLERK", ""); + try { + const fetchImpl = directoryFetch([]); + const service = new AccountMachineDirectoryService({ + getStatus: () => ({ signedIn: true, userId: "user", email: null, name: null, expiresAt: null }), + getAccessToken: async () => "account-token", + }, { + directoryBaseUrl: () => `${DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL}/tenant`, + fetchImpl, + }); + + await expect(service.listMachines()).resolves.toMatchObject({ state: "ok" }); + await expect(service.deleteMachine("mk-studio")).resolves.toEqual({ + ok: true, + machineKey: "mk-studio", + }); + expect((fetchImpl as ReturnType).mock.calls.map(([input]) => input)).toEqual([ + `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/account/machines`, + `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/account/machines/mk-studio`, + ]); + } finally { + vi.unstubAllEnvs(); + } + }); + it("keeps offline machines visible but rejects connecting to them", async () => { const offline = machine({ online: false, lastSeenAt: 1 }); const service = new AccountMachineDirectoryService({ diff --git a/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts b/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts index 8bd311d36..1319535ea 100644 --- a/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts +++ b/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts @@ -16,6 +16,8 @@ import { fetchAccountMachines, resolveTrustedAccountDirectoryBaseUrl, selectAccountMachine, + shouldIgnoreDevelopmentAccountDirectoryUrl, + warnDevelopmentClerkIgnored, } from "../../../../desktop/src/shared/accountDirectory"; import type { AccountAuthService } from "./accountAuthService"; import { defaultRelayUrl } from "../sync/syncCloudRelayStore"; @@ -106,6 +108,16 @@ export type AccountMachineListOptions = { export type AccountMachineDeleteOptions = AccountMachineListOptions; +function packagedSafeAccountDirectoryOverride( + rawUrl: string | null | undefined, +): string | undefined { + if (shouldIgnoreDevelopmentAccountDirectoryUrl(rawUrl, process.env)) { + warnDevelopmentClerkIgnored(); + return undefined; + } + return rawUrl ?? undefined; +} + export class AccountMachineDirectoryService { constructor( private readonly account: Pick, @@ -136,8 +148,9 @@ export class AccountMachineDirectoryService { } return await fetchAccountMachines({ baseUrl: resolveTrustedAccountDirectoryBaseUrl( - this.options.directoryBaseUrl?.() - ?? process.env.ADE_ACCOUNT_DIRECTORY_URL, + packagedSafeAccountDirectoryOverride( + this.options.directoryBaseUrl?.() ?? process.env.ADE_ACCOUNT_DIRECTORY_URL, + ), ), accessToken: token, fetchImpl: this.options.fetchImpl, @@ -166,7 +179,9 @@ export class AccountMachineDirectoryService { if (!token) throw new Error("Your ADE account session expired. Sign in again."); const baseUrl = resolveTrustedAccountDirectoryBaseUrl( - this.options.directoryBaseUrl?.() ?? process.env.ADE_ACCOUNT_DIRECTORY_URL, + packagedSafeAccountDirectoryOverride( + this.options.directoryBaseUrl?.() ?? process.env.ADE_ACCOUNT_DIRECTORY_URL, + ), ); if (!baseUrl) { throw new Error( diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts index 0b1ef1fc8..9ebde9e94 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts @@ -3,8 +3,34 @@ import { ACCOUNT_MACHINE_HEARTBEAT_MS, type AccountMachineRegistrationSnapshot, buildAccountMachineRegistration, + createBrainAccountMachinePublisherService, createAccountMachinePublisherService, } from "./accountMachinePublisherService"; +import { + DEFAULT_ADE_ACCOUNT_DIRECTORY_URL, + DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL, +} from "../../../../desktop/src/shared/accountDirectory"; + +const sharedAccountAuthService = vi.hoisted(() => ({ + getStatus: vi.fn(() => ({ + signedIn: true, + userId: "account-user", + source: "loopback" as const, + })), + getAccessToken: vi.fn(async () => "account-token"), + getSessionReadState: vi.fn(() => "available" as const), + onSignedIn: vi.fn(() => () => {}), +})); + +vi.mock("./sharedAccountAuthService", async () => { + const actual = await vi.importActual( + "./sharedAccountAuthService", + ); + return { + ...actual, + getSharedAccountAuthService: () => sharedAccountAuthService, + }; +}); function snapshot( overrides: Partial = {}, @@ -94,6 +120,8 @@ function routeSnapshot( afterEach(() => { vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); }); describe("account machine publisher health", () => { @@ -293,6 +321,43 @@ describe("account machine publisher health", () => { }); }); +describe("brain account machine publisher directory policy", () => { + it("posts the account bearer to production when a packaged override targets development", async () => { + vi.stubEnv("ADE_RUNTIME_PACKAGED", "1"); + vi.stubEnv("ADE_ALLOW_DEVELOPMENT_CLERK", ""); + const requests: Array<{ input: string; init?: RequestInit }> = []; + vi.stubGlobal("fetch", vi.fn(async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + requests.push({ input: String(input), init }); + return new Response(null, { status: 204 }); + })); + const service = createBrainAccountMachinePublisherService({ + secretsDir: "/tmp/ade-account-publisher-policy", + projectRoots: () => [], + isSyncEnabled: () => true, + getSnapshot: async () => snapshot(), + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL, + logger: { info: vi.fn(), warn: vi.fn() }, + }); + + await service.publishNow(); + service.dispose(); + + expect(requests.map((request) => request.input)).toEqual([ + `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/account/machines/register`, + ]); + expect(new Headers(requests[0]?.init?.headers).get("authorization")) + .toBe("Bearer account-token"); + expect(service.getPublisherHealth()).toMatchObject({ + state: "published", + directoryOrigin: DEFAULT_ADE_ACCOUNT_DIRECTORY_URL, + }); + }); +}); + describe("account machine registration publisher", () => { it("suffixes Beta and Alpha machine names but never stable names", () => { const registrationName = (packageChannel: string | null) => @@ -382,6 +447,29 @@ describe("account machine registration publisher", () => { })); }); + it("ignores a development directory at the core publisher boundary when packaged", async () => { + vi.stubEnv("ADE_RUNTIME_PACKAGED", "1"); + vi.stubEnv("ADE_ALLOW_DEVELOPMENT_CLERK", ""); + const fetchImpl = vi.fn(async ( + _input: string | URL | Request, + _init?: RequestInit, + ) => new Response(null, { status: 204 })); + const service = createAccountMachinePublisherService({ + getAccessToken: async () => "account-secret-token", + getSnapshot: async () => routeSnapshot(), + getMachineKey: () => "machine-studio", + directoryBaseUrl: () => `${DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL}/tenant`, + fetchImpl, + }); + + await service.publishNow(); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0]?.[0]).toBe( + `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/account/machines/register`, + ); + }); + it("never sends the bearer to an untrusted URL or logs it on failure", async () => { const fetchImpl = vi.fn(async () => new Response("no", { status: 503 })); const warn = vi.fn(); diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts index 4a8e1833b..747232eb2 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts @@ -8,6 +8,8 @@ import { import { readAccountDirectoryHttpReason, resolveTrustedAccountDirectoryBaseUrl, + shouldIgnoreDevelopmentAccountDirectoryUrl, + warnDevelopmentClerkIgnored, } from "../../../../desktop/src/shared/accountDirectory"; import { getSignedInAccountAccessToken, @@ -229,8 +231,17 @@ export function createAccountMachinePublisherService(options: { let baseUrl: string | null = null; try { + const configuredBaseUrl = options.directoryBaseUrl?.(); + let packagedSafeBaseUrl = configuredBaseUrl; + if (shouldIgnoreDevelopmentAccountDirectoryUrl( + configuredBaseUrl, + process.env, + )) { + warnDevelopmentClerkIgnored(); + packagedSafeBaseUrl = undefined; + } baseUrl = resolveTrustedAccountDirectoryBaseUrl( - options.directoryBaseUrl?.(), + packagedSafeBaseUrl, ); } catch { baseUrl = null; @@ -521,7 +532,7 @@ export function createBrainAccountMachinePublisherService(options: { secretsDir: string; projectRoots: () => Iterable; isSyncEnabled: () => boolean; - getSnapshot: () => Promise; + getSnapshot: () => Promise; getMachineKey: () => string; directoryBaseUrl?: () => string | null | undefined; logger: BrainAccountMachinePublisherLogger; @@ -546,7 +557,12 @@ export function createBrainAccountMachinePublisherService(options: { getMachineKey: options.getMachineKey, directoryBaseUrl: () => { const explicit = options.directoryBaseUrl?.(); - if (explicit?.trim()) return explicit; + if (explicit?.trim()) { + if (!shouldIgnoreDevelopmentAccountDirectoryUrl(explicit, process.env)) { + return explicit; + } + warnDevelopmentClerkIgnored(); + } return resolveOfficialAccountDirectoryBaseUrl({ env: process.env, projectRoots: options.projectRoots(), diff --git a/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts b/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts index 33c2d1b6e..2be18095c 100644 --- a/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts @@ -184,6 +184,87 @@ describe("getSharedAccountAuthService resolves CLERK OAuth config as an atomic p expect(authorizeUrl.origin).toBe("https://invoking.example.test"); expect(authorizeUrl.searchParams.get("client_id")).toBe("invoking-client"); }); + + it("atomically replaces project and environment development pairs when packaged", () => { + const developmentRoot = makeProjectRoot({ + CLERK_ISSUER: DEVELOPMENT_ADE_CLERK_ISSUER, + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }); + const packagedDevelopmentEnv = { + ADE_RUNTIME_PACKAGED: "1", + CLERK_ISSUER: DEVELOPMENT_ADE_CLERK_ISSUER, + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + } as NodeJS.ProcessEnv; + const productionConfig = { + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }; + + expect(resolveAccountOAuthConfig({ + env: { ADE_RUNTIME_PACKAGED: "1" } as NodeJS.ProcessEnv, + projectRoots: [developmentRoot], + })).toEqual(productionConfig); + expect(resolveAccountOAuthConfig({ + env: packagedDevelopmentEnv, + projectRoots: [], + })).toEqual(productionConfig); + expect(resolveAccountOAuthConfig({ + env: { + ADE_RUNTIME_PACKAGED: "1", + CLERK_ISSUER: "https://another.clerk.accounts.dev", + CLERK_OAUTH_CLIENT_ID: "another-development-client", + } as NodeJS.ProcessEnv, + projectRoots: [], + })).toEqual(productionConfig); + }); + + it("honors non-packaged, escape-hatch, and custom production overrides", () => { + const developmentConfig = { + issuer: DEVELOPMENT_ADE_CLERK_ISSUER, + clientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }; + expect(resolveAccountOAuthConfig({ + env: { + CLERK_ISSUER: DEVELOPMENT_ADE_CLERK_ISSUER, + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + } as NodeJS.ProcessEnv, + projectRoots: [], + })).toEqual(developmentConfig); + expect(resolveAccountOAuthConfig({ + env: { + ADE_RUNTIME_PACKAGED: "1", + ADE_ALLOW_DEVELOPMENT_CLERK: "1", + CLERK_ISSUER: DEVELOPMENT_ADE_CLERK_ISSUER, + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + } as NodeJS.ProcessEnv, + projectRoots: [], + })).toEqual(developmentConfig); + expect(resolveAccountOAuthConfig({ + env: { + ADE_RUNTIME_PACKAGED: "1", + CLERK_ISSUER: "https://clerk.example.com", + CLERK_OAUTH_CLIENT_ID: "custom-client", + } as NodeJS.ProcessEnv, + projectRoots: [], + })).toEqual({ + issuer: "https://clerk.example.com", + clientId: "custom-client", + }); + }); + + it("replaces a packaged development OAuth client id behind a custom issuer", () => { + expect(resolveAccountOAuthConfig({ + env: { + ADE_RUNTIME_PACKAGED: "1", + CLERK_ISSUER: "https://clerk.example.com", + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + } as NodeJS.ProcessEnv, + projectRoots: [], + })).toEqual({ + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }); + }); }); describe("getSharedAccountAttestationConfig", () => { @@ -219,6 +300,100 @@ describe("getSharedAccountAttestationConfig", () => { oauthClientId: "env-client", }); }); + + it("atomically replaces packaged development issuers and JWKS URLs", () => { + const developmentRoot = makeProjectRoot({ + CLERK_ISSUER: DEVELOPMENT_ADE_CLERK_ISSUER, + CLERK_JWKS_URL: `${DEVELOPMENT_ADE_CLERK_ISSUER}/.well-known/jwks.json`, + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }); + const productionConfig = { + issuer: DEFAULT_ADE_CLERK_ISSUER, + jwksUrl: DEFAULT_ADE_CLERK_JWKS_URL, + oauthClientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }; + + expect(getSharedAccountAttestationConfig({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [developmentRoot], + env: { ADE_RUNTIME_PACKAGED: "1" } as NodeJS.ProcessEnv, + })).toEqual(productionConfig); + expect(getSharedAccountAttestationConfig({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [], + env: { + ADE_RUNTIME_PACKAGED: "1", + CLERK_ISSUER: "https://clerk.example.com", + CLERK_JWKS_URL: "https://another.clerk.accounts.dev/.well-known/jwks.json", + CLERK_OAUTH_CLIENT_ID: "custom-client", + } as NodeJS.ProcessEnv, + })).toEqual(productionConfig); + expect(getSharedAccountAttestationConfig({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [], + env: { + ADE_RUNTIME_PACKAGED: "1", + CLERK_ISSUER: DEVELOPMENT_ADE_CLERK_ISSUER, + CLERK_JWKS_URL: `${DEVELOPMENT_ADE_CLERK_ISSUER}/.well-known/jwks.json`, + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + } as NodeJS.ProcessEnv, + })).toEqual(productionConfig); + expect(getSharedAccountAttestationConfig({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [], + env: { + ADE_RUNTIME_PACKAGED: "1", + CLERK_ISSUER: "https://clerk.example.com", + CLERK_JWKS_URL: "https://clerk.example.com/.well-known/jwks.json", + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + } as NodeJS.ProcessEnv, + })).toEqual(productionConfig); + }); + + it("honors non-packaged, escape-hatch, and custom production verifiers", () => { + const developmentJwksUrl = + `${DEVELOPMENT_ADE_CLERK_ISSUER}/.well-known/jwks.json`; + const developmentConfig = { + issuer: DEVELOPMENT_ADE_CLERK_ISSUER, + jwksUrl: developmentJwksUrl, + oauthClientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }; + + expect(getSharedAccountAttestationConfig({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [], + env: { + CLERK_ISSUER: DEVELOPMENT_ADE_CLERK_ISSUER, + CLERK_JWKS_URL: developmentJwksUrl, + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + } as NodeJS.ProcessEnv, + })).toEqual(developmentConfig); + expect(getSharedAccountAttestationConfig({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [], + env: { + ADE_RUNTIME_PACKAGED: "1", + ADE_ALLOW_DEVELOPMENT_CLERK: "1", + CLERK_ISSUER: DEVELOPMENT_ADE_CLERK_ISSUER, + CLERK_JWKS_URL: developmentJwksUrl, + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + } as NodeJS.ProcessEnv, + })).toEqual(developmentConfig); + expect(getSharedAccountAttestationConfig({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [], + env: { + ADE_RUNTIME_PACKAGED: "1", + CLERK_ISSUER: "https://clerk.example.com", + CLERK_JWKS_URL: "https://clerk.example.com/.well-known/jwks.json", + CLERK_OAUTH_CLIENT_ID: "custom-client", + } as NodeJS.ProcessEnv, + })).toEqual({ + issuer: "https://clerk.example.com", + jwksUrl: "https://clerk.example.com/.well-known/jwks.json", + oauthClientId: "custom-client", + }); + }); }); describe("getSharedAccountDirectoryBaseUrl", () => { @@ -240,4 +415,43 @@ describe("getSharedAccountDirectoryBaseUrl", () => { } as NodeJS.ProcessEnv, })).toBe("https://invoking-directory.example.test"); }); + + it("forces every packaged development directory path to production", () => { + const developmentRoot = makeProjectRoot({ + CLERK_ISSUER: DEVELOPMENT_ADE_CLERK_ISSUER, + CLERK_OAUTH_CLIENT_ID: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, + }); + const developmentDirectoryRoot = makeProjectRoot({ + ADE_ACCOUNT_DIRECTORY_URL: DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL, + }); + const env = { + ADE_RUNTIME_PACKAGED: "1", + ADE_ACCOUNT_DIRECTORY_URL: DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL, + } as NodeJS.ProcessEnv; + + expect(getSharedAccountDirectoryBaseUrl({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [developmentRoot], + env: { ADE_RUNTIME_PACKAGED: "1" } as NodeJS.ProcessEnv, + })).toBe(DEFAULT_ADE_ACCOUNT_DIRECTORY_URL); + expect(getSharedAccountDirectoryBaseUrl({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [developmentDirectoryRoot], + env, + })).toBe(DEFAULT_ADE_ACCOUNT_DIRECTORY_URL); + expect(getSharedAccountDirectoryBaseUrl({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [], + env, + })).toBe(DEFAULT_ADE_ACCOUNT_DIRECTORY_URL); + expect(getSharedAccountDirectoryBaseUrl({ + secretsDir: uniqueSecretsDir(), + projectRoots: () => [], + env: { + ADE_RUNTIME_PACKAGED: "1", + ADE_ALLOW_DEVELOPMENT_CLERK: "1", + ADE_ACCOUNT_DIRECTORY_URL: DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL, + } as NodeJS.ProcessEnv, + })).toBe(DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL); + }); }); diff --git a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts index 11758e3f0..4178a7255 100644 --- a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts @@ -4,7 +4,12 @@ import { DEFAULT_ADE_CLERK_ISSUER, DEFAULT_ADE_CLERK_JWKS_URL, DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + isClerkDevelopmentIssuer, + isClerkDevelopmentOAuthClientId, officialAccountDirectoryUrlForIssuer, + shouldIgnoreDevelopmentAccountDirectoryUrl, + shouldIgnoreDevelopmentClerkConfiguration, + warnDevelopmentClerkIgnored, } from "../../../../desktop/src/shared/accountDirectory"; import { EncryptedFileCredentialStore } from "../credentials/credentialStore"; import { resolveMachineAdeLayout } from "../projects/machineLayout"; @@ -68,6 +73,44 @@ function readProjectSecret(projectRoot: string, name: string): string | null { } } +function enforcePackagedOAuthConfig( + env: NodeJS.ProcessEnv, + config: AccountOAuthConfig, +): AccountOAuthConfig { + if ( + !shouldIgnoreDevelopmentClerkConfiguration(env) + || (!isClerkDevelopmentIssuer(config.issuer) + && !isClerkDevelopmentOAuthClientId(config.clientId)) + ) { + return config; + } + warnDevelopmentClerkIgnored(); + return { + issuer: DEFAULT_ADE_CLERK_ISSUER, + clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }; +} + +function enforcePackagedAttestationConfig( + env: NodeJS.ProcessEnv, + config: AccountAttestationConfig, +): AccountAttestationConfig { + if ( + !shouldIgnoreDevelopmentClerkConfiguration(env) + || (!isClerkDevelopmentIssuer(config.issuer) + && !isClerkDevelopmentIssuer(config.jwksUrl) + && !isClerkDevelopmentOAuthClientId(config.oauthClientId)) + ) { + return config; + } + warnDevelopmentClerkIgnored(); + return { + issuer: DEFAULT_ADE_CLERK_ISSUER, + jwksUrl: DEFAULT_ADE_CLERK_JWKS_URL, + oauthClientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + }; +} + export function resolveAccountOAuthConfig(args: { env: NodeJS.ProcessEnv; projectRoots: Iterable; @@ -79,21 +122,23 @@ export function resolveAccountOAuthConfig(args: { const issuer = readProjectSecret(projectRoot, "CLERK_ISSUER"); const clientId = readProjectSecret(projectRoot, "CLERK_OAUTH_CLIENT_ID"); if (issuer || clientId) { - return { + return enforcePackagedOAuthConfig(args.env, { issuer: issuer ?? args.env.CLERK_ISSUER?.trim() ?? "", clientId: clientId ?? args.env.CLERK_OAUTH_CLIENT_ID?.trim() ?? "", - }; + }); } } const issuer = args.env.CLERK_ISSUER?.trim() ?? ""; const clientId = args.env.CLERK_OAUTH_CLIENT_ID?.trim() ?? ""; // Environment overrides are another atomic pair: a partial custom pair must // fail closed rather than borrowing the missing half from ADE production. - if (issuer || clientId) return { issuer, clientId }; - return { + if (issuer || clientId) { + return enforcePackagedOAuthConfig(args.env, { issuer, clientId }); + } + return enforcePackagedOAuthConfig(args.env, { issuer: DEFAULT_ADE_CLERK_ISSUER, clientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, - }; + }); } export function resolveOfficialAccountDirectoryBaseUrl(args: { @@ -109,10 +154,22 @@ function resolveDeviceBridgeUrl(args: { }): string { for (const projectRoot of args.projectRoots) { const value = readProjectSecret(projectRoot, "ADE_ACCOUNT_DIRECTORY_URL"); - if (value) return value; + if (value) { + if (shouldIgnoreDevelopmentAccountDirectoryUrl(value, args.env)) { + warnDevelopmentClerkIgnored(); + return resolveOfficialAccountDirectoryBaseUrl(args); + } + return value; + } } const machineOverride = args.env.ADE_ACCOUNT_DIRECTORY_URL?.trim(); - if (machineOverride) return machineOverride; + if (machineOverride) { + if (shouldIgnoreDevelopmentAccountDirectoryUrl(machineOverride, args.env)) { + warnDevelopmentClerkIgnored(); + return resolveOfficialAccountDirectoryBaseUrl(args); + } + return machineOverride; + } return resolveOfficialAccountDirectoryBaseUrl(args); } @@ -129,24 +186,28 @@ function resolveAttestationConfig(args: { const jwksUrl = readProjectSecret(projectRoot, "CLERK_JWKS_URL"); const oauthClientId = readProjectSecret(projectRoot, "CLERK_OAUTH_CLIENT_ID"); if (issuer || jwksUrl || oauthClientId) { - return { + return enforcePackagedAttestationConfig(args.env, { issuer: issuer ?? args.env.CLERK_ISSUER?.trim() ?? "", jwksUrl: jwksUrl ?? args.env.CLERK_JWKS_URL?.trim() ?? "", oauthClientId: oauthClientId ?? args.env.CLERK_OAUTH_CLIENT_ID?.trim() ?? "", - }; + }); } } const issuer = args.env.CLERK_ISSUER?.trim() ?? ""; const jwksUrl = args.env.CLERK_JWKS_URL?.trim() ?? ""; const oauthClientId = args.env.CLERK_OAUTH_CLIENT_ID?.trim() ?? ""; if (issuer || jwksUrl || oauthClientId) { - return { issuer, jwksUrl, oauthClientId }; + return enforcePackagedAttestationConfig(args.env, { + issuer, + jwksUrl, + oauthClientId, + }); } - return { + return enforcePackagedAttestationConfig(args.env, { issuer: DEFAULT_ADE_CLERK_ISSUER, jwksUrl: DEFAULT_ADE_CLERK_JWKS_URL, oauthClientId: DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, - }; + }); } export function getSharedAccountAttestationConfig(args: { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 0d393d64f..b02762a53 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,4 +1,9 @@ import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, protocol, safeStorage, shell } from "electron"; + +if (app.isPackaged && process.env.ADE_RUNTIME_PACKAGED === undefined) { + process.env.ADE_RUNTIME_PACKAGED = "1"; +} + import { AsyncLocalStorage } from "node:async_hooks"; import os from "node:os"; import path from "node:path"; diff --git a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts index d06656ee4..b14959538 100644 --- a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts +++ b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { AdeAccountMachine } from "../../../shared/types/account"; import { accountMachinePairedSyncEndpoints, @@ -8,6 +8,8 @@ import { DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL, DEVELOPMENT_ADE_CLERK_ISSUER, fetchAccountMachines, + isClerkDevelopmentIssuer, + isDevelopmentAccountDirectoryUrl, MAX_ACCOUNT_DIRECTORY_ERROR_BYTES, MAX_ACCOUNT_DIRECTORY_RESPONSE_BYTES, officialAccountDirectoryUrlForIssuer, @@ -32,6 +34,10 @@ const pollLogin = vi.hoisted(() => vi.fn()); const signOut = vi.hoisted(() => vi.fn()); const deleteMachine = vi.hoisted(() => vi.fn()); const listMachines = vi.hoisted(() => vi.fn()); +const observedDirectoryBaseUrls = vi.hoisted(() => [] as Array); +const resolveOfficialAccountDirectoryBaseUrl = vi.hoisted(() => vi.fn( + () => "https://ade-account-directory-production.arulsharma1028.workers.dev", +)); vi.mock( "../../../../../ade-cli/src/services/account/sharedAccountAuthService", @@ -44,6 +50,11 @@ vi.mock( signOut, }), registerAccountConfigProjectRoot: vi.fn(), + resolveAccountOAuthConfig: () => ({ + issuer: "https://clerk.ade-app.dev", + clientId: "prod-client", + }), + resolveOfficialAccountDirectoryBaseUrl, }), ); @@ -51,7 +62,17 @@ vi.mock( "../../../../../ade-cli/src/services/account/accountMachineDirectoryService", () => ({ AccountMachineDirectoryService: class { + private readonly options: { directoryBaseUrl(): string | null }; + + constructor( + _accountService: unknown, + options: { directoryBaseUrl(): string | null }, + ) { + this.options = options; + } + async listMachines() { + observedDirectoryBaseUrls.push(this.options.directoryBaseUrl()); return listMachines(); } @@ -66,6 +87,10 @@ vi.mock( }), ); +afterEach(() => { + vi.unstubAllEnvs(); +}); + vi.mock( "../../../../../ade-cli/src/services/projects/machineLayout", () => ({ @@ -116,6 +141,30 @@ describe("parseTrustedDirectoryBaseUrl", () => { ); }); + it("detects only parsed Clerk development hosts as development issuers", () => { + expect(isClerkDevelopmentIssuer(DEVELOPMENT_ADE_CLERK_ISSUER)).toBe(true); + expect(isClerkDevelopmentIssuer("https://another.clerk.accounts.dev")).toBe(true); + expect(isClerkDevelopmentIssuer("https://deep.tenant.clerk.accounts.dev/path")).toBe(true); + expect(isClerkDevelopmentIssuer("https://another.clerk.accounts.dev./")).toBe(true); + expect(isClerkDevelopmentIssuer(DEFAULT_ADE_CLERK_ISSUER)).toBe(false); + expect(isClerkDevelopmentIssuer("https://clerk.example.com")).toBe(false); + expect(isClerkDevelopmentIssuer("garbage")).toBe(false); + }); + + it("detects the development directory by trusted host regardless of path", () => { + expect(isDevelopmentAccountDirectoryUrl( + "https://ade-account-directory.arulsharma1028.workers.dev./tenant", + )).toBe(true); + expect(isDevelopmentAccountDirectoryUrl(DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL)).toBe(true); + expect(isDevelopmentAccountDirectoryUrl(DEFAULT_ADE_ACCOUNT_DIRECTORY_URL)).toBe(false); + expect(isDevelopmentAccountDirectoryUrl( + `${DEFAULT_ADE_ACCOUNT_DIRECTORY_URL}/tenant`, + )).toBe(false); + expect(isDevelopmentAccountDirectoryUrl( + "https://ade-account-directory.arulsharma1028.workers.dev.attacker.example/tenant", + )).toBe(false); + }); + it("accepts an https URL and normalizes trailing slashes", () => { expect(parseTrustedDirectoryBaseUrl("https://directory.ade.dev/")).toBe( "https://directory.ade.dev", @@ -374,6 +423,25 @@ describe("desktop account machine lifecycle", () => { signOut.mockReset().mockReturnValue({ ...accountStatus }); deleteMachine.mockReset(); listMachines.mockReset().mockResolvedValue({ state: "ok", machines: [], message: null }); + observedDirectoryBaseUrls.splice(0); + resolveOfficialAccountDirectoryBaseUrl.mockClear(); + }); + + it("ignores the development directory environment override when packaged", async () => { + vi.stubEnv("ADE_RUNTIME_PACKAGED", "1"); + vi.stubEnv("ADE_ALLOW_DEVELOPMENT_CLERK", ""); + vi.stubEnv("ADE_ACCOUNT_DIRECTORY_URL", DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL); + const { createAccountBridge } = await import("./accountBridge"); + const bridge = createAccountBridge({ getProjectRoot: () => null }); + + await bridge.listMachines(); + + expect(observedDirectoryBaseUrls).toEqual([DEFAULT_ADE_ACCOUNT_DIRECTORY_URL]); + expect(resolveOfficialAccountDirectoryBaseUrl).toHaveBeenCalledWith({ + env: process.env, + projectRoots: [], + }); + expect(resolveOfficialAccountDirectoryBaseUrl).toHaveBeenCalledOnce(); }); it("keeps status pure and reconciles only authoritative auth transitions", async () => { diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index b70b4cbda..626dc69d6 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -11,6 +11,7 @@ import { getSharedAccountAuthService, registerAccountConfigProjectRoot, + resolveAccountOAuthConfig, resolveOfficialAccountDirectoryBaseUrl, } from "../../../../../ade-cli/src/services/account/sharedAccountAuthService"; import { AccountMachineDirectoryService } from "../../../../../ade-cli/src/services/account/accountMachineDirectoryService"; @@ -20,7 +21,6 @@ import type { AccountAuthStatus, AccountLoginStartResult, } from "../../../../../ade-cli/src/services/account/accountAuthService"; -import { createProjectSecretService } from "../secrets/projectSecretService"; import type { AccountMachineReconciliationResult } from "../remoteRuntime/remoteConnectionService"; import type { AdeAccountMachinePairResult, @@ -30,9 +30,9 @@ import type { AdeAccountStatus, } from "../../../shared/types"; import { - DEFAULT_ADE_CLERK_ISSUER, - DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, parseTrustedAccountDirectoryBaseUrl, + shouldIgnoreDevelopmentAccountDirectoryUrl, + warnDevelopmentClerkIgnored, } from "../../../shared/accountDirectory"; type AccountBridgeOptions = { @@ -47,27 +47,18 @@ type AccountBridgeOptions = { }; }; -function readProjectSecret(projectRoot: string | null, name: string): string | null { - if (!projectRoot) return null; - try { - return createProjectSecretService(projectRoot).get({ name }).value.trim() || null; - } catch { - return null; - } -} - -/** Best-effort: is machine sign-in configured (CLERK issuer + client id present)? */ +/** + * Best-effort: is machine sign-in configured (CLERK issuer + client id present)? + * Derive it from the SAME resolver `startLogin()` uses so the packaged + * development-Clerk-ignore policy is reflected here — otherwise a stale partial + * development secret would disable the sign-in UI even though login would fall + * back to the production defaults and succeed. + */ function isLoginConfigured(projectRoot: string | null): boolean { - const projectIssuer = readProjectSecret(projectRoot, "CLERK_ISSUER"); - const projectClientId = readProjectSecret(projectRoot, "CLERK_OAUTH_CLIENT_ID"); - const envIssuer = process.env.CLERK_ISSUER?.trim() ?? ""; - const envClientId = process.env.CLERK_OAUTH_CLIENT_ID?.trim() ?? ""; - if (projectIssuer || projectClientId) { - return Boolean(projectIssuer ?? envIssuer) && Boolean(projectClientId ?? envClientId); - } - if (envIssuer || envClientId) return Boolean(envIssuer && envClientId); - const issuer = DEFAULT_ADE_CLERK_ISSUER; - const clientId = DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID; + const { issuer, clientId } = resolveAccountOAuthConfig({ + env: process.env, + projectRoots: projectRoot ? [projectRoot] : [], + }); return Boolean(issuer && clientId); } @@ -101,6 +92,13 @@ export function parseTrustedDirectoryBaseUrl( function resolveDirectoryBaseUrl(projectRoot: string | null): string | null { const machineOverride = process.env.ADE_ACCOUNT_DIRECTORY_URL; if (machineOverride?.trim()) { + if (shouldIgnoreDevelopmentAccountDirectoryUrl(machineOverride, process.env)) { + warnDevelopmentClerkIgnored(); + return resolveOfficialAccountDirectoryBaseUrl({ + env: process.env, + projectRoots: projectRoot ? [projectRoot] : [], + }); + } return parseTrustedAccountDirectoryBaseUrl(machineOverride); } return resolveOfficialAccountDirectoryBaseUrl({ diff --git a/apps/desktop/src/shared/accountDirectory.ts b/apps/desktop/src/shared/accountDirectory.ts index 2802e3524..7a8d94489 100644 --- a/apps/desktop/src/shared/accountDirectory.ts +++ b/apps/desktop/src/shared/accountDirectory.ts @@ -27,6 +27,74 @@ export const DEVELOPMENT_ADE_CLERK_ISSUER = export const DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID = "d6pUGxQXTqIMYl5w"; export const DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL = "https://ade-account-directory.arulsharma1028.workers.dev"; +const DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_HOST = new URL( + DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL, +).hostname.replace(/\.$/, "").toLowerCase(); + +const DEVELOPMENT_CLERK_IGNORED_WARNING = + "[account] Ignoring development Clerk configuration in a packaged build; using ADE production. Set ADE_ALLOW_DEVELOPMENT_CLERK=1 to override."; +let warnedDevClerkIgnored = false; + +export function isPackagedRuntime(env: NodeJS.ProcessEnv): boolean { + return env.ADE_RUNTIME_PACKAGED === "1"; +} + +export function developmentClerkOverrideAllowed(env: NodeJS.ProcessEnv): boolean { + return env.ADE_ALLOW_DEVELOPMENT_CLERK === "1"; +} + +export function shouldIgnoreDevelopmentClerkConfiguration( + env: NodeJS.ProcessEnv, +): boolean { + return isPackagedRuntime(env) && !developmentClerkOverrideAllowed(env); +} + +export function warnDevelopmentClerkIgnored(): void { + if (warnedDevClerkIgnored) return; + warnedDevClerkIgnored = true; + console.warn(DEVELOPMENT_CLERK_IGNORED_WARNING); +} + +export function isClerkDevelopmentOAuthClientId( + clientId: string | null | undefined, +): boolean { + return clientId?.trim() === DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID; +} + +export function isClerkDevelopmentIssuer( + issuer: string | null | undefined, +): boolean { + const trimmed = issuer?.trim(); + if (!trimmed) return false; + let url: URL; + try { + url = new URL(trimmed); + } catch { + return false; + } + url.hostname = url.hostname.replace(/\.$/, ""); + const normalizedIssuer = `${url.origin}${url.pathname.replace(/\/+$/, "")}`; + return url.hostname.toLowerCase().endsWith(".clerk.accounts.dev") + || normalizedIssuer === DEVELOPMENT_ADE_CLERK_ISSUER; +} + +export function isDevelopmentAccountDirectoryUrl( + url: string | null | undefined, +): boolean { + const trustedUrl = parseTrustedAccountDirectoryBaseUrl(url); + if (!trustedUrl) return false; + const parsed = new URL(trustedUrl); + const normalizedHost = parsed.hostname.replace(/\.$/, "").toLowerCase(); + return normalizedHost === DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_HOST; +} + +export function shouldIgnoreDevelopmentAccountDirectoryUrl( + url: string | null | undefined, + env: NodeJS.ProcessEnv, +): boolean { + return shouldIgnoreDevelopmentClerkConfiguration(env) + && isDevelopmentAccountDirectoryUrl(url); +} function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff9b0c1cc..598a82d1a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -137,7 +137,7 @@ Product positioning and workflows live in [`docs/PRD.md`](../docs/PRD.md). This **Session identity.** The runtime resolves caller role from ADE context env vars and command flags. Role vocabulary: `cto`, `orchestrator`, `agent`, `external`, `evaluator`. Browser automation adds a separate bearer capability: ADE-launched chat and owned-terminal environments receive an opaque `ADE_BROWSER_ACTOR_TOKEN` bound in Electron memory to that chat's trusted lane/project or personal tab collection. The runtime requires the token, strips caller-supplied routing, and carries it only over the authenticated desktop bridge. Electron validates it in the same process that issued it before restoring the bound scope; role alone never grants access to a human-authenticated browser profile. -**Optional ADE account auth.** `ade login` preserves the local-browser loopback OAuth path, but selects the account-directory device authorization bridge for explicit `--headless`, SSH, display-less hosts, or a failed browser launch. The brain generates and retains the device redemption secret, polls the bridge, and persists the resulting refresh-capable session under `account.session.v1`. For a JWT access token, its decoded `exp` claim is authoritative over the OAuth `expires_in` bookkeeping: status reports that expiry, and `getAccessToken()` refreshes inside the two-minute skew even when an older stored session record claims a later expiry. Tokens without a usable JWT expiry retain the stored `expiresAt` fallback. The desktop and brain share the encrypted session file and may race a rotating refresh credential; after an OAuth `invalid_grant`, the loser re-reads persistence and retries once only when another process has written a different refresh token. Other refresh failures are not replayed, and raw tokens are never logged. `ADE_ACCOUNT_TOKEN` takes precedence without starting a login flow: JWT access credentials are used through their declared expiry, while refresh credentials are exchanged and rotated only in memory. `ade account token create` wraps the current interactive refresh credential with its public issuer/client context in a versioned secret envelope, so a newly provisioned agent or CI host needs no local Clerk configuration. Legacy raw opaque refresh tokens retain local-config compatibility and return migration guidance when that config is absent. The desktop Account page exposes one honest browser continuation because the bridge opens the generic hosted account flow rather than selecting a provider; the browser presents whichever methods are enabled. Native iOS uses ClerkKit's transferable OAuth result to distinguish new accounts from returning users. Its identifier-first email path starts sign-in, falls back to sign-up only for Clerk's precise account-not-found codes, sends the sign-up email verification code, and verifies against the matching sign-in or sign-up attempt. Account status exposes `loopback`, `device`, or `env-token`; signed-out state never gates local projects, `ade code`, local pairing, or PIN workflows. +**Optional ADE account auth.** `ade login` preserves the local-browser loopback OAuth path, but selects the account-directory device authorization bridge for explicit `--headless`, SSH, display-less hosts, or a failed browser launch. The brain generates and retains the device redemption secret, polls the bridge, and persists the resulting refresh-capable session under `account.session.v1`. For a JWT access token, its decoded `exp` claim is authoritative over the OAuth `expires_in` bookkeeping: status reports that expiry, and `getAccessToken()` refreshes inside the two-minute skew even when an older stored session record claims a later expiry. Tokens without a usable JWT expiry retain the stored `expiresAt` fallback. The desktop and brain share the encrypted session file and may race a rotating refresh credential; after an OAuth `invalid_grant`, the loser re-reads persistence and retries once only when another process has written a different refresh token. Other refresh failures are not replayed, and raw tokens are never logged. `ADE_ACCOUNT_TOKEN` takes precedence without starting a login flow: JWT access credentials are used through their declared expiry, while refresh credentials are exchanged and rotated only in memory. `ade account token create` wraps the current interactive refresh credential with its public issuer/client context in a versioned secret envelope, so a newly provisioned agent or CI host needs no local Clerk configuration. Legacy raw opaque refresh tokens retain local-config compatibility and return migration guidance when that config is absent. Distributed CLI/brain binaries and packaged Electron set `ADE_RUNTIME_PACKAGED=1` before account services start. In that mode, a Clerk issuer or JWKS URL under `*.clerk.accounts.dev`, plus the exact ADE development directory override, is rejected atomically in favor of the complete built-in production OAuth, attestation, and directory configuration; a non-development custom issuer remains valid, and source checkouts retain their existing override behavior. Persisted sessions pinned to a development issuer/client, sessions carrying a development `iss` access-token claim, and equivalent `ADE_ACCOUNT_TOKEN` credentials are rejected before token return, refresh, userinfo, or directory use. A rejected environment credential is treated as absent by status, access-token resolution, interactive login, device login, and durable-token provisioning, so it cannot block a new production sign-in. When the credential store supports atomic updates, a persisted development session is compare-and-deleted, then persistence is re-read exactly once: a peer-written acceptable production replacement is returned in the same status call. Without compare-and-delete support, ADE leaves the stored value untouched to avoid erasing a peer write but continues to report that development session as signed out. `ADE_ALLOW_DEVELOPMENT_CLERK=1` is the explicit packaged-build escape hatch for controlled development testing. The desktop Account page exposes one honest browser continuation because the bridge opens the generic hosted account flow rather than selecting a provider; the browser presents whichever methods are enabled. Native iOS uses ClerkKit's transferable OAuth result to distinguish new accounts from returning users. Its identifier-first email path starts sign-in, falls back to sign-up only for Clerk's precise account-not-found codes, sends the sign-up email verification code, and verifies against the matching sign-in or sign-up attempt. Account status exposes `loopback`, `device`, or `env-token`; signed-out state never gates local projects, `ade code`, local pairing, or PIN workflows. **Action surface.** First-class command families cover lanes (including `ade lanes link-linear-issue` / `detach-linear-issue` for post-creation Linear issue linking, and `ade lanes create-from-linear` / `batch-create-from-linear` to spin up one or many issue lanes — optionally launching an agent chat with `--start-chat`), git, diffs, files, PRs, runs, shells, chats (including `ade chat create --prompt` for a persistent Work chat followed by an initial chat message, `ade chat send` / `message` / `steer` / `wait` for peer chat delivery and status polling, `ade chat read ` for recent transcript messages, `ade chat scheduled-work create --cron "" --prompt "" [--once]` for durable provider-neutral scheduling, `ade chat create --from-linear-issue `, `ade chat attach-linear-issue` / `detach-linear-issue` / `linear-issues` for session-scoped issue attachment, and `--parent ` / `--no-parent` to control child-chat lineage — a chat created via `ade chat create` / `ade new --mode chat` defaults its parent to `$ADE_CHAT_SESSION_ID` (the spawning agent's own chat, injected into every tracked agent shell) so it lists in the parent's subagents panel instead of becoming an orphan, and `--no-parent` opts out), agents, CTO, Linear (the write bridge an attached CLI agent uses: `ade linear attach` / `detach` / `issues` / `issue` / `comment` / `set-state` / `assign` / `label`, with `--this-session` resolving the issue id from `$ADE_LINEAR_ISSUE_IDS` so a launched agent needs no Linear token — see [features/linear-integration/README.md](./features/linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection)), tests, proof, settings, the iOS Simulator (`ade ios-sim` / `ade ios` / `ade simulator` — see [features/ios-simulator/README.md](./features/ios-simulator/README.md)), the Cursor Cloud bridge (`ade cursor cloud agents | runs | artifacts | repos | models | me` — talks directly to `@cursor/sdk` without going through the ADE runtime endpoint), the App Control bridge for Electron apps (`ade app-control` / `ade app` / `ade electron` — `launch`, `connect`, `stop`, `status`, `screenshot`, `snapshot`, `inspect`, `select`, `click`, `type`, `scroll`, `key`, `targets`, `attach`, `logs`, `terminal write`, `terminal signal` — see [features/computer-use/app-control.md](./features/computer-use/app-control.md)), the chat-scoped terminal (`ade terminal list` / `read` / `write` / `signal` / `active`), universal search (`ade search ""` over chats, terminals, PRs, commits, branches, lanes, files, and Linear — see [features/search/README.md](./features/search/README.md)), and a generic `ade actions run ` escape hatch for every registered ADE service action. The chat action surface includes `chat.createSession`, `chat.sendMessage` (low-level normal-turn send), `chat.messageSession` (normalized peer delivery: auto, queue, wake, interrupt-replace), `chat.readTranscript`, `chat.createScheduledWork`, `chat.listScheduledWork`, `chat.getScheduledWorkState`, `chat.cancelScheduledWork`, `chat.setScheduledWorkPaused`, and model-catalog actions; session-bound non-CTO callers are restricted to their own eligible session for every scheduled-work read or mutation (a chat or ADE-tracked provider CLI), and to their own chat for `chat.sendMessage` / `chat.readTranscript`, while `chat.messageSession` is the reviewed primitive for deliberately messaging another ADE chat through routing semantics. The action allow-list adds three domains for these surfaces: `app_control` (every public method on `AppControlService`), `terminal` (`list`, `read`, `write`, `signal`, `activeForChat` against `ptyService`), named iOS Simulator actions for launch, live view, inspection, input, and Preview Lab workflows, and `search` (`query`, `indexStatus`, and the CTO-only `rebuildIndex` against `searchService`; session-bound non-CTO callers get chat/terminal hits scoped to their own session). diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 49a5a080a..461d725e9 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -227,7 +227,23 @@ Runtime support files outside `services/sync/`: the account bearer. The publisher and desktop account bridge derive the official directory from the same project-aware Clerk issuer resolver, while the machine-owned `ADE_ACCOUNT_DIRECTORY_URL` override remains fail-closed - behind the trusted-origin parser. + behind the trusted-origin parser. In a packaged runtime, an explicit override + of that publisher URL to ADE's development directory is ignored and resolves + to the production directory instead. This follows the same atomic packaged + Clerk policy as OAuth and attestation resolution: the distributed CLI/brain + and Electron entry points set `ADE_RUNTIME_PACKAGED=1`; development Clerk + hosts cannot produce a mixed development/production configuration; + persisted or environment-provided credentials pinned to a development + issuer/client (including an access-token development `iss` claim) are rejected + before refresh or publication; rejected environment credentials are treated + as absent so they do not block a production login; persisted development + sessions are compare-and-deleted only when the store supports an atomic + update, followed by one re-read that can surface a peer-written production + replacement in the same status call; stores without that primitive leave the + value untouched but continue to report it as signed out; and + `ADE_ALLOW_DEVELOPMENT_CLERK=1` is the explicit controlled-testing escape + hatch. Source-checkout runtimes and non-development custom issuers keep their + existing override behavior. - `apps/desktop/src/shared/accountDirectory.ts` — canonical account-directory origin, bounded success/error response decoding, route allowlisting, machine selection, and paired endpoint validation shared by desktop, the brain, ADE