From 888a06e9360111a6e8db4a3f68d5e3e574cefc32 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:41:42 -0400 Subject: [PATCH 1/7] harden(account): ignore development Clerk config in packaged builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A developer project that sets CLERK_ISSUER/CLERK_JWKS_URL/ CLERK_OAUTH_CLIENT_ID (or ADE_ACCOUNT_DIRECTORY_URL) to ADE's development Clerk instance leaked into the packaged production app's brain, which then authenticated against dev Clerk and got a 401 "invalid token" from the production account directory. Packaged builds now set ADE_RUNTIME_PACKAGED=1 (bootstrap.ts via the existing source-checkout check; desktop main.ts via app.isPackaged). When set, the account resolvers discard any Clerk override that resolves to a development instance (*.clerk.accounts.dev, incl. trailing-dot aliases) or the development account directory — atomically snapping issuer, JWKS, client, and directory back to the built-in production defaults (never a hybrid) and logging one warning. The machine publisher's directory override is guarded the same way. Source-checkout/dev builds are unchanged; ADE_ALLOW_DEVELOPMENT_CLERK=1 is a deliberate escape hatch. Non-development custom issuer overrides are still honored. Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/bootstrap.ts | 7 + .../accountMachinePublisherService.test.ts | 65 ++++++ .../account/accountMachinePublisherService.ts | 11 +- .../account/sharedAccountAuthService.test.ts | 190 ++++++++++++++++++ .../account/sharedAccountAuthService.ts | 82 ++++++-- apps/desktop/src/main/main.ts | 5 + .../account/accountBridge.trust.test.ts | 60 +++++- .../main/services/account/accountBridge.ts | 9 + apps/desktop/src/shared/accountDirectory.ts | 60 ++++++ docs/ARCHITECTURE.md | 2 +- docs/features/sync-and-multi-device/README.md | 10 +- 11 files changed, 484 insertions(+), 17 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index be1c73425..916ff2465 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -314,6 +314,13 @@ function isSourceCheckoutRuntimeModule(modulePath: string): boolean { 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/services/account/accountMachinePublisherService.test.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts index 0b1ef1fc8..53cc3ef24 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) => diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts index 4a8e1833b..6b3727511 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, @@ -521,7 +523,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 +548,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..6643f97da 100644 --- a/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts @@ -184,6 +184,73 @@ 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", + }); + }); }); describe("getSharedAccountAttestationConfig", () => { @@ -219,6 +286,90 @@ 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); + }); + + 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 +391,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..2cbfae31c 100644 --- a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts @@ -4,7 +4,11 @@ import { DEFAULT_ADE_CLERK_ISSUER, DEFAULT_ADE_CLERK_JWKS_URL, DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, + isClerkDevelopmentIssuer, officialAccountDirectoryUrlForIssuer, + shouldIgnoreDevelopmentAccountDirectoryUrl, + shouldIgnoreDevelopmentClerkConfiguration, + warnDevelopmentClerkIgnored, } from "../../../../desktop/src/shared/accountDirectory"; import { EncryptedFileCredentialStore } from "../credentials/credentialStore"; import { resolveMachineAdeLayout } from "../projects/machineLayout"; @@ -68,6 +72,42 @@ function readProjectSecret(projectRoot: string, name: string): string | null { } } +function enforcePackagedOAuthConfig( + env: NodeJS.ProcessEnv, + config: AccountOAuthConfig, +): AccountOAuthConfig { + if ( + !shouldIgnoreDevelopmentClerkConfiguration(env) + || !isClerkDevelopmentIssuer(config.issuer) + ) { + 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)) + ) { + 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 +119,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 +151,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 +183,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..b3914817a 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,7 @@ vi.mock( signOut, }), registerAccountConfigProjectRoot: vi.fn(), + resolveOfficialAccountDirectoryBaseUrl, }), ); @@ -51,7 +58,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 +83,10 @@ vi.mock( }), ); +afterEach(() => { + vi.unstubAllEnvs(); +}); + vi.mock( "../../../../../ade-cli/src/services/projects/machineLayout", () => ({ @@ -116,6 +137,24 @@ 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("normalizes a terminal DNS dot when detecting the development directory", () => { + expect(isDevelopmentAccountDirectoryUrl( + "https://ade-account-directory.arulsharma1028.workers.dev./", + )).toBe(true); + expect(isDevelopmentAccountDirectoryUrl(DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL)).toBe(true); + expect(isDevelopmentAccountDirectoryUrl(DEFAULT_ADE_ACCOUNT_DIRECTORY_URL)).toBe(false); + }); + it("accepts an https URL and normalizes trailing slashes", () => { expect(parseTrustedDirectoryBaseUrl("https://directory.ade.dev/")).toBe( "https://directory.ade.dev", @@ -374,6 +413,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..525ff9e8c 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -33,6 +33,8 @@ import { DEFAULT_ADE_CLERK_ISSUER, DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, parseTrustedAccountDirectoryBaseUrl, + shouldIgnoreDevelopmentAccountDirectoryUrl, + warnDevelopmentClerkIgnored, } from "../../../shared/accountDirectory"; type AccountBridgeOptions = { @@ -101,6 +103,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..e64bab231 100644 --- a/apps/desktop/src/shared/accountDirectory.ts +++ b/apps/desktop/src/shared/accountDirectory.ts @@ -28,6 +28,66 @@ 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_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 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); + parsed.hostname = parsed.hostname.replace(/\.$/, ""); + const normalizedUrl = `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`; + return normalizedUrl === DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL; +} + +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..0c28e6998 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. `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..9cc163c2c 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -227,7 +227,15 @@ 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; 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 From fc8edb18a29b3af3562a2c5ba30b09cc6f7ba927 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:09:37 -0400 Subject: [PATCH 2/7] harden(account): fix packaged detection + close directory guard gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review findings: - Packaged detection keyed off the module filename (bootstrap.cjs), but bootstrap is bundled into main.cjs/cli.cjs, so dev builds running built bundles misidentified as packaged and would auth against production Clerk. Extract isSourceCheckoutRuntimeModule to runtimePackaging.ts and make it directory-based (matches an apps/{ade-cli,desktop}/{src,dist}/ path segment) — packaged resources have no such segment. - Route accountMachineDirectoryService's raw ADE_ACCOUNT_DIRECTORY_URL env fallback through the dev-directory guard (was a latent bypass). - Match the development account directory by host, so a dev-host URL with an extra path/subdomain is also caught. Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/bootstrap.test.ts | 19 ++++++++++++ apps/ade-cli/src/bootstrap.ts | 5 +--- apps/ade-cli/src/runtimePackaging.ts | 5 ++++ .../accountMachineDirectoryService.test.ts | 30 ++++++++++++++++++- .../account/accountMachineDirectoryService.ts | 12 ++++++-- .../account/accountBridge.trust.test.ts | 10 +++++-- apps/desktop/src/shared/accountDirectory.ts | 8 +++-- 7 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 apps/ade-cli/src/runtimePackaging.ts 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 916ff2465..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,10 +308,6 @@ 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); 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/accountMachineDirectoryService.test.ts b/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts index 41b9ff88f..ccc1b9294 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,31 @@ 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("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..00e571457 100644 --- a/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts +++ b/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts @@ -16,6 +16,7 @@ import { fetchAccountMachines, resolveTrustedAccountDirectoryBaseUrl, selectAccountMachine, + shouldIgnoreDevelopmentAccountDirectoryUrl, } from "../../../../desktop/src/shared/accountDirectory"; import type { AccountAuthService } from "./accountAuthService"; import { defaultRelayUrl } from "../sync/syncCloudRelayStore"; @@ -106,6 +107,13 @@ export type AccountMachineListOptions = { export type AccountMachineDeleteOptions = AccountMachineListOptions; +function rawAccountDirectoryEnvironmentFallback(): string | undefined { + const rawUrl = process.env.ADE_ACCOUNT_DIRECTORY_URL; + return shouldIgnoreDevelopmentAccountDirectoryUrl(rawUrl, process.env) + ? undefined + : rawUrl; +} + export class AccountMachineDirectoryService { constructor( private readonly account: Pick, @@ -137,7 +145,7 @@ export class AccountMachineDirectoryService { return await fetchAccountMachines({ baseUrl: resolveTrustedAccountDirectoryBaseUrl( this.options.directoryBaseUrl?.() - ?? process.env.ADE_ACCOUNT_DIRECTORY_URL, + ?? rawAccountDirectoryEnvironmentFallback(), ), accessToken: token, fetchImpl: this.options.fetchImpl, @@ -166,7 +174,7 @@ 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, + this.options.directoryBaseUrl?.() ?? rawAccountDirectoryEnvironmentFallback(), ); if (!baseUrl) { throw new Error( 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 b3914817a..4ce23672d 100644 --- a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts +++ b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts @@ -147,12 +147,18 @@ describe("parseTrustedDirectoryBaseUrl", () => { expect(isClerkDevelopmentIssuer("garbage")).toBe(false); }); - it("normalizes a terminal DNS dot when detecting the development directory", () => { + it("detects the development directory by trusted host regardless of path", () => { expect(isDevelopmentAccountDirectoryUrl( - "https://ade-account-directory.arulsharma1028.workers.dev./", + "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", () => { diff --git a/apps/desktop/src/shared/accountDirectory.ts b/apps/desktop/src/shared/accountDirectory.ts index e64bab231..140492e25 100644 --- a/apps/desktop/src/shared/accountDirectory.ts +++ b/apps/desktop/src/shared/accountDirectory.ts @@ -27,6 +27,9 @@ 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."; @@ -75,9 +78,8 @@ export function isDevelopmentAccountDirectoryUrl( const trustedUrl = parseTrustedAccountDirectoryBaseUrl(url); if (!trustedUrl) return false; const parsed = new URL(trustedUrl); - parsed.hostname = parsed.hostname.replace(/\.$/, ""); - const normalizedUrl = `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`; - return normalizedUrl === DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_URL; + const normalizedHost = parsed.hostname.replace(/\.$/, "").toLowerCase(); + return normalizedHost === DEVELOPMENT_ADE_ACCOUNT_DIRECTORY_HOST; } export function shouldIgnoreDevelopmentAccountDirectoryUrl( From f3b37039d0281db689d33a1488dffd4564bcfd2b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:31:23 -0400 Subject: [PATCH 3/7] harden(account): also reject the development OAuth client id The packaged-build enforcement discarded dev issuers and dev JWKS hosts but not the development OAuth client id, so a custom issuer paired with DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID survived. Treat the dev client id as an independent development-instance marker in both enforcement functions, replacing the whole tuple with production defaults. Tests cover a dev client id behind a custom issuer/JWKS for OAuth and attestation. Co-Authored-By: Claude Opus 4.8 --- .../account/sharedAccountAuthService.test.ts | 24 +++++++++++++++++++ .../account/sharedAccountAuthService.ts | 7 ++++-- apps/desktop/src/shared/accountDirectory.ts | 6 +++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts b/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts index 6643f97da..2be18095c 100644 --- a/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.test.ts @@ -251,6 +251,20 @@ describe("getSharedAccountAuthService resolves CLERK OAuth config as an atomic p 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", () => { @@ -324,6 +338,16 @@ describe("getSharedAccountAttestationConfig", () => { 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", () => { diff --git a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts index 2cbfae31c..4178a7255 100644 --- a/apps/ade-cli/src/services/account/sharedAccountAuthService.ts +++ b/apps/ade-cli/src/services/account/sharedAccountAuthService.ts @@ -5,6 +5,7 @@ import { DEFAULT_ADE_CLERK_JWKS_URL, DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, isClerkDevelopmentIssuer, + isClerkDevelopmentOAuthClientId, officialAccountDirectoryUrlForIssuer, shouldIgnoreDevelopmentAccountDirectoryUrl, shouldIgnoreDevelopmentClerkConfiguration, @@ -78,7 +79,8 @@ function enforcePackagedOAuthConfig( ): AccountOAuthConfig { if ( !shouldIgnoreDevelopmentClerkConfiguration(env) - || !isClerkDevelopmentIssuer(config.issuer) + || (!isClerkDevelopmentIssuer(config.issuer) + && !isClerkDevelopmentOAuthClientId(config.clientId)) ) { return config; } @@ -96,7 +98,8 @@ function enforcePackagedAttestationConfig( if ( !shouldIgnoreDevelopmentClerkConfiguration(env) || (!isClerkDevelopmentIssuer(config.issuer) - && !isClerkDevelopmentIssuer(config.jwksUrl)) + && !isClerkDevelopmentIssuer(config.jwksUrl) + && !isClerkDevelopmentOAuthClientId(config.oauthClientId)) ) { return config; } diff --git a/apps/desktop/src/shared/accountDirectory.ts b/apps/desktop/src/shared/accountDirectory.ts index 140492e25..7a8d94489 100644 --- a/apps/desktop/src/shared/accountDirectory.ts +++ b/apps/desktop/src/shared/accountDirectory.ts @@ -55,6 +55,12 @@ export function warnDevelopmentClerkIgnored(): void { 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 { From b124efac68892eedb6750020e368fe22420a97f2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:53:29 -0400 Subject: [PATCH 4/7] harden(account): derive sign-in availability from the hardened resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isLoginConfigured() read the raw CLERK_* values, so in a packaged build a stale partial development secret (issuer or client id only) reported configured=false and disabled the Account-page sign-in — even though the hardened resolver would fall back to production and log in fine. Derive it from the same resolveAccountOAuthConfig() the login flow uses (which applies the packaged development-Clerk-ignore policy), and drop the now dead readProjectSecret helper. Co-Authored-By: Claude Opus 4.8 --- .../account/accountBridge.trust.test.ts | 4 +++ .../main/services/account/accountBridge.ts | 35 +++++++------------ 2 files changed, 16 insertions(+), 23 deletions(-) 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 4ce23672d..b14959538 100644 --- a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts +++ b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts @@ -50,6 +50,10 @@ vi.mock( signOut, }), registerAccountConfigProjectRoot: vi.fn(), + resolveAccountOAuthConfig: () => ({ + issuer: "https://clerk.ade-app.dev", + clientId: "prod-client", + }), resolveOfficialAccountDirectoryBaseUrl, }), ); diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index 525ff9e8c..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,8 +30,6 @@ import type { AdeAccountStatus, } from "../../../shared/types"; import { - DEFAULT_ADE_CLERK_ISSUER, - DEFAULT_ADE_CLERK_OAUTH_CLIENT_ID, parseTrustedAccountDirectoryBaseUrl, shouldIgnoreDevelopmentAccountDirectoryUrl, warnDevelopmentClerkIgnored, @@ -49,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); } From 48ddd8ee554193c57a409068a14cecdf158f20d0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:26:35 -0400 Subject: [PATCH 5/7] harden(account): reject stored development sessions in packaged builds The config resolvers were hardened, but a stored account.session.v1 created against dev Clerk bypassed them: getAccessToken() returned the stored dev access token directly and refreshed against the stored dev oauthConfig, so a packaged build with a pre-existing dev session kept hitting the production directory with a dev token (401) until manual sign-out. In packaged mode (and equally for ADE_ACCOUNT_TOKEN credentials), reject any session whose stored oauthConfig issuer/client is a development instance or whose access-token `iss` claim is a dev host, before token return, refresh, userinfo, or directory use. A persisted dev session is compare-and-deleted from the shared credential store (only when the stored value still matches what we read) so status flips to signed-out without erasing a newer peer-written session, and the user re-signs-in against production. ADE_ALLOW_DEVELOPMENT_CLERK=1 still keeps dev working; source checkouts are unchanged. Swept the whole account surface (getAccessToken, refresh, status, userinfo, env-token, publisher, directory) for equivalent bypasses. Co-Authored-By: Claude Opus 4.8 --- .../account/accountAuthService.test.ts | 297 ++++++++++++++++++ .../services/account/accountAuthService.ts | 225 +++++++++++-- .../accountMachineDirectoryService.test.ts | 27 ++ .../account/accountMachineDirectoryService.ts | 23 +- .../accountMachinePublisherService.test.ts | 23 ++ .../account/accountMachinePublisherService.ts | 11 +- docs/ARCHITECTURE.md | 2 +- docs/features/sync-and-multi-device/README.md | 6 +- 8 files changed, 570 insertions(+), 44 deletions(-) diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index 07ea0f9f0..c248481d3 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -1,5 +1,11 @@ import { createHash } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + 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 +146,210 @@ 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; + + 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(); + 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().signedIn).toBe(true); + await expect(service.getAccessToken()).resolves.toBe(productionSession.accessToken); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBe(JSON.stringify(productionSession)); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); + describe("AccountAuthService CLERK_ISSUER scheme enforcement", () => { function serviceForIssuer(issuer: string): AccountAuthService { const service = createAccountAuthService({ @@ -319,6 +529,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 +1359,52 @@ 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")}`, + }, + ])("rejects a packaged ADE_ACCOUNT_TOKEN with $name without using it", async ({ credential }) => { + const store = new MemoryCredentialStore(); + 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: credential, + } as NodeJS.ProcessEnv, + now: () => Date.parse("2026-07-14T12:00:00.000Z"), + fetchImpl, + }); + activeServices.push(service); + + expect(service.getStatus()).toMatchObject({ + signedIn: false, + userId: null, + source: "env-token", + }); + await expect(service.getAccessToken()).rejects.toThrow(/development Clerk/i); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)).toBeNull(); + }); + 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..dc0768f6f 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"; @@ -356,6 +391,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; @@ -664,6 +716,9 @@ export function createAccountAuthService(args: { const readEnvCredential = (): string | null => readNonEmptyString(env[ACCOUNT_TOKEN_ENV_KEY]); + const resolveOAuthConfig = async (): Promise => + normalizeRuntimeOAuthConfig(await args.getOAuthConfig(), env); + const resetEnvSessionIfCredentialChanged = ( credential: string, inspected: EnvCredential, @@ -676,9 +731,36 @@ export function createAccountAuthService(args: { envRefreshToken = inspected.kind === "refresh_token" ? inspected.token : null; }; + 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 envCredentialStatus = (credential: string): AccountAuthStatus => { const inspected = inspectEnvCredential(credential); resetEnvSessionIfCredentialChanged(credential, inspected); + if (rejectDevelopmentEnvCredential(inspected)) { + return { + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + source: "env-token", + }; + } 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 +781,65 @@ 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): boolean => { + const updateSync = args.credentialStore.updateSync; + let invalidated = false; + if (updateSync) { + updateSync.call(args.credentialStore, (values) => { + if (values[ACCOUNT_SESSION_CREDENTIAL_KEY] !== raw) return false; + delete values[ACCOUNT_SESSION_CREDENTIAL_KEY]; + invalidated = true; + return true; + }); + } else if (args.credentialStore.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY) === raw) { + args.credentialStore.deleteSync(ACCOUNT_SESSION_CREDENTIAL_KEY); + invalidated = true; + } + if (invalidated) { + authEpoch += 1; + lastObservedSignedIn = false; + sessionReadState = "missing"; + warnDevelopmentClerkIgnored(); + } + return invalidated; + }; + 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. + for (let attempt = 0; attempt < 2; attempt += 1) { + 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"; + if (!session || !stored) return session; + if (!shouldRejectDevelopmentAccountMaterial({ + env, + accessToken: session.accessToken, + oauthConfig: session.oauthConfig, + })) { + return session; + } + if (invalidateStoredSessionIfCurrent(stored)) return null; + } + sessionReadState = "unreadable"; + return null; } catch (error) { sessionReadState = "unreadable"; logger.warn("account.session_read_failed", { @@ -730,15 +859,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 +916,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 +971,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); @@ -961,7 +1099,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 +1195,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, @@ -1293,12 +1436,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; @@ -1371,6 +1525,11 @@ export function createAccountAuthService(args: { if (envCredential && !record?.suppressEnvCredential) { const inspected = inspectEnvCredential(envCredential); resetEnvSessionIfCredentialChanged(envCredential, inspected); + if (rejectDevelopmentEnvCredential(inspected)) { + throw new Error( + "ADE_ACCOUNT_TOKEN uses development Clerk and is disabled in this packaged build. Replace it with an ADE production token.", + ); + } 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 +1567,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.", @@ -1475,7 +1634,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, @@ -1540,7 +1699,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 ccc1b9294..d8d86fb0d 100644 --- a/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachineDirectoryService.test.ts @@ -189,6 +189,33 @@ describe("AccountMachineDirectoryService", () => { } }); + 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 00e571457..1319535ea 100644 --- a/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts +++ b/apps/ade-cli/src/services/account/accountMachineDirectoryService.ts @@ -17,6 +17,7 @@ import { resolveTrustedAccountDirectoryBaseUrl, selectAccountMachine, shouldIgnoreDevelopmentAccountDirectoryUrl, + warnDevelopmentClerkIgnored, } from "../../../../desktop/src/shared/accountDirectory"; import type { AccountAuthService } from "./accountAuthService"; import { defaultRelayUrl } from "../sync/syncCloudRelayStore"; @@ -107,11 +108,14 @@ export type AccountMachineListOptions = { export type AccountMachineDeleteOptions = AccountMachineListOptions; -function rawAccountDirectoryEnvironmentFallback(): string | undefined { - const rawUrl = process.env.ADE_ACCOUNT_DIRECTORY_URL; - return shouldIgnoreDevelopmentAccountDirectoryUrl(rawUrl, process.env) - ? undefined - : rawUrl; +function packagedSafeAccountDirectoryOverride( + rawUrl: string | null | undefined, +): string | undefined { + if (shouldIgnoreDevelopmentAccountDirectoryUrl(rawUrl, process.env)) { + warnDevelopmentClerkIgnored(); + return undefined; + } + return rawUrl ?? undefined; } export class AccountMachineDirectoryService { @@ -144,8 +148,9 @@ export class AccountMachineDirectoryService { } return await fetchAccountMachines({ baseUrl: resolveTrustedAccountDirectoryBaseUrl( - this.options.directoryBaseUrl?.() - ?? rawAccountDirectoryEnvironmentFallback(), + packagedSafeAccountDirectoryOverride( + this.options.directoryBaseUrl?.() ?? process.env.ADE_ACCOUNT_DIRECTORY_URL, + ), ), accessToken: token, fetchImpl: this.options.fetchImpl, @@ -174,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?.() ?? rawAccountDirectoryEnvironmentFallback(), + 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 53cc3ef24..9ebde9e94 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.test.ts @@ -447,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 6b3727511..747232eb2 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts @@ -231,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; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0c28e6998..befd87055 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. 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. `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. +**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 persisted development session is compare-and-deleted from the shared credential store so status immediately becomes signed out without erasing a newer peer-written session. `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 9cc163c2c..87f619a72 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -232,7 +232,11 @@ Runtime support files outside `services/sync/`: 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; and + 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, with persisted sessions cleared to 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. From ba272fc812940a3d9456819c55d4dc4f326412fa Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:50:08 -0400 Subject: [PATCH 6/7] harden(account): make dev-session invalidation race-safe and signed-out Two review findings on the stored-session rejection: - The updateSync-less fallback in invalidateStoredSessionIfCurrent did a non-atomic getSync-then-deleteSync, which could delete a production credential a peer wrote between the two calls. Drop the non-atomic delete: use atomic compare-and-delete when available, otherwise leave the value in place and reject the development session on every read. - A rejected development ADE_ACCOUNT_TOKEN reported source: "env-token" with signedIn: false, so downstream treated it as an active env credential and blocked production re-auth. Report a fully signed-out status (source: null) instead. Adds coverage for a store without atomic compare-and-delete (rejected, not clobbered). Co-Authored-By: Claude Opus 4.8 --- .../account/accountAuthService.test.ts | 36 ++++++++++++++++++- .../services/account/accountAuthService.ts | 33 +++++++++-------- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index c248481d3..fa9c17c5d 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -348,6 +348,40 @@ describe("AccountAuthService packaged development-session policy", () => { 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 }); + 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", () => { @@ -1398,7 +1432,7 @@ describe("AccountAuthService ADE_ACCOUNT_TOKEN", () => { expect(service.getStatus()).toMatchObject({ signedIn: false, userId: null, - source: "env-token", + source: null, }); await expect(service.getAccessToken()).rejects.toThrow(/development Clerk/i); expect(fetchImpl).not.toHaveBeenCalled(); diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts index dc0768f6f..b0838450b 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -752,13 +752,17 @@ export function createAccountAuthService(args: { const inspected = inspectEnvCredential(credential); resetEnvSessionIfCredentialChanged(credential, inspected); if (rejectDevelopmentEnvCredential(inspected)) { + // Report a fully signed-out status (source: null, not "env-token") so + // downstream flows run their signed-out handling and re-auth against + // production instead of treating the rejected dev token as an active + // env-credential path. return { signedIn: false, userId: null, email: null, name: null, expiresAt: null, - source: "env-token", + source: null, }; } if (envSession) return { ...toStatus(envSession), source: "env-token" }; @@ -790,27 +794,25 @@ export function createAccountAuthService(args: { lastObservedSignedIn = record != null; }; - const invalidateStoredSessionIfCurrent = (raw: string): boolean => { + const invalidateStoredSessionIfCurrent = (raw: string): void => { const updateSync = args.credentialStore.updateSync; - let invalidated = false; 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]; - invalidated = true; return true; }); - } else if (args.credentialStore.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY) === raw) { - args.credentialStore.deleteSync(ACCOUNT_SESSION_CREDENTIAL_KEY); - invalidated = true; - } - if (invalidated) { - authEpoch += 1; - lastObservedSignedIn = false; - sessionReadState = "missing"; - warnDevelopmentClerkIgnored(); } - return invalidated; + // 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 => { @@ -836,7 +838,8 @@ export function createAccountAuthService(args: { })) { return session; } - if (invalidateStoredSessionIfCurrent(stored)) return null; + invalidateStoredSessionIfCurrent(stored); + return null; } sessionReadState = "unreadable"; return null; From d6c061a826d5b0b288dd62f1382852d804024c25 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:24:37 -0400 Subject: [PATCH 7/7] harden(account): unblock re-auth and honor the post-invalidation retry Two final review findings on the packaged dev-Clerk policy: - startLogin()/startDeviceLogin()/createToken() blocked whenever a raw ADE_ACCOUNT_TOKEN was present, so after a rejected development token correctly reported signed-out, Sign In threw "already providing" instead of starting production login. Route every login-gating path through readAcceptedEnvCredential(), which treats a rejected development credential as absent, so production login proceeds. - readSession() returned null right after invalidating a development session, ignoring the promised retry: when the atomic compare-and-delete saw a peer had already replaced it with a production session, that record stayed but the read still reported signed-out. Re-read once and return the peer-written production session in the same call; only a still-rejectable or absent session yields signed-out. Retry at most once. Co-Authored-By: Claude Opus 4.8 --- apps/ade-cli/src/cli.test.ts | 39 +++++++ apps/ade-cli/src/cli.ts | 9 +- .../account/accountAuthService.test.ts | 105 +++++++++++++++++- .../services/account/accountAuthService.ts | 104 ++++++++++------- docs/ARCHITECTURE.md | 2 +- docs/features/sync-and-multi-device/README.md | 8 +- 6 files changed, 214 insertions(+), 53 deletions(-) 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/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index fa9c17c5d..7b5608168 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -1,6 +1,7 @@ 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, @@ -319,6 +320,12 @@ describe("AccountAuthService packaged development-session policy", () => { }); 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) { @@ -329,7 +336,6 @@ describe("AccountAuthService packaged development-session policy", () => { } } const store = new RacingCredentialStore(); - store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(developmentSession)); const fetchImpl = vi.fn(); const service = createAccountAuthService({ credentialStore: store, @@ -343,7 +349,16 @@ describe("AccountAuthService packaged development-session policy", () => { }); activeServices.push(service); - expect(service.getStatus().signedIn).toBe(true); + // 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(); @@ -376,6 +391,7 @@ describe("AccountAuthService packaged development-session policy", () => { 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( @@ -1411,9 +1427,22 @@ describe("AccountAuthService ADE_ACCOUNT_TOKEN", () => { clientId: DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, }), "utf8").toString("base64url")}`, }, - ])("rejects a packaged ADE_ACCOUNT_TOKEN with $name without using it", async ({ credential }) => { + ])("treats a packaged ADE_ACCOUNT_TOKEN with $name as absent across auth flows", async ({ credential }) => { const store = new MemoryCredentialStore(); - const fetchImpl = vi.fn(); + 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: () => ({ @@ -1424,7 +1453,9 @@ describe("AccountAuthService ADE_ACCOUNT_TOKEN", () => { 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); @@ -1434,11 +1465,73 @@ describe("AccountAuthService ADE_ACCOUNT_TOKEN", () => { userId: null, source: null, }); - await expect(service.getAccessToken()).rejects.toThrow(/development Clerk/i); - expect(fetchImpl).not.toHaveBeenCalled(); + 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 b0838450b..fcac3e543 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -352,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. @@ -714,7 +726,7 @@ 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); @@ -748,23 +760,20 @@ export function createAccountAuthService(args: { return true; }; - const envCredentialStatus = (credential: string): AccountAuthStatus => { + const readAcceptedEnvCredential = (): { + credential: string; + inspected: EnvCredential; + } | null => { + const credential = readRawEnvCredential(); + if (!credential) return null; const inspected = inspectEnvCredential(credential); resetEnvSessionIfCredentialChanged(credential, inspected); - if (rejectDevelopmentEnvCredential(inspected)) { - // Report a fully signed-out status (source: null, not "env-token") so - // downstream flows run their signed-out handling and re-auth against - // production instead of treating the rejected dev token as an active - // env-credential path. - return { - signedIn: false, - userId: null, - email: null, - name: null, - expiresAt: null, - source: null, - }; - } + 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; @@ -820,7 +829,10 @@ export function createAccountAuthService(args: { // 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. - for (let attempt = 0; attempt < 2; attempt += 1) { + const readStoredSession = (): { + raw: string | null; + session: AccountSessionRecord | null; + } => { const stored = args.credentialStore.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY); const session = parseStoredSession(stored); sessionReadState = stored == null @@ -830,19 +842,31 @@ export function createAccountAuthService(args: { : session ? "available" : "unreadable"; - if (!session || !stored) return session; - if (!shouldRejectDevelopmentAccountMaterial({ - env, - accessToken: session.accessToken, - oauthConfig: session.oauthConfig, - })) { - return session; - } - invalidateStoredSessionIfCurrent(stored); + 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; } - sessionReadState = "unreadable"; - return null; + return retry.session; } catch (error) { sessionReadState = "unreadable"; logger.warn("account.session_read_failed", { @@ -1088,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(); @@ -1229,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) { @@ -1518,21 +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); - if (rejectDevelopmentEnvCredential(inspected)) { - throw new Error( - "ADE_ACCOUNT_TOKEN uses development Clerk and is disabled in this packaged build. Replace it with an ADE production token.", - ); - } + 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`.", @@ -1596,7 +1614,7 @@ export function createAccountAuthService(args: { if ( envCredentialEpoch !== epochAtRefresh || envSessionCredential !== credentialAtRefresh - || readEnvCredential() !== credentialAtRefresh + || readRawEnvCredential() !== credentialAtRefresh || envRefreshInFlight !== refreshPromise ) { return getAccessToken(); @@ -1692,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.", ); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index befd87055..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. 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 persisted development session is compare-and-deleted from the shared credential store so status immediately becomes signed out without erasing a newer peer-written session. `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. +**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 87f619a72..461d725e9 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -235,8 +235,12 @@ Runtime support files outside `services/sync/`: 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, with persisted sessions cleared to signed-out; - and + 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.