From c5b28a4669e95897d19b0348fdb59397918ec7f6 Mon Sep 17 00:00:00 2001 From: David Whatley Date: Sat, 1 Aug 2026 19:10:11 -0400 Subject: [PATCH 01/51] feat(windows): port desktop sync and remote clients from #999 Based-on: nsxdavid/ADE#999 --- .../automations/automationPlannerService.ts | 2 + .../services/automations/automationService.ts | 3 +- .../builtInBrowser/desktopBridgeServer.ts | 3 +- .../services/chat/agentChatService.test.ts | 8 +- .../main/services/chat/agentChatService.ts | 1 + .../chat/crossMachineForkTransport.ts | 1 + .../services/conflicts/conflictService.ts | 1 + .../externalSessionsService.ts | 1 + .../src/main/services/files/fileService.ts | 3 +- .../src/main/services/git/ghOpenPrLookup.ts | 1 + apps/desktop/src/main/services/git/git.ts | 3 +- .../src/main/services/ipc/registerIpc.ts | 37 ++++---- .../services/lanes/laneEnvironmentService.ts | 1 + .../src/main/services/prs/prService.ts | 1 + .../services/remoteRuntime/remoteBootstrap.ts | 3 +- .../remoteRuntime/runtimeDiscovery.ts | 3 +- .../syncPairedMachineStore.test.ts | 2 +- .../remoteRuntime/syncPairedMachineStore.ts | 2 +- .../src/main/services/tests/testService.ts | 1 + .../transcription/microphoneAccess.test.ts | 49 ++++++++++ .../transcription/microphoneAccess.ts | 46 ++++++++++ .../transcription/transcriptionService.ts | 5 +- .../services/usage/usageTrackingService.ts | 1 + apps/desktop/src/preload/preload.test.ts | 6 +- .../components/account/AccountPage.test.tsx | 16 ++-- .../components/account/AccountPage.tsx | 36 ++++---- .../components/app/App.workKeepAlive.test.tsx | 2 +- .../components/app/CommandPalette.tsx | 6 +- .../components/app/ConnectionsPanel.tsx | 4 +- .../components/app/ProjectRecoveryScreen.tsx | 4 +- .../app/ProjectTransitionErrorAlert.test.tsx | 6 +- .../app/StoragePressureIndicator.test.tsx | 2 +- .../app/StoragePressureIndicator.tsx | 2 +- .../renderer/components/app/TopBar.test.tsx | 2 +- .../src/renderer/components/app/TopBar.tsx | 2 +- .../chat/AgentChatComposer.test.tsx | 2 +- .../components/chat/AgentChatPane.test.tsx | 89 +++++++++++-------- .../components/chat/AgentChatPane.tsx | 37 +++----- .../chat/ChatContinuityRecoveryCard.test.tsx | 2 +- .../chat/ChatContinuityRecoveryCard.tsx | 2 +- .../components/chat/ChatIosSimulatorPanel.tsx | 8 +- .../components/chat/VoiceDictationButton.tsx | 3 +- .../chat/microphonePermissionGuidance.test.ts | 16 ++++ .../chat/microphonePermissionGuidance.ts | 13 +++ .../components/chat/thisMachineProjectRoot.ts | 6 +- .../src/renderer/components/cto/CtoPage.tsx | 4 +- .../renderer/components/cto/ctoUi.test.tsx | 4 +- .../components/files/v2/EditorGroup.test.tsx | 14 +-- .../components/files/v2/EditorGroup.tsx | 5 +- .../components/files/v2/FilesWorkbench.tsx | 4 +- .../files/v2/viewers/MediaViewer.tsx | 3 +- .../lanes/CreateLaneDialog.test.tsx | 2 +- .../components/lanes/LaneGitActionsPane.tsx | 2 +- .../lanes/LaneMachineSelector.test.tsx | 4 +- .../components/lanes/LaneMachineSelector.tsx | 2 +- .../lanes/PushDivergenceGuard.test.tsx | 2 +- .../components/lanes/laneMachines.test.ts | 6 +- .../renderer/components/lanes/laneMachines.ts | 14 +-- .../components/lanes/useLaneWorkSessions.ts | 3 +- .../components/onboarding/LaunchGate.test.tsx | 2 +- .../personalChats/PersonalChatsPage.test.tsx | 2 +- .../personalChats/PersonalChatsPage.tsx | 2 +- .../prs/shared/PrMarkdownEditor.tsx | 7 +- .../remoteTargets/AccountMachineRow.tsx | 12 +-- .../remoteTargets/PairMachineForm.tsx | 14 +-- .../remoteTargets/RemoteTargetForm.tsx | 2 +- .../remoteTargets/RemoteTargetList.test.tsx | 26 +++--- .../remoteTargets/RemoteTargetList.tsx | 22 ++--- .../remoteTargets/SavedMachineRow.tsx | 4 +- .../remoteTargets/remoteMachineModel.ts | 4 +- .../components/settings/SecretsSection.tsx | 2 +- .../components/settings/StorageSection.tsx | 2 +- .../settings/SyncDevicesSection.test.tsx | 34 +++++-- .../settings/SyncDevicesSection.tsx | 62 +++++++++---- .../settings/accountDirectorySummary.ts | 2 +- .../settings/storage/StorageDiagnostics.tsx | 2 +- .../components/settings/useSyncConnections.ts | 8 +- apps/desktop/src/renderer/index.css | 11 ++- .../src/renderer/state/appStore.test.ts | 2 +- apps/desktop/src/renderer/state/appStore.ts | 4 +- .../renderer/state/crossMachineLanes.test.ts | 10 +-- .../src/renderer/state/crossMachineLanes.ts | 12 +-- .../__tests__/connectionsPane.test.tsx | 2 +- .../adapter/sessionLifecycleSupport.ts | 4 +- .../renderer/webclient/shell/shellTokens.ts | 6 +- .../webclient/sync/__tests__/sync.test.ts | 8 +- .../src/renderer/webclient/sync/client.ts | 18 ++-- .../src/renderer/webclient/sync/connection.ts | 12 +-- .../renderer/webclient/sync/wireProtocol.ts | 4 +- .../desktop/src/shared/laneDivergence.test.ts | 16 ++-- apps/desktop/src/shared/laneDivergence.ts | 2 +- apps/desktop/src/shared/types/account.ts | 2 +- apps/ios/ADE/App/ContentView.swift | 2 +- apps/ios/ADE/App/DeepLinkRouter.swift | 2 +- .../ios/ADE/Services/SSHBootstrapModels.swift | 18 ++-- .../ADE/Services/SSHBootstrapService.swift | 2 +- .../ios/ADE/Services/SyncRecoveryPolicy.swift | 6 +- apps/ios/ADE/Services/SyncService.swift | 68 +++++++------- .../ADE/Services/SyncTerminalInputQueue.swift | 2 +- .../Account/AccountConnectionsSection.swift | 4 +- .../Views/Account/MobileAccessGateView.swift | 6 +- .../ADE/Views/Components/MachineRowView.swift | 2 +- .../ADE/Views/Deeplinks/SendToMacCard.swift | 20 ++--- apps/ios/ADE/Views/Hub/HubQuickConnect.swift | 8 +- .../Views/Linear/LinearConnectionScreen.swift | 4 +- .../ADE/Views/Linear/LinearPaneSheet.swift | 2 +- apps/ios/ADE/Views/PRs/PrDetailScreen.swift | 2 +- .../Settings/ConnectionSettingsView.swift | 10 +-- .../ADE/Views/Settings/SSHPairingView.swift | 10 +-- .../Settings/SettingsPairingSection.swift | 24 ++--- .../ADE/Views/Settings/SettingsPinSheet.swift | 14 +-- .../SettingsPushDeliverySection.swift | 4 +- .../Views/Settings/SettingsSupportTypes.swift | 2 +- apps/ios/ADEClip/ClipPairingClient.swift | 4 +- apps/ios/ADEClip/ClipPairingView.swift | 8 +- apps/ios/ADEClip/Info.plist | 2 +- apps/ios/ADETests/ADETests.swift | 14 +-- apps/ios/ADETests/PairingAndDpopTests.swift | 12 +++ .../ADETests/SyncRecoveryPolicyTests.swift | 6 +- apps/ios/ADEWidgets/ADELockScreenWidget.swift | 16 ++-- apps/web/public/pair/index.html | 6 +- apps/web/src/app/pages/PairPage.tsx | 2 +- 122 files changed, 664 insertions(+), 453 deletions(-) create mode 100644 apps/desktop/src/main/services/transcription/microphoneAccess.test.ts create mode 100644 apps/desktop/src/main/services/transcription/microphoneAccess.ts create mode 100644 apps/desktop/src/renderer/components/chat/microphonePermissionGuidance.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/microphonePermissionGuidance.ts diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index a22fbbfbd..214674fa6 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -451,6 +451,7 @@ async function runCodexExec(args: { env, stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); let stderr = ""; @@ -534,6 +535,7 @@ async function runClaudeHeadless(args: { env, stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 31cfed7e8..8f3e58926 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -2419,7 +2419,8 @@ export function createAutomationService({ const child = spawn(shellFile, shellArgs, { cwd: args.cwd, env: process.env, - stdio: ["ignore", "pipe", "pipe"] + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); let stdout = ""; let stderr = ""; diff --git a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts index 8b5d03e12..aed3e6db6 100644 --- a/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts +++ b/apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts @@ -19,6 +19,7 @@ import { import type { Logger } from "../logging/logger"; import { resolveBuiltInBrowserActorCapability } from "./builtInBrowserActorCapabilities"; import type { BuiltInBrowserService } from "./builtInBrowserService"; +import { localIpcListenOptions } from "../../../../../ade-cli/src/services/runtime/localIpcListenOptions"; /** * Side-channel JSON-RPC server that exposes the desktop's @@ -111,7 +112,7 @@ export function startBuiltInBrowserDesktopBridgeServer(args: { }); try { - server.listen(socketPath, () => { + server.listen(localIpcListenOptions(socketPath), () => { if (!isNamedPipe) { try { fs.chmodSync(socketPath, 0o600); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 8899aa68b..e2c02c00b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2308,7 +2308,7 @@ describe("createAgentChatService", () => { allowed: false, state: "exhausted", code: "disk_full", - message: "Your Mac is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", + message: "Your computer is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", })), }, onEvent: (event: AgentChatEventEnvelope) => events.push(event), @@ -2338,7 +2338,7 @@ describe("createAgentChatService", () => { allowed: false, state: "exhausted" as const, code: "disk_full" as const, - message: "Your Mac is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", + message: "Your computer is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", })); const { service } = createService({ diskPressureMonitor: { canPerform }, @@ -26387,7 +26387,7 @@ describe("createAgentChatService", () => { allowed: false, state: "exhausted", code: "disk_full", - message: "Your Mac is almost out of storage.", + message: "Your computer is almost out of storage.", }), }, onEvent: (event: AgentChatEventEnvelope) => events.push(event), @@ -38107,7 +38107,7 @@ describe("explicit provider-thread continuity recovery", () => { allowed: false, state: "exhausted", code: "disk_full", - message: "Your Mac is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", + message: "Your computer is almost out of storage. ADE paused new agent work to protect your chats and projects. Free up space, then resume.", })), }, onEvent: (event: AgentChatEventEnvelope) => events.push(event), diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index a8a45dc1b..b2116b1d4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -26485,6 +26485,7 @@ export function createAgentChatService(args: { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); const reader = readline.createInterface({ input: proc.stdout }); diff --git a/apps/desktop/src/main/services/chat/crossMachineForkTransport.ts b/apps/desktop/src/main/services/chat/crossMachineForkTransport.ts index 270562120..94edc1cc8 100644 --- a/apps/desktop/src/main/services/chat/crossMachineForkTransport.ts +++ b/apps/desktop/src/main/services/chat/crossMachineForkTransport.ts @@ -75,6 +75,7 @@ export const runCliCapture = ( const child = spawn(bin, args, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index 8525eda0a..3e6209e61 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -3560,6 +3560,7 @@ export function createConflictService({ env: process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", + windowsHide: true, windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts index 2d2ced84b..b84e4f11d 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts @@ -432,6 +432,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) timeout: 1500, encoding: "utf8", env: args.env ?? process.env, + windowsHide: true, }, (_error, stdout, stderr) => { droidForkProbe = /(^|\s)--fork\b/u.test(`${stdout ?? ""}\n${stderr ?? ""}`); resolve(droidForkProbe); diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index e0470852d..89742a8ee 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -372,7 +372,8 @@ async function runGitCheckIgnoreBatch(args: { cwd: string; paths: string[]; time return await new Promise>((resolve) => { const child = spawn("git", ["check-ignore", "--stdin"], { cwd: args.cwd, - stdio: ["pipe", "pipe", "ignore"] + stdio: ["pipe", "pipe", "ignore"], + windowsHide: true, }); let settled = false; diff --git a/apps/desktop/src/main/services/git/ghOpenPrLookup.ts b/apps/desktop/src/main/services/git/ghOpenPrLookup.ts index 2e64959ac..d3e99d262 100644 --- a/apps/desktop/src/main/services/git/ghOpenPrLookup.ts +++ b/apps/desktop/src/main/services/git/ghOpenPrLookup.ts @@ -58,6 +58,7 @@ function runGhPrList(args: { cwd: args.worktreePath, env: process.env, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }, ); const finish = (value: string | null) => { diff --git a/apps/desktop/src/main/services/git/git.ts b/apps/desktop/src/main/services/git/git.ts index db0580395..8a853916b 100644 --- a/apps/desktop/src/main/services/git/git.ts +++ b/apps/desktop/src/main/services/git/git.ts @@ -229,7 +229,8 @@ async function runGitOnce(args: string[], opts: GitRunOptions): Promise 0) { activeGitPids.add(child.pid); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index cab3c3054..834fed1ee 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -8,6 +8,7 @@ import { DEFAULT_AUTO_UPDATE_PREFERENCES } from "../../../shared/types"; import { buildGithubReleaseUrl, compareUpdateVersions, + DEFAULT_RELEASE_REPOSITORY, } from "../updates/autoUpdateVersions"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -665,6 +666,7 @@ import { type TranscriptionStatus, TranscriptionError, } from "../transcription/transcriptionService"; +import { requestMicrophoneAccess } from "../transcription/microphoneAccess"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import { fetchAdeLatestRelease, type createGithubService } from "../github/githubService"; import { createAccountBridge } from "../account/accountBridge"; @@ -1589,6 +1591,7 @@ export function registerIpc({ closeCurrentProject, closeProjectByPath, globalStatePath, + releaseRepository = DEFAULT_RELEASE_REPOSITORY, builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot, @@ -1617,6 +1620,7 @@ export function registerIpc({ closeCurrentProject: () => Promise; closeProjectByPath: (projectRoot: string) => Promise; globalStatePath: string; + releaseRepository?: string; builtInBrowserService?: ReturnType | null; productAnalyticsService?: ProductAnalyticsService; publishAttentionNotchSnapshot?: (snapshot: AttentionSnapshot) => void; @@ -3819,6 +3823,7 @@ export function registerIpc({ const child = spawn(command, args, { detached: true, stdio: "ignore", + windowsHide: true, windowsVerbatimArguments: options?.windowsVerbatimArguments, }); child.once("error", (error) => { @@ -3984,7 +3989,7 @@ export function registerIpc({ if (!version) return null; return { version, - htmlUrl: buildGithubReleaseUrl(version), + htmlUrl: buildGithubReleaseUrl(version, releaseRepository), publishedAt: null, updateAvailable: compareUpdateVersions(version, app.getVersion()) > 0, }; @@ -7059,31 +7064,16 @@ export function registerIpc({ }, ); - // Ensure macOS microphone access before the renderer calls getUserMedia. + // Check OS-level microphone access before the renderer calls getUserMedia. // Electron on macOS returns a silent (all-zero) audio track instead of // throwing when the OS hasn't granted mic access, so we must check/request // the system-level permission explicitly (electron/electron#23792, #42714). + // Windows exposes the global Win32 microphone privacy switch through + // getMediaAccessStatus; Chromium owns any per-origin prompt. ipcMain.handle( IPC.transcriptionRequestMicAccess, async (): Promise<{ status: "granted" | "denied" | "not-determined" | "restricted" | "unknown" }> => { - if (process.platform !== "darwin") { - return { status: "granted" }; - } - const current = systemPreferences.getMediaAccessStatus("microphone"); - if (current === "granted") { - return { status: "granted" }; - } - if (current === "not-determined") { - try { - const ok = await systemPreferences.askForMediaAccess("microphone"); - return { status: ok ? "granted" : "denied" }; - } catch { - return { status: "denied" }; - } - } - // "denied" | "restricted" | "unknown" — the user must change this in - // System Settings; askForMediaAccess will not re-prompt. - return { status: current }; + return requestMicrophoneAccess(process.platform, systemPreferences); }, ); @@ -7824,7 +7814,7 @@ export function registerIpc({ const simulatorWindowName = /(?:^|\s|[(\[\-–])(simulator|iphone|ipad|apple\s*watch|apple\s*tv|vision\s*pro)(?:\s|[)\]\-–]|$)/i; const runMacUtility = async (command: string, args: string[], timeoutMs = 900) => { await new Promise((resolve) => { - const child = spawn(command, args, { stdio: "ignore" }); + const child = spawn(command, args, { stdio: "ignore", windowsHide: true }); let settled = false; const finish = () => { if (settled) return; @@ -7845,7 +7835,10 @@ export function registerIpc({ }; const runMacUtilityText = async (command: string, args: string[], timeoutMs = 900): Promise => { return new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); let stdout = ""; let stderr = ""; const timeout = setTimeout(() => { diff --git a/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts b/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts index bf91f4654..9c160602a 100644 --- a/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts +++ b/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts @@ -238,6 +238,7 @@ export function createLaneEnvironmentService({ env: process.env, stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); let stdout = ""; let stderr = ""; diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 7dc7a6a9b..2c65482bc 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -6986,6 +6986,7 @@ export function createPrService({ cwd: opts.cwd, env: ghToken ? { ...process.env, GH_TOKEN: ghToken, GITHUB_TOKEN: ghToken } : process.env, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); const finish = (code: number) => { diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index d02078b1b..10e736ed2 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -1008,6 +1008,7 @@ async function uploadSshChunkViaOpenSsh( let settled = false; const child = spawn("ssh", openSshArgsForRoute(target, route, connectedConfig, remoteUploadAppendCommand(remoteFileExpr)), { stdio: [chunkHandle.fd, "ignore", "pipe"], + windowsHide: true, }); let stderr = ""; let timeout: NodeJS.Timeout | null = null; @@ -1366,7 +1367,7 @@ async function signUploadedRuntimeBinaryIfNeeded(client: Client, layout: RemoteR throw new Error( signed.stderr.trim() || signed.stdout.trim() || - "Uploaded ADE service could not be signed on the remote Mac.", + "Uploaded ADE service could not be signed on the remote computer.", ); } } diff --git a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts index ae8b03eae..0e4d42378 100644 --- a/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts +++ b/apps/desktop/src/main/services/remoteRuntime/runtimeDiscovery.ts @@ -297,7 +297,7 @@ function isTailscalePeerMachine( * Drops this machine's own Bonjour advertisement from the discovery list. The * ADE sync service advertises itself over mDNS with its `deviceId` in the TXT * payload (surfaced as `hostIdentity`); comparing that against the local sync - * device id keeps the panel from listing "this Mac" as a connectable target. + * device id keeps the panel from listing "this computer" as a connectable target. */ export function dropSelfDiscoveredMachines( machines: RemoteRuntimeDiscoveredMachine[], @@ -395,6 +395,7 @@ async function discoverTailscalePeers(timeoutMs = 1_200): Promise<{ { timeout: Math.max(500, timeoutMs), maxBuffer: 1024 * 1024, + windowsHide: true, }, ); return { diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts index ec5713a8c..558276f01 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts @@ -923,7 +923,7 @@ describe("DesktopPairedMachineStore", () => { name: "requires an update when an old host omits AEAD negotiation", responseAead: undefined, expectedError: - "The other Mac is running an older ADE that can't negotiate a compatible cipher — update it to the latest version.", + "The other computer is running an older ADE that can't negotiate a compatible cipher — update it to the latest version.", }, { name: "rejects a host AEAD that the client did not offer", diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts index ff9c71cfc..5bd32f66e 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.ts @@ -97,7 +97,7 @@ class AccountPairingAuthorizationError extends Error { const HOST_IDENTITY_VERIFICATION_ERROR = "Host identity verification failed — the machine may be running an older ADE."; const ADOPT_CHANNEL_UPDATE_REQUIRED_ERROR = - "The other Mac is running an older ADE that can't negotiate a compatible cipher — update it to the latest version."; + "The other computer is running an older ADE that can't negotiate a compatible cipher — update it to the latest version."; export class AccountHostIdentityVerificationError extends Error { readonly code = "account_host_identity_verification_failed"; diff --git a/apps/desktop/src/main/services/tests/testService.ts b/apps/desktop/src/main/services/tests/testService.ts index 016fc8e30..bb629ccd5 100644 --- a/apps/desktop/src/main/services/tests/testService.ts +++ b/apps/desktop/src/main/services/tests/testService.ts @@ -307,6 +307,7 @@ export function createTestService({ shell: false, stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); const entry: ActiveRunEntry = { diff --git a/apps/desktop/src/main/services/transcription/microphoneAccess.test.ts b/apps/desktop/src/main/services/transcription/microphoneAccess.test.ts new file mode 100644 index 000000000..6768b395b --- /dev/null +++ b/apps/desktop/src/main/services/transcription/microphoneAccess.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { requestMicrophoneAccess } from "./microphoneAccess"; + +describe("requestMicrophoneAccess", () => { + it("reports the Windows global Win32 microphone policy", async () => { + const getMediaAccessStatus = vi.fn(() => "denied" as const); + const askForMediaAccess = vi.fn(); + + await expect(requestMicrophoneAccess("win32", { + getMediaAccessStatus, + askForMediaAccess, + })).resolves.toEqual({ status: "denied" }); + expect(getMediaAccessStatus).toHaveBeenCalledWith("microphone"); + expect(askForMediaAccess).not.toHaveBeenCalled(); + }); + + it("lets Chromium handle Windows microphone access when the global policy is inconclusive", async () => { + const getMediaAccessStatus = vi.fn(() => "unknown" as const); + const askForMediaAccess = vi.fn(); + + await expect(requestMicrophoneAccess("win32", { + getMediaAccessStatus, + askForMediaAccess, + })).resolves.toEqual({ status: "granted" }); + expect(askForMediaAccess).not.toHaveBeenCalled(); + }); + + it("requests undetermined macOS microphone access", async () => { + const getMediaAccessStatus = vi.fn(() => "not-determined" as const); + const askForMediaAccess = vi.fn(async () => true); + + await expect(requestMicrophoneAccess("darwin", { + getMediaAccessStatus, + askForMediaAccess, + })).resolves.toEqual({ status: "granted" }); + expect(askForMediaAccess).toHaveBeenCalledWith("microphone"); + }); + + it("leaves unsupported platforms to Chromium permission handling", async () => { + const getMediaAccessStatus = vi.fn(); + const askForMediaAccess = vi.fn(); + + await expect(requestMicrophoneAccess("linux", { + getMediaAccessStatus, + askForMediaAccess, + })).resolves.toEqual({ status: "granted" }); + expect(getMediaAccessStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/services/transcription/microphoneAccess.ts b/apps/desktop/src/main/services/transcription/microphoneAccess.ts new file mode 100644 index 000000000..bceab7c84 --- /dev/null +++ b/apps/desktop/src/main/services/transcription/microphoneAccess.ts @@ -0,0 +1,46 @@ +export type MicrophoneAccessStatus = + | "granted" + | "denied" + | "not-determined" + | "restricted" + | "unknown"; + +type SystemMediaPreferences = { + getMediaAccessStatus: (mediaType: "microphone") => MicrophoneAccessStatus; + askForMediaAccess: (mediaType: "microphone") => Promise; +}; + +export async function requestMicrophoneAccess( + platform: NodeJS.Platform, + preferences: SystemMediaPreferences, +): Promise<{ status: MicrophoneAccessStatus }> { + if (platform === "win32") { + try { + const status = preferences.getMediaAccessStatus("microphone"); + // Windows exposes only the global Win32 privacy policy here. Treat + // definitive OS denials as blocking; Chromium/getUserMedia owns any + // per-origin prompt when Electron cannot determine that policy. + return { status: status === "denied" || status === "restricted" ? status : "granted" }; + } catch { + return { status: "granted" }; + } + } + + if (platform !== "darwin") { + return { status: "granted" }; + } + + const current = preferences.getMediaAccessStatus("microphone"); + if (current === "granted") { + return { status: "granted" }; + } + if (current === "not-determined") { + try { + const granted = await preferences.askForMediaAccess("microphone"); + return { status: granted ? "granted" : "denied" }; + } catch { + return { status: "denied" }; + } + } + return { status: current }; +} diff --git a/apps/desktop/src/main/services/transcription/transcriptionService.ts b/apps/desktop/src/main/services/transcription/transcriptionService.ts index 1dc2ecad8..246e2fd5f 100644 --- a/apps/desktop/src/main/services/transcription/transcriptionService.ts +++ b/apps/desktop/src/main/services/transcription/transcriptionService.ts @@ -338,7 +338,10 @@ export function createTranscriptionService({ ): Promise => { return await new Promise((resolve, reject) => { const args = buildWhisperArgs(modelPath, wavPath); - const child = spawn(binaryPath, args, { stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn(binaryPath, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); activeChildren.add(child); const whisperTimeoutMs = resolveWhisperProcessTimeoutMs(); diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 5b2330bad..ba80ecc24 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -797,6 +797,7 @@ async function pollCodexViaCliRpc(logger: Logger): Promise { }); }); - it("routes pinned lane and session lists to This Mac without rebinding the window", async () => { + it("routes pinned lane and session lists to This computer without rebinding the window", async () => { const { bridge, invoke } = await mountBridge(); await bridge.lanes.list({ includeStatus: true }, machineA); @@ -6982,7 +6982,7 @@ describe("per-chat runtime routing", () => { ); }); - it("streams a pinned This Mac chat while the window is remote-bound", async () => { + it("streams a pinned This computer chat while the window is remote-bound", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-07-27T18:02:00.500Z")); try { @@ -6991,7 +6991,7 @@ describe("per-chat runtime routing", () => { const envelope = { sessionId: "chat-on-a", timestamp: "2026-07-27T18:02:00.000Z", - event: { type: "text", text: "from This Mac" }, + event: { type: "text", text: "from This computer" }, }; invoke.mockImplementation(async (channel: string, arg?: unknown) => { if (channel === IPC.localRuntimeCallAction) { diff --git a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx index 5f47ca756..3a8476695 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx @@ -203,15 +203,15 @@ describe("AccountPage signed-in", () => { expect(screen.queryByText(/you're in/i)).toBeNull(); }); - it("pins this Mac first with a badge and hides its removal menu", async () => { + it("pins this computer first with a badge and hides its removal menu", async () => { renderPage(); await screen.findByText("MacBook Pro"); const rows = screen.getAllByText(/MacBook Pro|Studio/); expect(rows[0].textContent).toBe("MacBook Pro"); - expect(screen.getByText("This Mac")).toBeTruthy(); + expect(screen.getByText("This computer")).toBeTruthy(); - // Only the other Mac exposes an options (removal) menu. + // Only the other computer exposes an options (removal) menu. expect(screen.queryByRole("button", { name: /Options for MacBook Pro/ })).toBeNull(); expect(screen.getByRole("button", { name: /Options for Studio/ })).toBeTruthy(); }); @@ -233,7 +233,7 @@ describe("AccountPage signed-in", () => { await waitFor(() => expect(document.activeElement).toBe(trigger)); }); - it("removes another Mac only after confirmation", async () => { + it("removes another computer only after confirmation", async () => { renderPage(); await screen.findByText("Studio"); @@ -241,7 +241,7 @@ describe("AccountPage signed-in", () => { fireEvent.click(screen.getByRole("menuitem", { name: /Remove from account/ })); const dialog = screen.getByRole("dialog"); - expect(within(dialog).getByText(/Remove this Mac from your account\?/)).toBeTruthy(); + expect(within(dialog).getByText(/Remove this computer from your account\?/)).toBeTruthy(); fireEvent.click(within(dialog).getByRole("button", { name: "Remove" })); await waitFor(() => expect(removeMachine).toHaveBeenCalledWith("studio-key")); @@ -251,13 +251,13 @@ describe("AccountPage signed-in", () => { renderPage(); await screen.findByText("MacBook Pro"); - expect(screen.getByText("Signed in on this Mac")).toBeTruthy(); + expect(screen.getByText("Signed in on this computer")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Sign out" })); const dialog = screen.getByRole("dialog"); expect( within(dialog).getByText( - "Signing out removes this Mac's access to your account and its account-connected machines. Devices paired directly with a code stay connected.", + "Signing out removes this computer's access to your account and its account-connected machines. Devices paired directly with a code stay connected.", ), ).toBeTruthy(); @@ -325,7 +325,7 @@ describe("AccountPage signed-in", () => { renderPage(); - expect(await screen.findByText("Use the machine menu above to switch Macs.")).toBeTruthy(); + expect(await screen.findByText("Use the machine menu above to switch computers.")).toBeTruthy(); expect(screen.queryByText(/still connect from Connections/)).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/account/AccountPage.tsx b/apps/desktop/src/renderer/components/account/AccountPage.tsx index 1ca0d7766..3dc2aae8f 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -385,7 +385,7 @@ export function SignInCard({ } // --------------------------------------------------------------------------- -// Signed-in: Your Macs — the account directory, this Mac pinned first. +// Signed-in: Your computers — the account directory, this computer pinned first. // --------------------------------------------------------------------------- function YourMacsCard() { @@ -415,7 +415,7 @@ function YourMacsCard() { } }, []); - // Identify this Mac once so it can be pinned and shielded from removal. + // Identify this computer once so it can be pinned and shielded from removal. useEffect(() => { let cancelled = false; const api = accountBridge(); @@ -453,7 +453,7 @@ function YourMacsCard() { const machines = useMemo(() => { const list = [...(result?.machines ?? [])]; - // Pin this Mac first; keep directory order otherwise. + // Pin this computer first; keep directory order otherwise. return list.sort((a, b) => (isThisMac(b) ? 1 : 0) - (isThisMac(a) ? 1 : 0)); }, [result?.machines, isThisMac]); @@ -487,16 +487,16 @@ function YourMacsCard() { }, [openMenuKey]); let summary: string; - if (loading && !result) summary = "Checking your Macs…"; + if (loading && !result) summary = "Checking your computers…"; else if (result?.state === "ok") { summary = machines.length === 0 - ? "No Macs connected yet" + ? "No computers connected yet" : `${onlineCount} online · ${machines.length} connected`; } else if (result?.state === "not_configured") { summary = "The account directory isn't set up yet"; } else if (result?.state === "signed_out") { - summary = "Sign in to see your Macs"; + summary = "Sign in to see your computers"; } else { summary = "Can't reach the account directory"; } @@ -515,7 +515,7 @@ function YourMacsCard() { setPendingRemoval(null); await load(); } catch (err) { - setRemoveError(err instanceof Error ? err.message : "Couldn't remove that Mac from your account."); + setRemoveError(err instanceof Error ? err.message : "Couldn't remove that computer from your account."); } finally { setRemoving(false); } @@ -542,7 +542,7 @@ function YourMacsCard() {
- Your Macs + Your computers
{summary}
@@ -605,11 +605,11 @@ function YourMacsCard() { whiteSpace: "nowrap", }} > - {accountMachineDisplayName(machine) ?? "Unnamed Mac"} + {accountMachineDisplayName(machine) ?? "Unnamed computer"} {thisMac ? ( - This Mac + This computer ) : null} @@ -631,7 +631,7 @@ function YourMacsCard() { ) : ( ) : null} diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index 748002498..09bf91015 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -65,7 +65,7 @@ import { WorkspacePicker } from "./WorkspacePicker"; import { CreatePromptModal, SearchOverlay } from "./overlays"; import { setPendingReveal } from "./pendingReveals"; import { COLORS } from "../../lanes/laneDesignTokens"; -import { modifierKeyLabel } from "../../../lib/platform"; +import { modifierKeyLabel, revealLabel } from "../../../lib/platform"; import type { EditorThemeMode } from "./viewers/types"; import { joinDisplayPath } from "./pathDisplay"; @@ -1175,7 +1175,7 @@ export function FilesWorkbench({ items.push({ type: "item", label: "Copy Name", icon: , onClick: () => void window.ade.app.writeClipboardText?.(name) }); items.push({ type: "item", - label: "Reveal in Finder", + label: revealLabel, icon: , onClick: () => void window.ade.app.openPathInEditor?.({ rootPath, relativePath: path, target: "finder" }).catch(() => {}), disabled: !canRevealInFinder, diff --git a/apps/desktop/src/renderer/components/files/v2/viewers/MediaViewer.tsx b/apps/desktop/src/renderer/components/files/v2/viewers/MediaViewer.tsx index 46191bdd2..be59703b9 100644 --- a/apps/desktop/src/renderer/components/files/v2/viewers/MediaViewer.tsx +++ b/apps/desktop/src/renderer/components/files/v2/viewers/MediaViewer.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from "react"; import { ArrowSquareOut, MusicNotes, VideoCamera } from "@phosphor-icons/react"; import { COLORS } from "../../../lanes/laneDesignTokens"; +import { revealLabel } from "../../../../lib/platform"; import { streamFileBytes } from "../streamBytes"; import type { ViewerProps } from "./types"; @@ -57,7 +58,7 @@ export function MediaViewer({ workspaceId, rootPath, tab, content, kind }: Viewe {mimeType} {formatBytes(content.size)} - diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx index f288978ab..3dc42dd0b 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.test.tsx @@ -263,7 +263,7 @@ function machine(overrides: Partial & { id: string; name: str }; } -const thisMac = machine({ id: THIS_MACHINE_ID, name: "This Mac", isBound: true }); +const thisMac = machine({ id: THIS_MACHINE_ID, name: "This computer", isBound: true }); const studio = machine({ id: "studio", name: "MacBook Pro (97)" }); describe("CreateLaneDialog machine selection", () => { diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 802c67a65..cfcc0b05d 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -240,7 +240,7 @@ function useLaneGitActionRuntimeState(scopeKey: string | null): LaneGitActionRun // without a translation step. Machines are named absolutely — never "remote". // Imported, not re-typed. The previous hardcoded literals were kept in sync // with laneMachines.ts by a comment; the guard compares machine ids, so a drift -// here makes it warn that This Mac diverged from itself. +// here makes it warn that This computer diverged from itself. import { THIS_MACHINE_ID as THIS_MACHINE_GUARD_ID, THIS_MACHINE_NAME as THIS_MACHINE_GUARD_NAME, diff --git a/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.test.tsx index 1f6e8ea70..b63ce1d57 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.test.tsx @@ -21,7 +21,7 @@ function machine(overrides: Partial & { id: string; name: str } const machines: LaneMachineOption[] = [ - machine({ id: THIS_MACHINE_ID, name: "This Mac", isBound: true, freeBytes: 412 * 1024 ** 3 }), + machine({ id: THIS_MACHINE_ID, name: "This computer", isBound: true, freeBytes: 412 * 1024 ** 3 }), machine({ id: "studio", name: "MacBook Pro (97)", freeBytes: 4 * 1024 ** 3 }), ]; @@ -36,7 +36,7 @@ describe("LaneMachineSelector", () => { ); expect(screen.getByText("Create on")).toBeTruthy(); - expect(screen.getByText("This Mac")).toBeTruthy(); + expect(screen.getByText("This computer")).toBeTruthy(); expect(screen.getByText("MacBook Pro (97)")).toBeTruthy(); const group = screen.getByRole("radiogroup", { name: "Machine for this lane" }); expect(group.textContent?.toLowerCase()).not.toContain("remote"); diff --git a/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.tsx b/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.tsx index bf4fd1db3..a6207db04 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneMachineSelector.tsx @@ -15,7 +15,7 @@ import { * * Deliberately does NOT reuse the dialog's "Remote"/"Local" vocabulary: that * pair already means the git base-branch source a few rows below. Machines are - * named absolutely instead ("This Mac", "MacBook Pro (97)"). + * named absolutely instead ("This computer", "MacBook Pro (97)"). */ export function LaneMachineSelector({ machines, diff --git a/apps/desktop/src/renderer/components/lanes/PushDivergenceGuard.test.tsx b/apps/desktop/src/renderer/components/lanes/PushDivergenceGuard.test.tsx index 832390ce2..bd3f5341b 100644 --- a/apps/desktop/src/renderer/components/lanes/PushDivergenceGuard.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/PushDivergenceGuard.test.tsx @@ -257,7 +257,7 @@ describe("LaneGitActionsPane push divergence guard", () => { it("never warns about this machine's own entry", async () => { renderPane({ otherMachineBranchStates: [ - otherMachine({ machineId: "this-mac", machineName: "This Mac", headSha: null, ahead: 9 }), + otherMachine({ machineId: "this-mac", machineName: "This computer", headSha: null, ahead: 9 }), ], }); await clickPush(); diff --git a/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts b/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts index d6f214ebe..8ac319523 100644 --- a/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts +++ b/apps/desktop/src/renderer/components/lanes/laneMachines.test.ts @@ -156,11 +156,11 @@ describe("deriveLaneMachineOptions", () => { expect(studio?.repoMatch).toBe("matched"); expect(studio?.project?.rootPath).toBe("/Users/x/ADE"); expect(defaultLaneMachineId(options)).toBe("studio"); - // This Mac is no longer the bound machine, and nothing proves the repo is here. + // This computer is no longer the bound machine, and nothing proves the repo is here. expect(options[0]?.repoMatch).toBe("unknown"); }); - it("resolves this Mac's checkout from open local project tabs", () => { + it("resolves this computer's checkout from open local project tabs", () => { const options = deriveLaneMachineOptions({ connections: [connection({ id: "studio" })], boundTargetId: "studio", @@ -263,7 +263,7 @@ describe("deriveLaneMachineOptions", () => { expect(studio?.repoMatch).not.toBe("matched"); }); - it("says the repo is missing from this Mac only when the local tabs are known", () => { + it("says the repo is missing from this computer only when the local tabs are known", () => { const withTabs = deriveLaneMachineOptions({ connections: [connection({ id: "studio" })], boundTargetId: "studio", diff --git a/apps/desktop/src/renderer/components/lanes/laneMachines.ts b/apps/desktop/src/renderer/components/lanes/laneMachines.ts index 65bfa0469..e721cd336 100644 --- a/apps/desktop/src/renderer/components/lanes/laneMachines.ts +++ b/apps/desktop/src/renderer/components/lanes/laneMachines.ts @@ -21,7 +21,7 @@ import type { RecentProjectSummary, RemoteRuntimeConnectionStatus } from "../../ // Machine identity is shared, not per-module: five copies of these constants // with two different id values is what made the divergence guard able to warn -// that This Mac diverged from itself. Re-exported here for existing callers. +// that This computer diverged from itself. Re-exported here for existing callers. import { THIS_MACHINE_ID, THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; export { THIS_MACHINE_ID, THIS_MACHINE_NAME }; @@ -73,7 +73,7 @@ export type LaneMachineOption = { export type LaneMachineDerivationInput = { /** Connections from the remote-runtime snapshot; only `connected` are listed. */ connections: readonly RemoteRuntimeConnectionStatus[]; - /** Target id of the machine the active project is bound to; null = this Mac. */ + /** Target id of the machine the active project is bound to; null = this computer. */ boundTargetId: string | null; /** The bound machine's checkout of this repo, from the active project binding. */ boundProject?: LaneMachineProjectRef | null; @@ -85,11 +85,11 @@ export type LaneMachineDerivationInput = { localProjectRoots?: readonly string[]; /** * Known local projects, including unopened recents. Their git origins let a - * remote-bound tab address the matching checkout on This Mac without forcing + * remote-bound tab address the matching checkout on This computer without forcing * the user to open it once just to establish identity. */ localProjects?: readonly RecentProjectSummary[]; - /** Free disk headroom on this Mac, when a caller already has it. */ + /** Free disk headroom on this computer, when a caller already has it. */ thisMachineFreeBytes?: number | null; }; @@ -203,7 +203,7 @@ function repoMatchFor( } /** - * This Mac. Its checkout can't be matched by git origin — local projects aren't + * This computer. Its checkout can't be matched by git origin — local projects aren't * in the connection snapshot — so it matches on the repo folder name against * the project tabs already open in this window. */ @@ -247,7 +247,7 @@ function thisMachineOption(input: LaneMachineDerivationInput): LaneMachineOption }; } } - // We can only claim the repo is absent from this Mac when we know what we're + // We can only claim the repo is absent from this computer when we know what we're // looking for and have the local project list to look in. const canProveAbsence = !isBound && ( repoIdentity @@ -272,7 +272,7 @@ function thisMachineOption(input: LaneMachineDerivationInput): LaneMachineOption } /** - * Connected machines a lane can be created on, this Mac first. Machines that + * Connected machines a lane can be created on, this computer first. Machines that * are pairing, erroring, or idle are omitted entirely — you can only create a * lane on a machine ADE is talking to right now. */ diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 11ccec5cf..8ce1897df 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -676,7 +676,7 @@ export function useLaneWorkSessions(laneId: string | null) { // defaults from the other fields (which used to override the caller's // intent — e.g. a custom startupCommand silently displaced by default // command/args). - const launchFields = resolveLaunchFields({ + const launchFields = args.runtimeCliLaunch ? {} : resolveLaunchFields({ profile: args.profile, ...(args.permissionMode !== undefined ? { permissionMode: args.permissionMode } : {}), ...(args.orchestrationRole !== undefined ? { orchestrationRole: args.orchestrationRole } : {}), @@ -698,6 +698,7 @@ export function useLaneWorkSessions(laneId: string | null) { ...(launchFields.initialInput !== undefined ? { initialInput: launchFields.initialInput } : {}), ...(launchFields.initialInputDelayMs !== undefined ? { initialInputDelayMs: launchFields.initialInputDelayMs } : {}), ...(args.linearIssues?.length ? { linearIssues: args.linearIssues } : {}), + ...(args.runtimeCliLaunch ? { runtimeCliLaunch: args.runtimeCliLaunch } : {}), ...launchFields, }; const result = args.pin diff --git a/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx b/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx index a14256bc2..6b9daad3c 100644 --- a/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx +++ b/apps/desktop/src/renderer/components/onboarding/LaunchGate.test.tsx @@ -73,7 +73,7 @@ describe("LaunchGate", () => { render(
Application
); expect(await screen.findByRole("button", { name: /continue without an account/i })).toBeTruthy(); - expect(screen.queryByText(/Use ADE on this Mac without an account/i)).toBeNull(); + expect(screen.queryByText(/Use ADE on this computer without an account/i)).toBeNull(); expect(screen.getByTestId("launch-gate-drag-region").getAttribute("data-app-region")).toBe("drag"); expect(screen.queryByText("Application")).toBeNull(); await waitFor(() => { diff --git a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx index a21767475..293fdda43 100644 --- a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx +++ b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.test.tsx @@ -771,7 +771,7 @@ describe("PersonalChatsPage", () => { expect(screen.getByRole("menuitem", { name: /MacBook Pro \(97\)/ })).toBeTruthy(); }); - it("names the bound machine when the window runs on another Mac", async () => { + it("names the bound machine when the window runs on another computer", async () => { storeState.projectBinding = { kind: "remote", key: "remote:target-1:project-1", diff --git a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx index 6c0c8c1b2..6e01730c9 100644 --- a/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx +++ b/apps/desktop/src/renderer/components/personalChats/PersonalChatsPage.tsx @@ -674,7 +674,7 @@ export function PersonalChatsPage({ standalone = false }: { standalone?: boolean if (nextMachineId === machineId) return; setError(null); if (nextMachineId === LOCAL_MACHINE_ID) { - // The machine is a dimension of THIS repo's tab, so "This Mac" must + // The machine is a dimension of THIS repo's tab, so "This computer" must // resolve to this repo's local checkout — never to whichever local tab // happens to be first. void switchToThisMachineProject({ diff --git a/apps/desktop/src/renderer/components/prs/shared/PrMarkdownEditor.tsx b/apps/desktop/src/renderer/components/prs/shared/PrMarkdownEditor.tsx index 161c29226..77f60b4a7 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrMarkdownEditor.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrMarkdownEditor.tsx @@ -12,6 +12,7 @@ import { } from "@phosphor-icons/react"; import { COLORS, MONO_FONT, SANS_FONT } from "../../lanes/laneDesignTokens"; +import { isMac } from "../../../lib/platform"; import { PrMarkdown } from "./PrMarkdown"; type EditorMode = "write" | "preview"; @@ -103,10 +104,12 @@ export function applyAction(textarea: HTMLTextAreaElement, action: ToolbarAction } } +const shortcutModifier = isMac ? "⌘" : "Ctrl+"; + const TOOLBAR: Array<{ action: ToolbarAction; icon: typeof TextB; title: string }> = [ { action: "heading", icon: TextHOne, title: "Heading" }, - { action: "bold", icon: TextB, title: "Bold (⌘B)" }, - { action: "italic", icon: TextItalic, title: "Italic (⌘I)" }, + { action: "bold", icon: TextB, title: `Bold (${shortcutModifier}B)` }, + { action: "italic", icon: TextItalic, title: `Italic (${shortcutModifier}I)` }, { action: "quote", icon: Quotes, title: "Quote" }, { action: "code", icon: Code, title: "Inline code" }, { action: "codeblock", icon: CodeBlock, title: "Code block" }, diff --git a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx index e7da8f928..89ebf3020 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx @@ -48,10 +48,10 @@ function accountMachineStatusLabel( connectionState: ReturnType, ): string { if (connectionState === "unreachable") { - return "Can't reach this Mac right now — make sure it's online and up to date."; + return "Can't reach this computer right now — make sure it's online and up to date."; } if (machine.online) return "Ready to connect"; - return `${relativeLastSeen(machine.lastSeenAt)} · Open ADE on that Mac`; + return `${relativeLastSeen(machine.lastSeenAt)} · Open ADE on that computer`; } export function AccountMachineRow({ @@ -95,7 +95,7 @@ export function AccountMachineRow({ setRenaming(false); onRenamed?.(); } catch (error) { - setRenameError(error instanceof Error ? error.message : "Couldn't rename this Mac."); + setRenameError(error instanceof Error ? error.message : "Couldn't rename this computer."); } finally { setRenameBusy(false); } @@ -275,12 +275,12 @@ export function AccountMachineRow({ {canExplain && detailOpen ? (
- {needsSetup ? "Finish setup on the other Mac" : relativeLastSeen(machine.lastSeenAt)} + {needsSetup ? "Finish setup on the other computer" : relativeLastSeen(machine.lastSeenAt)}
{needsSetup - ? "On that Mac, open ADE and sign in to this same ADE account. Once it's online and up to date, it appears here automatically." - : "This Mac hasn't checked in recently. Open ADE on it, then try again."} + ? "On that computer, open ADE and sign in to this same ADE account. Once it's online and up to date, it appears here automatically." + : "This computer hasn't checked in recently. Open ADE on it, then try again."}
{!needsSetup && machine.reachableEndpoints.length > 0 ? (
diff --git a/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx b/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx index a214948c9..466f30aa1 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/PairMachineForm.tsx @@ -29,10 +29,10 @@ function friendlyPairError(error: unknown): string { .replace(/^Error:\s*/i, "") .trim(); if (/pin|unauthor|forbidden|401|403|invalid code/i.test(message)) { - return "That code didn't work. Check the six digits shown on the other Mac and try again."; + return "That code didn't work. Check the six digits shown on the other computer and try again."; } if (/unreachable|timed out|timeout|ECONN|ENOTFOUND|network|connect|offline/i.test(message)) { - return "Couldn't reach that Mac. Make sure ADE is open there, then try again."; + return "Couldn't reach that computer. Make sure ADE is open there, then try again."; } return message || "Pairing failed."; } @@ -49,7 +49,7 @@ type PairMachineFormProps = { /** * First-time pairing with a nearby Mac: ADE discovered it on the network and * synthesized its pairing URL internally, so the user only confirms the machine - * and types the 6-digit code shown in ADE on that Mac. There is no manual link + * and types the 6-digit code shown in ADE on that computer. There is no manual link * or address entry — nearby discovery is the only entry point. */ export function PairMachineForm({ @@ -64,7 +64,7 @@ export function PairMachineForm({ const [pin, setPin] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const deviceName = defaultDeviceName.trim() || "This Mac"; + const deviceName = defaultDeviceName.trim() || "This computer"; const trimmedInput = initialInput?.trim() ?? ""; @@ -127,10 +127,10 @@ export function PairMachineForm({ return (
void handleSubmit(event)} style={{ display: "grid", gap: 12 }}>
- You haven't connected to this Mac before. Enter the pairing code shown in ADE on that Mac. + You haven't connected to this computer before. Enter the pairing code shown in ADE on that computer.
- Nearby Mac + Nearby computer {parsing ? ( Checking… @@ -171,7 +171,7 @@ export function PairMachineForm({ />
- This confirms that you can see the code on the other Mac. + This confirms that you can see the code on the other computer.
diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx index a18f0e75b..fee6efc4c 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetForm.tsx @@ -137,7 +137,7 @@ export function RemoteTargetForm({ setName(event.target.value)} - placeholder="Mac Studio" + placeholder="Development computer" style={fieldStyle} disabled={busy} /> diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 5e178bbf4..a182a85a4 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -59,7 +59,7 @@ function installAdeMock(): void { remoteRuntimeMock.getLocalPairingInfo.mockResolvedValue({ url: "https://ade-app.dev/pair#payload", pin: "123456", - machineName: "This Mac", + machineName: "This computer", relayAvailable: false, }); remoteRuntimeMock.runDoctor.mockResolvedValue({ checks: [] }); @@ -107,7 +107,7 @@ function getAccountRow(name: string): HTMLElement { return row; } -function openAddMode(label: "Find nearby Macs" | "Add over SSH"): void { +function openAddMode(label: "Find nearby computers" | "Add over SSH"): void { fireEvent.click(screen.getByRole("button", { name: "Add machine" })); fireEvent.click(screen.getByRole("button", { name: new RegExp(`^${label}`) })); } @@ -186,7 +186,7 @@ describe("RemoteTargetList", () => { render(); - openAddMode("Find nearby Macs"); + openAddMode("Find nearby computers"); await waitFor(() => expect(screen.getByText("Studio")).toBeTruthy()); expect(screen.getByText("Found nearby")).toBeTruthy(); @@ -202,7 +202,7 @@ describe("RemoteTargetList", () => { await waitFor(() => expect(remoteRuntimeMock.pairWithMachine).toHaveBeenCalledWith({ input: expect.stringMatching(/^https:\/\/ade-app\.dev\/pair#/), pin: "654321", - deviceName: "This Mac", + deviceName: "This computer", })); await waitFor(() => expect(remoteRuntimeMock.connect).toHaveBeenCalledWith("target-1")); expect(remoteRuntimeMock.saveTarget).not.toHaveBeenCalled(); @@ -237,9 +237,9 @@ describe("RemoteTargetList", () => { render(); - openAddMode("Find nearby Macs"); + openAddMode("Find nearby computers"); - await screen.findByText(/No Macs found/); + await screen.findByText(/No computers found/); expect(screen.queryByText("Linux box")).toBeNull(); expect(screen.queryByText(/SSH/i)).toBeNull(); }); @@ -981,8 +981,8 @@ describe("RemoteTargetList", () => { expect(screen.getByText("CONNECTED")).toBeTruthy(); expect(screen.getByRole("button", { name: "Disconnect" })).toBeTruthy(); - openAddMode("Find nearby Macs"); - expect(screen.getByText(/No Macs found/)).toBeTruthy(); + openAddMode("Find nearby computers"); + expect(screen.getByText(/No computers found/)).toBeTruthy(); expect(screen.queryByText("Windows PC")).toBeNull(); expect(screen.queryByText("Windows — not supported yet")).toBeNull(); }); @@ -1140,7 +1140,7 @@ describe("RemoteTargetList", () => { ), ).toBeTruthy(), ); - expect(screen.getByText("No Macs yet. Choose Add machine to connect one.")).toBeTruthy(); + expect(screen.getByText("No computers yet. Choose Add machine to connect one.")).toBeTruthy(); }); it("adopts a desktop account machine as paired-only instead of saving a broken SSH target", async () => { @@ -1485,7 +1485,7 @@ describe("RemoteTargetList", () => { expect(screen.queryByText("Connected via Tailscale · 12ms")).toBeNull(); }); - it("never lists this Mac as its own remote target (self-filter by machineKey or deviceId)", async () => { + it("never lists this computer as its own remote target (self-filter by machineKey or deviceId)", async () => { remoteRuntimeMock.listTargets.mockResolvedValue([]); remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ machines: [], diagnostics: [] }); installAdeMock(); @@ -1504,7 +1504,7 @@ describe("RemoteTargetList", () => { { machineKey: "reinstalled-mk", deviceId: "local-dev", // matches getLocalMachineIdentity().deviceId (pre-reinstall row) - name: "This Mac Before Reinstall", + name: "This computer Before Reinstall", platform: "darwin", deviceType: "desktop", reachableEndpoints: [], @@ -1534,7 +1534,7 @@ describe("RemoteTargetList", () => { await waitFor(() => expect(screen.getByText("Other Studio")).toBeTruthy()); expect(accountMock.getLocalMachineIdentity).toHaveBeenCalled(); expect(screen.queryByText("This Very Mac")).toBeNull(); - expect(screen.queryByText("This Mac Before Reinstall")).toBeNull(); + expect(screen.queryByText("This computer Before Reinstall")).toBeNull(); }); it("explains how to finish setup when an online account Mac has no ready route", () => { @@ -1576,7 +1576,7 @@ describe("RemoteTargetList", () => { onConnect={vi.fn()} />, ); - expect(screen.getByText("Finish setup on the other Mac")).toBeTruthy(); + expect(screen.getByText("Finish setup on the other computer")).toBeTruthy(); expect(screen.getByText(/it appears here automatically/i)).toBeTruthy(); }); diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 091c4bc10..67144c81a 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -426,7 +426,7 @@ export function RemoteTargetList({ }; }, []); - // This Mac's route-publish health, refreshed periodically so a persisting + // This computer's route-publish health, refreshed periodically so a persisting // failure's "for N min" stays truthful while the panel is open. getInfo is a // cheap one-shot; there is no push event for the publisher's health. useEffect(() => { @@ -1023,7 +1023,7 @@ export function RemoteTargetList({ setAddMode(next); }, []); - // Signed in, account Macs appear in the list automatically, so the add sheet + // Signed in, account computers appear in the list automatically, so the add sheet // only offers Nearby + SSH. Signed out, we lead with the account sign-in. const addChoices = useMemo( () => { @@ -1039,22 +1039,22 @@ export function RemoteTargetList({ key: "signin", icon: UserCircle, label: "Sign in to ADE", - detail: "The easiest way to find and connect to your other Macs.", + detail: "The easiest way to find and connect to your other computers.", onSelect: () => onAccountRequested?.(), }); } choices.push({ key: "nearby", icon: WifiHigh, - label: "Find nearby Macs", - detail: "Search this Wi-Fi for Macs with ADE open.", + label: "Find nearby computers", + detail: "Search this Wi-Fi for computers with ADE open.", onSelect: () => chooseAddMode("nearby"), }); choices.push({ key: "ssh", icon: TerminalWindow, label: "Add over SSH (Advanced)", - detail: "Connect with the Mac's SSH address and private key.", + detail: "Connect with the computer's SSH address and private key.", onSelect: () => chooseAddMode("ssh"), }); return choices; @@ -1107,7 +1107,7 @@ export function RemoteTargetList({ > - Other devices may not reach this Mac — route publish failing for{" "} + Other devices may not reach this computer — route publish failing for{" "} {publishHealthDisplay.minutes} min
@@ -1216,7 +1216,7 @@ export function RemoteTargetList({ {loadingDiscovered ?
Scanning nearby machines…
: null} {!loadingDiscovered && nearbyMachines.length === 0 ? (
- No Macs found. Open ADE on the other Mac and make sure both are on the same Wi-Fi or Tailscale network. + No computers found. Open ADE on the other computer and make sure both are on the same Wi-Fi or Tailscale network.
) : null} {nearbyMachines.map((machine) => ( @@ -1271,14 +1271,14 @@ export function RemoteTargetList({ {accountMachinesState && accountMachinesState !== "ok" && accountMachinesState !== "signed_out" ? (
{accountMachinesState === "not_configured" - ? "Account Macs aren't available yet. Saved and nearby Macs still work." - : "We couldn't load your account Macs. Saved and nearby Macs still work."} + ? "Account computers aren't available yet. Saved and nearby computers still work." + : "We couldn't load your account computers. Saved and nearby computers still work."}
) : null} {!loading && totalRows === 0 && !addMode && !loadingDiscovered ? (
- No Macs yet. Choose Add machine to connect one. + No computers yet. Choose Add machine to connect one.
) : null} {loadingDiscovered ? ( diff --git a/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx index ac16567d5..82a2aa3e3 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx @@ -239,7 +239,7 @@ export function SavedMachineRow({ ) : null}
- {target.transport === "paired" ? "Paired with this Mac" : "Saved SSH connection"} + {target.transport === "paired" ? "Paired with this computer" : "Saved SSH connection"}
{section === "unavailable" && row.unavailableReason ? ( @@ -441,7 +441,7 @@ export function SavedMachineRow({ Reconnect automatically - ADE will reconnect when the app opens. LAN and Tailscale work without signing in; ADE Relay needs the same account on both Macs. + ADE will reconnect when the app opens. LAN and Tailscale work without signing in; ADE Relay needs the same account on both computers. diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts index a1bea14e1..cb8b42c98 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -210,7 +210,7 @@ export function machineMatchesSavedTarget( } // --------------------------------------------------------------------------- -// Local "This Mac" route-publish health +// Local "This computer" route-publish health // --------------------------------------------------------------------------- /** Publish-health slice the runtime status IPC exposes for this machine. */ @@ -228,7 +228,7 @@ export type PublishHealthDisplay = export const PUBLISH_FAILING_ALARM_MS = 2 * 60_000; /** - * Non-publishing states: this Mac isn't advertising routes to the account + * Non-publishing states: this computer isn't advertising routes to the account * directory (sync off, not the host, signed out, …). There is nothing wrong to * surface, so these read as "none" rather than a failure. */ diff --git a/apps/desktop/src/renderer/components/settings/SecretsSection.tsx b/apps/desktop/src/renderer/components/settings/SecretsSection.tsx index 00b1dca26..9b5aa81f3 100644 --- a/apps/desktop/src/renderer/components/settings/SecretsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SecretsSection.tsx @@ -312,7 +312,7 @@ export function SecretsSection() {
- Import reads a file from this Mac. Export writes an unencrypted .env file containing all project secret values to Downloads on the machine hosting this project. + Import reads a file from this computer. Export writes an unencrypted .env file containing all project secret values to Downloads on the machine hosting this project.
@@ -296,6 +305,22 @@ export function ThisMacCard({
+ {crdtUnavailable ? ( +
+ {status.blockingStateText} +
+ ) : null} + {(host && routeLabels.length > 0) || appInfo ? (
{host && routeLabels.length > 0 ? ( @@ -311,7 +336,7 @@ export function ThisMacCard({
) : null} - {host ? ( + {host && !crdtUnavailable ? ( isRemoteBound ? (
Pairing code
- New nearby devices enter this code the first time they connect to this Mac. + New nearby devices enter this code the first time they connect to this computer.
{!pinConfigured ? (
Pairing code
{stateLine}
Pairing changes aren’t available while this window is connected to{" "} - {boundMachineName ?? "another Mac"}. + {boundMachineName ?? "another computer"}.
); @@ -514,7 +542,7 @@ export function PhoneConnectionsTab({ ) : (
- Set up phones on the Mac that hosts your ADE projects. + Set up phones on the computer that hosts your ADE projects.
)}
@@ -525,8 +553,8 @@ function ConnectNewPhone({ status }: { status: SyncRoleSnapshot }) { const pinReadout = status.pairingPin ? status.pairingPin : status.pairingPinConfigured - ? "Pairing code is set — see This Mac above" - : "Set a pairing code in This Mac above"; + ? "Pairing code is set — see This computer above" + : "Set a pairing code in This computer above"; return (
@@ -534,7 +562,7 @@ function ConnectNewPhone({ status }: { status: SyncRoleSnapshot }) { Connect a new phone
- Sign in to ADE on your iPhone — this Mac appears automatically. + Sign in to ADE on your iPhone — this computer appears automatically.
{status.pairingConnectInfo ? (
- Or scan this code with your iPhone camera, then enter this Mac's pairing code. + Or scan this code with your iPhone camera, then enter this computer's pairing code.
Pairing code @@ -610,7 +638,7 @@ export function WebConnectionsTab({ {accountSignedIn ? ( <>
- Open the web client and sign in with your ADE account to reach this Mac. + Open the web client and sign in with your ADE account to reach this computer.
{usageReady && hasPressureSignal ? ( diff --git a/apps/desktop/src/renderer/components/settings/useSyncConnections.ts b/apps/desktop/src/renderer/components/settings/useSyncConnections.ts index 734314e43..edda6e757 100644 --- a/apps/desktop/src/renderer/components/settings/useSyncConnections.ts +++ b/apps/desktop/src/renderer/components/settings/useSyncConnections.ts @@ -9,8 +9,8 @@ export type SyncConnections = ReturnType; /** Display name for a machine snapshot, preferring its per-runtime name. */ function machineDisplayName(status: SyncRoleSnapshot | null): string { - if (!status) return "This Mac"; - return status.runtimeName?.trim() || status.localDevice.name || "This Mac"; + if (!status) return "This computer"; + return status.runtimeName?.trim() || status.localDevice.name || "This computer"; } function isRemoteBinding( @@ -110,7 +110,7 @@ export function useSyncConnections() { }, [refresh]); // When remote-bound, routed listDevices() describes that remote machine. - // Derive this Mac's connected devices from the local snapshot's live peers; + // Derive this computer's connected devices from the local snapshot's live peers; // offline-but-paired rows are not available until a local-scoped IPC exists. const isRemoteBound = isRemoteBinding(status, routedStatus); const boundMachineName = isRemoteBound ? machineDisplayName(routedStatus) : null; @@ -189,7 +189,7 @@ export function useSyncConnections() { boundMachineName, /** Display name of this physical Mac (from the local snapshot). */ localMachineName, - /** Whether device/pairing mutations are known to land on this Mac. */ + /** Whether device/pairing mutations are known to land on this computer. */ canManageDevices: status !== null && !isRemoteBound, setPinValue, generatePin, diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index f1c299844..8d172762e 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -121,7 +121,7 @@ /* App shell primitives (shared contract for TopBar + sidebar) */ --shell-header-height: 32px; - --shell-header-padding-start: 80px; + --shell-header-padding-start: 14px; --shell-header-padding-end: 14px; --shell-header-bg: rgba(12, 11, 16, 0.82); --shell-header-fg: var(--color-fg); @@ -138,6 +138,15 @@ --shell-sidebar-item-hover-bg: rgba(255, 255, 255, 0.05); --shell-sidebar-item-active-fg: var(--color-accent); --shell-sidebar-item-active-bg: rgba(167, 139, 250, 0.1); + + &[data-ade-platform="darwin"] { + --shell-header-padding-start: 80px; + } + + &[data-ade-platform="win32"] { + /* Keep utility controls clear of minimize/maximize/close in the overlay. */ + --shell-header-padding-end: 150px; + } --shell-sidebar-item-active-rail: var(--color-accent); --shell-sidebar-separator: rgba(255, 255, 255, 0.04); diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 824c8ed29..a33719f73 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -1552,7 +1552,7 @@ describe("appStore", () => { expect(useAppStore.getState().projectTransitionError).toEqual({ code: "disk_full", - message: "Your Mac ran out of storage while ADE was saving project data. Free up space, then try again.", + message: "Your computer ran out of storage while ADE was saving project data. Free up space, then try again.", detail: "internal database detail", rootPath: "/tmp/project", }); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index bb86714ed..aa3c96d6d 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -1031,7 +1031,7 @@ export type CrossMachineMachineLanes = { /** * Usually a remote target id. `THIS_MACHINE_ID` is stored only while the * active tab is bound remotely, because then `lanes` belongs to that remote - * binding and This Mac is one of the union's other machines. + * binding and This computer is one of the union's other machines. */ machineId: string; /** Absolute machine name ("MacBook Pro (97)"). Never the word "remote". */ @@ -1404,7 +1404,7 @@ function formatProjectTransitionError( } const code = toAdeRecoveryErrorCode(parsed.code); const recoveryMessage = code === "disk_full" - ? "Your Mac ran out of storage while ADE was saving project data. Free up space, then try again." + ? "Your computer ran out of storage while ADE was saving project data. Free up space, then try again." : code === "brain_crash_looping" || code === "migration_incomplete" || code === "migration_unknown_state" ? "ADE's background service needs a repair before this project can open." : code && [ diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts index 85279ccc1..e38067b72 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.test.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.test.ts @@ -274,7 +274,7 @@ describe("offline machines stay in the sidebar, dimmed", () => { }); }); -describe("This Mac counterpart resolution", () => { +describe("This computer counterpart resolution", () => { it("joins only an existing local checkout with the same normalized origin", () => { expect(resolveThisMachineBindingForOrigin([ { @@ -368,7 +368,7 @@ describe("machine marker", () => { }, [THIS_MACHINE_ID]: { machineId: THIS_MACHINE_ID, - machineName: "This Mac", + machineName: "This computer", targetId: null, projectId: null, binding: { @@ -635,7 +635,7 @@ describe("selectOtherMachineBranchStates", () => { const warning = detectPushDivergence({ current: { machineId: THIS_MACHINE_ID, - machineName: "This Mac", + machineName: "This computer", branchRef: "feature/shared", headSha: null, ahead: 1, @@ -752,7 +752,7 @@ describe("selectOtherMachineBranchStates", () => { }, [THIS_MACHINE_ID]: { machineId: THIS_MACHINE_ID, - machineName: "This Mac", + machineName: "This computer", targetId: null, projectId: null, online: true, @@ -845,7 +845,7 @@ describe("foreign payload decoding", () => { }); describe("cross-machine refresh scheduling", () => { - it("reads This Mac explicitly while the active tab is bound remotely", async () => { + it("reads This computer explicitly while the active tab is bound remotely", async () => { vi.useFakeTimers(); const localBinding = { kind: "local" as const, diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.ts b/apps/desktop/src/renderer/state/crossMachineLanes.ts index 80a9863b1..5a526cbdd 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.ts @@ -607,7 +607,7 @@ export function selectOtherMachineBranchStates( } } // A lane outside the active binding compares against the active binding too; - // `state.lanes` is not necessarily This Mac. + // `state.lanes` is not necessarily This computer. if (subjectMachineId !== activeMachineId) { for (const lane of state.lanes) { if (normalizeBranchRef(lane.branchRef) !== subjectBranch) continue; @@ -640,7 +640,7 @@ export type CrossMachineLaneScope = { repoDisplayName: string | null; /** Verified local origin URL; null means this repo has no usable origin. */ repoOriginUrl: string | null; - /** Target id the tab is bound to; null when the tab is on this Mac. */ + /** Target id the tab is bound to; null when the tab is on this computer. */ boundTargetId: string | null; /** Project id on the bound machine, when the tab is bound to a remote one. */ boundProjectId: string | null; @@ -1032,7 +1032,7 @@ async function readThisMachine( binding, ), MACHINE_READ_TIMEOUT_MS, - "lane.list on This Mac", + "lane.list on This computer", ); // See `readMachine`: PRs ride the lane cadence for the same reasons, and the // read is best-effort — a machine that fails only this read must still @@ -1061,7 +1061,7 @@ async function readThisMachine( binding, ), MACHINE_READ_TIMEOUT_MS, - "session.list on This Mac", + "session.list on This computer", ), lanesDue ? readPrs() : null, ]); @@ -1199,7 +1199,7 @@ function scheduleRefresh(): void { /** * The machines that can contribute rows for the current scope: connected AND - * still hosting this repository, excluding This Mac and the tab's own binding + * still hosting this repository, excluding This computer and the tab's own binding * (both of which the primary list already owns). * * Reachability and the read list share this one definition on purpose. When they @@ -1358,7 +1358,7 @@ function applyReachability(): void { ]); for (const machineId of scopedMachineIds) { const entry = store.crossMachineLanesByMachineId[machineId] ?? null; - // This Mac is not a connection target and is always reachable; holding a + // This computer is not a connection target and is always reachable; holding a // drop record for it would leak a map entry nothing can ever clear. if (machineId === THIS_MACHINE_ID) continue; const machine = connectivity.get(machineId); diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx b/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx index 71a5fc981..10073ec27 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx @@ -62,7 +62,7 @@ describe("hosted Connections pane", () => { , ); - expect(await screen.findByText("No Macs yet. Choose Add machine to connect one.")).toBeTruthy(); + expect(await screen.findByText("No computers yet. Choose Add machine to connect one.")).toBeTruthy(); await expect(window.ade.remoteRuntime.getConnectionSnapshot()).resolves.toEqual({ connections: [], connectedCount: 0, diff --git a/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts b/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts index cf2b0e45c..ff98397b1 100644 --- a/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts +++ b/apps/desktop/src/renderer/webclient/adapter/sessionLifecycleSupport.ts @@ -39,10 +39,10 @@ const REQUIRED_ACTIONS = [ ] as const; export const SESSION_LIFECYCLE_DISCONNECTED_MESSAGE = - "Can't reach this Mac right now, so nothing was changed."; + "Can't reach this computer right now, so nothing was changed."; export const SESSION_LIFECYCLE_UNSUPPORTED_MESSAGE = - "This Mac is running an older ADE that can't settle or snooze sessions."; + "This computer is running an older ADE that can't settle or snooze sessions."; export type SessionLifecycleUnavailableCode = "disconnected" | "unsupported"; diff --git a/apps/desktop/src/renderer/webclient/shell/shellTokens.ts b/apps/desktop/src/renderer/webclient/shell/shellTokens.ts index 940389c6f..be60d7aa9 100644 --- a/apps/desktop/src/renderer/webclient/shell/shellTokens.ts +++ b/apps/desktop/src/renderer/webclient/shell/shellTokens.ts @@ -34,12 +34,12 @@ export function connectionTone(state: SyncConnectionState): ConnectionTone { case "reconnecting": return { label: "Reconnecting", color: COLORS.warning, live: false, fatal: false }; case "auth_failed": - return { label: "Can't reach this Mac", color: COLORS.danger, live: false, fatal: true }; + return { label: "Can't reach this computer", color: COLORS.danger, live: false, fatal: true }; case "error": - return { label: "Can't reach this Mac", color: COLORS.danger, live: false, fatal: false }; + return { label: "Can't reach this computer", color: COLORS.danger, live: false, fatal: false }; case "disconnected": case "idle": default: - return { label: "Can't reach this Mac", color: COLORS.textMuted, live: false, fatal: false }; + return { label: "Can't reach this computer", color: COLORS.textMuted, live: false, fatal: false }; } } diff --git a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts index ffcb3701f..e2e65f54e 100644 --- a/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts +++ b/apps/desktop/src/renderer/webclient/sync/__tests__/sync.test.ts @@ -1322,7 +1322,7 @@ describe("browser sync connection and client", () => { script.sockets[0]?.close(4501, "host offline"); await expect(outcome).resolves.toMatchObject({ - message: expect.stringContaining("Can't reach this Mac"), + message: expect.stringContaining("Can't reach this computer"), }); expect(script.sockets[0]?.sent).toEqual([]); expect(connection.getStatus().state).toBe("reconnecting"); @@ -2257,9 +2257,9 @@ describe("browser sync connection and client", () => { }); it.each([ - { code: 4501, expected: "Can't reach this Mac. Retrying…" }, - { code: 4507, expected: "Your Mac couldn't accept the connection. Retrying…" }, - { code: 4503, expected: "Too many active connections to this Mac" }, + { code: 4501, expected: "Can't reach this computer. Retrying…" }, + { code: 4507, expected: "Your computer couldn't accept the connection. Retrying…" }, + { code: 4503, expected: "Too many active connections to this computer" }, { code: 4502, expected: "Connection lost. Reconnecting." }, { code: 4000, expected: "Connection lost. Reconnecting." }, { code: 4505, expected: "Connection lost. Reconnecting." }, diff --git a/apps/desktop/src/renderer/webclient/sync/client.ts b/apps/desktop/src/renderer/webclient/sync/client.ts index be889e4b3..28094b01c 100644 --- a/apps/desktop/src/renderer/webclient/sync/client.ts +++ b/apps/desktop/src/renderer/webclient/sync/client.ts @@ -631,7 +631,7 @@ export class AdeSyncClient { ...transportStatus, state, error: this.readiness === "failed" - ? this.readinessError?.message ?? "Can't reach this Mac." + ? this.readinessError?.message ?? "Can't reach this computer." : transportStatus.error, activeProjectId: this.activeProjectId, selectedEnvId: this.selectedEnvId, @@ -785,7 +785,7 @@ export class AdeSyncClient { || this.terminalInputQueueBytes + byteLength > MAX_QUEUED_TERMINAL_INPUT_BYTES ) { throw new AdeSyncError( - "Terminal input is waiting for this Mac to catch up. Try again in a moment.", + "Terminal input is waiting for this computer to catch up. Try again in a moment.", "terminal_input_queue_full", ); } @@ -972,7 +972,7 @@ export class AdeSyncClient { this.latestHello = payload; if (!this.terminalInputAcksSupported() && this.terminalInputQueue.length > 0) { this.rejectTerminalInputQueue(new AdeSyncError( - "The reconnected Mac cannot safely confirm pending terminal input.", + "The reconnected computer cannot safely confirm pending terminal input.", "terminal_input_ack_unavailable", )); } @@ -1039,7 +1039,7 @@ export class AdeSyncClient { private async awaitCurrentRestoration(): Promise { const restoration = this.restorationPromise; if (!restoration) { - throw new AdeSyncError("The Mac connected without restoring the workspace.", "restoration_missing"); + throw new AdeSyncError("The computer connected without restoring the workspace.", "restoration_missing"); } await restoration; } @@ -1248,7 +1248,7 @@ export class AdeSyncClient { const rawError = (payload as { error?: unknown }).error; if (!rawError || typeof rawError !== "object") { this.failTerminalInput(operation, new AdeSyncError( - "This Mac returned an invalid terminal input response.", + "This computer returned an invalid terminal input response.", "terminal_input_invalid_ack", )); return; @@ -1277,7 +1277,7 @@ export class AdeSyncClient { this.failTerminalInput(operation, new AdeSyncError( typeof inputError.message === "string" && inputError.message.trim() ? inputError.message - : "This Mac rejected terminal input.", + : "This computer rejected terminal input.", `terminal_input_${code}`, { retryable }, )); @@ -1404,7 +1404,7 @@ export class AdeSyncClient { } if (operation.attempts >= this.terminalInputMaxAttempts) { this.failTerminalInput(operation, new AdeSyncError( - "This Mac did not confirm terminal input. Check the terminal before retrying.", + "This computer did not confirm terminal input. Check the terminal before retrying.", "terminal_input_ack_timeout", { attempts: operation.attempts }, )); @@ -1617,7 +1617,7 @@ export class AdeSyncClient { private requireReadyGeneration(): number { if (this.readiness !== "ready" || !this.connection.isConnected()) { - throw new AdeSyncError("Reconnecting to this Mac. Try again when connected.", "not_connected"); + throw new AdeSyncError("Reconnecting to this computer. Try again when connected.", "not_connected"); } return this.clientGeneration; } @@ -1626,7 +1626,7 @@ export class AdeSyncClient { return error instanceof AdeSyncError ? error : new AdeSyncError( - error instanceof Error ? error.message : "Connection to this Mac was lost.", + error instanceof Error ? error.message : "Connection to this computer was lost.", "not_connected", error, ); diff --git a/apps/desktop/src/renderer/webclient/sync/connection.ts b/apps/desktop/src/renderer/webclient/sync/connection.ts index 0992a49bb..ce4b43e2a 100644 --- a/apps/desktop/src/renderer/webclient/sync/connection.ts +++ b/apps/desktop/src/renderer/webclient/sync/connection.ts @@ -79,7 +79,7 @@ const FULL_INVALIDATION_TABLES = [ "rebase", ] as const; export const INVALIDATION_ONLY_V1_HOST_UPDATE_MESSAGE = - "Update ADE on this Mac via Settings > General > Check for Updates, then retry."; + "Update ADE on this computer via Settings > General > Check for Updates, then retry."; function invalidationTables(payload: SyncInvalidationBatchPayload): Set { if (!payload || typeof payload !== "object") return new Set(FULL_INVALIDATION_TABLES); @@ -215,9 +215,9 @@ function protocolVersionMismatchMessage( payload: Extract, ): string { if (payload.updateTarget === "host") { - return "Update ADE on your Mac to connect to this browser."; + return "Update ADE on your computer to connect to this browser."; } - return "Update ADE in this browser to connect to your Mac."; + return "Update ADE in this browser to connect to your computer."; } function hostAcceptedInvalidationOnlyV1(payload: SyncHelloOkPayload): boolean { @@ -1441,9 +1441,9 @@ export class SyncConnection { private errorForClose(event: Pick): SyncConnectionError { switch (event.code) { case 4501: - return new SyncConnectionError("Can't reach this Mac. Retrying…", "relay_host_offline"); + return new SyncConnectionError("Can't reach this computer. Retrying…", "relay_host_offline"); case 4507: - return new SyncConnectionError("Your Mac couldn't accept the connection. Retrying…", "relay_bridge_rejected"); + return new SyncConnectionError("Your computer couldn't accept the connection. Retrying…", "relay_bridge_rejected"); case 4508: return new SyncConnectionError("Connection setup expired. Reconnecting.", "relay_stale_pipe"); case 4509: @@ -1451,7 +1451,7 @@ export class SyncConnection { case 4510: return new SyncConnectionError("Connection was not ready. Reconnecting.", "relay_not_ready"); case 4503: - return new SyncConnectionError("Too many active connections to this Mac", "relay_capacity"); + return new SyncConnectionError("Too many active connections to this computer", "relay_capacity"); case 4502: return new SyncConnectionError("Connection lost. Reconnecting.", "relay_idle"); case 4505: diff --git a/apps/desktop/src/renderer/webclient/sync/wireProtocol.ts b/apps/desktop/src/renderer/webclient/sync/wireProtocol.ts index 4b9c16257..eeca9b80e 100644 --- a/apps/desktop/src/renderer/webclient/sync/wireProtocol.ts +++ b/apps/desktop/src/renderer/webclient/sync/wireProtocol.ts @@ -32,8 +32,8 @@ export class BrowserSyncProtocolVersionMismatchError extends Error { : `${SYNC_PROTOCOL_MIN_SUPPORTED}-${SYNC_PROTOCOL_VERSION}`; const updateTarget = receivedVersion < SYNC_PROTOCOL_MIN_SUPPORTED ? "host" : "client"; super(updateTarget === "host" - ? `Update ADE on your Mac. It uses sync protocol ${receivedVersion}; this browser supports ${supported}.` - : `Update ADE in this browser. The Mac uses sync protocol ${receivedVersion}; this browser supports ${supported}.`); + ? `Update ADE on your computer. It uses sync protocol ${receivedVersion}; this browser supports ${supported}.` + : `Update ADE in this browser. The computer uses sync protocol ${receivedVersion}; this browser supports ${supported}.`); this.name = "BrowserSyncProtocolVersionMismatchError"; this.updateTarget = updateTarget; } diff --git a/apps/desktop/src/shared/laneDivergence.test.ts b/apps/desktop/src/shared/laneDivergence.test.ts index 6bcccb1a7..5c6869908 100644 --- a/apps/desktop/src/shared/laneDivergence.test.ts +++ b/apps/desktop/src/shared/laneDivergence.test.ts @@ -22,7 +22,7 @@ function machine(overrides: Partial = {}): MachineBranchStat const current = machine({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", headSha: "aaaaaaa", ahead: 1, behind: 0, @@ -176,13 +176,13 @@ describe("toMachineBranchState", () => { expect( toMachineBranchState({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", lane: { branchRef: "feature/x", status: { ahead: 2, behind: 1 } }, headSha: "aaaaaaa", }), ).toEqual({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", branchRef: "feature/x", headSha: "aaaaaaa", ahead: 2, @@ -194,12 +194,12 @@ describe("toMachineBranchState", () => { expect( toMachineBranchState({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", lane: { branchRef: "feature/x" }, }), ).toEqual({ machineId: "machine-this", - machineName: "This Mac", + machineName: "This computer", branchRef: "feature/x", headSha: null, ahead: 0, @@ -268,7 +268,7 @@ describe("detectPushDivergence over real lane snapshots", () => { detectPushDivergence({ current: toMachineBranchState({ machineId: "this-mac", - machineName: "This Mac", + machineName: "This computer", lane: here.lane, }), others: [ @@ -297,7 +297,7 @@ describe("detectPushDivergence over real lane snapshots", () => { detectPushDivergence({ current: toMachineBranchState({ machineId: "this-mac", - machineName: "This Mac", + machineName: "This computer", lane: here.lane, }), others: [ @@ -315,7 +315,7 @@ describe("detectPushDivergence over real lane snapshots", () => { const here = laneSnapshot(); const current = toMachineBranchState({ machineId: "this-mac", - machineName: "This Mac", + machineName: "This computer", lane: here.lane, }); diff --git a/apps/desktop/src/shared/laneDivergence.ts b/apps/desktop/src/shared/laneDivergence.ts index b8ca89405..5d7f2be3b 100644 --- a/apps/desktop/src/shared/laneDivergence.ts +++ b/apps/desktop/src/shared/laneDivergence.ts @@ -19,7 +19,7 @@ export type MachineBranchState = { machineId: string; /** - * Absolute machine name as shown to the user ("This Mac", "MacBook Pro (97)"). + * Absolute machine name as shown to the user ("This computer", "MacBook Pro (97)"). * Never a relative word like "remote" — the user has to know *which* machine. */ machineName: string; diff --git a/apps/desktop/src/shared/types/account.ts b/apps/desktop/src/shared/types/account.ts index b1913e054..dabd685ed 100644 --- a/apps/desktop/src/shared/types/account.ts +++ b/apps/desktop/src/shared/types/account.ts @@ -76,7 +76,7 @@ export type AdeAccountMachinesResult = { message: string | null; }; -/** Stable identities used to recognize this Mac in the account directory. */ +/** Stable identities used to recognize this computer in the account directory. */ export type AdeAccountLocalMachineIdentity = { machineKey: string; deviceId: string; diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 36d4d8e13..5a70164cc 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -66,7 +66,7 @@ struct ContentView: View { guard requestId != nil else { return } // A linear-issue deep link opens the global pane (it consumes the // request once presented). Only reached when a project is active — the - // router bounces the link to the Mac otherwise. + // router bounces the link to the computer otherwise. syncService.closeProjectHub() syncService.linearPanePresented = true } diff --git a/apps/ios/ADE/App/DeepLinkRouter.swift b/apps/ios/ADE/App/DeepLinkRouter.swift index ee8138856..04b208321 100644 --- a/apps/ios/ADE/App/DeepLinkRouter.swift +++ b/apps/ios/ADE/App/DeepLinkRouter.swift @@ -94,7 +94,7 @@ final class DeepLinkRouter { ) case "lane": // Lanes are a local-only desktop concept — the iOS client has no - // counterpart UI, so we surface a "Send to your Mac" card instead of + // counterpart UI, so we surface a "Send to your computer" card instead of // trying to navigate. guard let laneId = pathComponents.first, ADEDeepLinkURLParsing.isValidUUID(laneId) else { return } diff --git a/apps/ios/ADE/Services/SSHBootstrapModels.swift b/apps/ios/ADE/Services/SSHBootstrapModels.swift index cdc048a2a..ec9eadf8d 100644 --- a/apps/ios/ADE/Services/SSHBootstrapModels.swift +++ b/apps/ios/ADE/Services/SSHBootstrapModels.swift @@ -66,7 +66,7 @@ struct SSHBootstrapResponse: Decodable, Equatable { guard ok else { throw SSHBootstrapError.remote( code: error?.code ?? "pairing_failed", - message: error?.message ?? "The Mac could not complete setup." + message: error?.message ?? "The computer could not complete setup." ) } guard let machine, let pairing, let sync, @@ -138,20 +138,20 @@ enum SSHBootstrapError: LocalizedError, Equatable { var errorDescription: String? { switch self { - case .invalidHost: "Enter a Mac address." + case .invalidHost: "Enter a computer address." case .invalidPort: "Enter an SSH port from 1 to 65535." - case .invalidUsername: "Enter the macOS username used for SSH." + case .invalidUsername: "Enter the macOS or Linux username used for SSH." case .invalidPrivateKey: "Paste or import a supported private key." case .unsupportedKey(let detail): detail case .passphraseRequired: "This private key needs its passphrase." case .incorrectPassphrase: "The private-key passphrase is incorrect." - case .hostKeyNotConfirmed: "Compare the Mac's SSH fingerprint, then confirm that it matches." + case .hostKeyNotConfirmed: "Compare the computer's SSH fingerprint, then confirm that it matches." case .hostKeyChanged(let expected, let received): - "This Mac's SSH fingerprint changed. Expected \(expected), but received \(received). Check the fingerprint on the Mac before trying again." - case .cliUnavailable: "ADE is not installed for this user on the Mac. Install ADE, then try again." - case .invalidResponse: "The Mac returned an unexpected response. Update ADE on the Mac, then try again." - case .responseTooLarge: "The Mac returned an unexpectedly large response. Update ADE on the Mac, then try again." - case .timedOut: "The Mac took too long to finish setup. Make sure ADE is open on the Mac, then try again." + "This computer's SSH fingerprint changed. Expected \(expected), but received \(received). Check the fingerprint on the computer before trying again." + case .cliUnavailable: "ADE is not installed for this user on the computer. Install ADE, then try again." + case .invalidResponse: "The computer returned an unexpected response. Update ADE on the computer, then try again." + case .responseTooLarge: "The computer returned an unexpectedly large response. Update ADE on the computer, then try again." + case .timedOut: "The computer took too long to finish setup. Make sure ADE is open on the computer, then try again." case .remote(_, let message): message } } diff --git a/apps/ios/ADE/Services/SSHBootstrapService.swift b/apps/ios/ADE/Services/SSHBootstrapService.swift index 6c4c69fcb..54ba35723 100644 --- a/apps/ios/ADE/Services/SSHBootstrapService.swift +++ b/apps/ios/ADE/Services/SSHBootstrapService.swift @@ -85,7 +85,7 @@ actor SSHBootstrapService { username: input.normalizedUsername ) } catch { - validated.credentialWarning = "Your Mac is paired, but ADE could not save the SSH key. You can pair again later if you need SSH recovery." + validated.credentialWarning = "Your computer is paired, but ADE could not save the SSH key. You can pair again later if you need SSH recovery." } } else { credentialStore.remove(host: input.normalizedHost, port: input.port, username: input.normalizedUsername) diff --git a/apps/ios/ADE/Services/SyncRecoveryPolicy.swift b/apps/ios/ADE/Services/SyncRecoveryPolicy.swift index 359ee2d95..45571de50 100644 --- a/apps/ios/ADE/Services/SyncRecoveryPolicy.swift +++ b/apps/ios/ADE/Services/SyncRecoveryPolicy.swift @@ -334,11 +334,11 @@ func syncSocketCloseError(closeCodeRawValue: Int, reason: String?) -> NSError { case 4004: message = "Connection attempts are paused briefly. Try again shortly." case 4503: - message = "This Mac is handling too many connections. Try again shortly." + message = "This computer is handling too many connections. Try again shortly." case 4000, 4001, 4002, 4008, 4501, 4502, 4505, 4506, 4507: - message = "Can’t reach this Mac right now. Reconnecting now." + message = "Can’t reach this computer right now. Reconnecting now." default: - message = "Can’t reach this Mac right now. Reconnecting now." + message = "Can’t reach this computer right now. Reconnecting now." } var userInfo: [String: Any] = [ NSLocalizedDescriptionKey: message, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 910f432a7..f89980716 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -440,13 +440,13 @@ struct AccountAdoptionIdentityVerificationError: LocalizedError, Equatable { let machineName: String var errorDescription: String? { - "Couldn't verify that \(machineName)'s identity. Open ADE on that Mac and try again." + "Couldn't verify that \(machineName)'s identity. Open ADE on that computer and try again." } } /// A route this build cannot negotiate -- today, a host that named an adoption /// cipher this client does not implement. That is a version gap, not evidence -/// the Mac is an impostor, so it fails only its own route: another route (or +/// the computer is an impostor, so it fails only its own route: another route (or /// another host build) may negotiate fine, and the user needs "update", not a /// security warning. The unsupported cipher itself is still never used. struct AccountAdoptionRouteCompatibilityError: LocalizedError, Equatable { @@ -959,9 +959,9 @@ enum SyncRelayAuthorizationRequirement: String, Equatable, Error, LocalizedError var errorDescription: String? { switch self { case .signInRequired: - return "Sign in to the same ADE account as this Mac to connect from another network. LAN and Tailscale still work without an account." + return "Sign in to the same ADE account as this computer to connect from another network. LAN and Tailscale still work without an account." case .sameAccountRequired: - return "This Mac's internet connection belongs to another ADE account. Sign in with the same account as the Mac, or connect over LAN or Tailscale." + return "This computer's internet connection belongs to another ADE account. Sign in with the same account as the computer, or connect over LAN or Tailscale." } } } @@ -1609,8 +1609,8 @@ struct SyncProtocolVersionMismatchError: LocalizedError, Equatable { var errorDescription: String? { updateTarget == "host" - ? "Update ADE on your Mac. It uses sync protocol \(receivedVersion); this iPhone supports \(minSupportedVersion)-\(currentVersion)." - : "Update ADE on this iPhone. The Mac uses sync protocol \(receivedVersion); this iPhone supports \(minSupportedVersion)-\(currentVersion)." + ? "Update ADE on your computer. It uses sync protocol \(receivedVersion); this iPhone supports \(minSupportedVersion)-\(currentVersion)." + : "Update ADE on this iPhone. The computer uses sync protocol \(receivedVersion); this iPhone supports \(minSupportedVersion)-\(currentVersion)." } } @@ -1626,8 +1626,8 @@ func syncProtocolMismatchMessage(_ payload: [String: Any]) -> String { versions = "" } return target == "host" - ? "Update ADE on your Mac to connect this iPhone.\(versions)" - : "Update ADE on this iPhone to connect to your Mac.\(versions)" + ? "Update ADE on your computer to connect this iPhone.\(versions)" + : "Update ADE on this iPhone to connect to your computer.\(versions)" } func syncProtocolVersionNumber(_ value: Any?) -> Int? { @@ -2422,7 +2422,7 @@ enum SyncUserFacingError { return "This phone no longer has a saved address for this machine. Open Settings to rediscover it or pair again." } if lowered.contains("the host is offline") || lowered.contains("requires a live connection to the host") { - return "Can’t reach this Mac right now." + return "Can’t reach this computer right now." } if lowered.contains("the host returned incomplete") { return "The machine sent incomplete sync data. Retry the affected area or reconnect the machine." @@ -3104,13 +3104,13 @@ func workStartShellSessionRequest( struct AccountPairingAuthorizationChangedError: LocalizedError, Equatable { var errorDescription: String? { - "Your ADE account changed while this Mac was connecting. Sign in, then try again." + "Your ADE account changed while this computer was connecting. Sign in, then try again." } } struct AccountPairingConnectionSupersededError: LocalizedError, Equatable { var errorDescription: String? { - "A newer Mac connection replaced this attempt." + "A newer computer connection replaced this attempt." } } @@ -3297,13 +3297,13 @@ final class SyncService: ObservableObject { connectionState == .connected || connectionState == .syncing } - /// Human-facing name of the connected machine, or a neutral "your Mac" + /// Human-facing name of the connected machine, or a neutral "your computer" /// fallback. Shared by Linear connect/status copy (and available to other /// surfaces that otherwise re-derive the same fallback). var machineDisplayName: String { let trimmed = hostName?.trimmingCharacters(in: .whitespacesAndNewlines) if let trimmed, !trimmed.isEmpty { return trimmed } - return "your Mac" + return "your computer" } /// Whether this phone currently holds a Tailscale-assigned address on a /// tunnel interface. Drives the "iPhone isn't on Tailscale" connection hint. @@ -4453,7 +4453,7 @@ final class SyncService: ObservableObject { } guard canSendLiveRequests() else { throw NSError(domain: "ADE", code: 14, userInfo: [ - NSLocalizedDescriptionKey: "Can’t reach this Mac right now." + NSLocalizedDescriptionKey: "Can’t reach this computer right now." ]) } } @@ -5995,7 +5995,7 @@ final class SyncService: ObservableObject { return preprocessed.payload case "account_challenge_error": let message = syncNonEmpty((preprocessed.payload as? [String: Any])?["message"] as? String) - ?? "That route could not verify the Mac's identity." + ?? "That route could not verify the computer's identity." throw NSError( domain: "ADE.AdoptChannel", code: 6, @@ -6148,7 +6148,7 @@ final class SyncService: ObservableObject { "supportedAeads": AdoptChannelCrypto.supportedAeads.map(\.rawValue), ], timeoutNanoseconds: AdoptChannelCrypto.challengeTimeoutNanoseconds, - timeoutMessage: "That Mac did not answer the secure identity challenge.", + timeoutMessage: "That computer did not answer the secure identity challenge.", relayAccountOwnerId: nil ) guard isCurrentCandidate() else { @@ -6311,7 +6311,7 @@ final class SyncService: ObservableObject { "auth": auth, ], timeoutNanoseconds: SyncConnectionRaceTiming.overallBudgetNanoseconds, - timeoutMessage: "That Mac did not finish account connection. Try again.", + timeoutMessage: "That computer did not finish account connection. Try again.", relayAccountOwnerId: owner ) guard isCurrentCandidate() else { @@ -6703,7 +6703,7 @@ final class SyncService: ObservableObject { // learning even though we are not redialling. if var existing = activeHostProfile { guard existing.accountOwnerId == nil || existing.accountOwnerId == owner else { - lastError = "This saved Mac belongs to a different signed-in account." + lastError = "This saved computer belongs to a different signed-in account." connectionState = .error ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) return false @@ -6750,7 +6750,7 @@ final class SyncService: ObservableObject { && tokenForProfile(profile) != nil }) { guard existing.accountOwnerId == nil || existing.accountOwnerId == owner else { - lastError = "This saved Mac belongs to a different signed-in account." + lastError = "This saved computer belongs to a different signed-in account." connectionState = .error ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) return false @@ -6804,9 +6804,9 @@ final class SyncService: ObservableObject { ) guard !routes.isEmpty else { if signingPublicKey == nil { - lastError = "That Mac is not ready for account connection yet. Open ADE on the Mac and try again." + lastError = "That computer is not ready for account connection yet. Open ADE on the computer and try again." } else { - lastError = "That Mac did not advertise a secure account connection route. Open ADE on the Mac and try again." + lastError = "That computer did not advertise a secure account connection route. Open ADE on the computer and try again." } connectionState = .error ProductAnalytics.shared.captureMachineAdoptionOutcome(.failed) @@ -6924,7 +6924,7 @@ final class SyncService: ObservableObject { throw NSError( domain: "ADE", code: 33, - userInfo: [NSLocalizedDescriptionKey: "This Mac would not hand back a connection for this iPhone. Open ADE on the Mac, remove this iPhone under Settings → Devices, then connect again."] + userInfo: [NSLocalizedDescriptionKey: "This computer would not hand back a connection for this iPhone. Open ADE on the computer, remove this iPhone under Settings → Devices, then connect again."] ) } @@ -7092,7 +7092,7 @@ final class SyncService: ObservableObject { // A blocked navigation used to abort with nothing on screen: the tap // simply did not work. Record it the same way a failed connect does so // the reason is available to whatever surface the user is looking at. - let message = "That Mac is not available in your ADE account." + let message = "That computer is not available in your ADE account." lastError = message lastConnectAttemptFailure = SyncConnectAttemptFailure(message: message) return false @@ -7110,7 +7110,7 @@ final class SyncService: ObservableObject { return true } guard let authorization = AccountService.shared.currentPairingAuthorization else { - let message = "Sign in again to open work from that Mac." + let message = "Sign in again to open work from that computer." lastError = message lastConnectAttemptFailure = SyncConnectAttemptFailure(message: message) return false @@ -7946,8 +7946,8 @@ final class SyncService: ObservableObject { } // Persist BEFORE the hello. The host may commit this secret while the // hello_ok that reports it is still in flight, and a drop right there - // used to leave the phone holding a secret the Mac had already retired -- - // recoverable only by typing another PIN at the Mac. Saving first cannot + // used to leave the phone holding a secret the computer had already retired -- + // recoverable only by typing another PIN at the computer. Saving first cannot // strand the phone the other way: a host that never commits keeps // accepting the previous secret, and this device reconnects within // seconds, far inside that window. @@ -7982,7 +7982,7 @@ final class SyncService: ObservableObject { throw NSError( domain: "ADE", code: 36, - userInfo: [NSLocalizedDescriptionKey: "The Mac did not finish saving this pairing. Try again."] + userInfo: [NSLocalizedDescriptionKey: "The computer did not finish saving this pairing. Try again."] ) } } @@ -8902,7 +8902,7 @@ final class SyncService: ObservableObject { private func sessionLifecycleNotAppliedError(_ action: String) -> NSError { NSError(domain: "ADE", code: 28, userInfo: [ NSLocalizedDescriptionKey: - "This Mac didn’t apply that change — the session may have already changed there.", + "This computer didn’t apply that change — the session may have already changed there.", "adeAction": action, ]) } @@ -10121,7 +10121,7 @@ final class SyncService: ObservableObject { self.terminalInputQueues[sessionId] = queue self.terminalInputTimeoutTasks[sessionId] = nil self.terminalStreamHandlers[sessionId]?(.inputFailure( - message: "The Mac did not confirm whether that terminal input was applied. It was not retried again." + message: "The computer did not confirm whether that terminal input was applied. It was not retried again." )) self.flushTerminalInputQueue(sessionId: sessionId) } @@ -16593,7 +16593,7 @@ final class SyncService: ObservableObject { case "account_challenge_error": let challengeError = payload as? [String: Any] let message = syncNonEmpty(challengeError?["message"] as? String) - ?? "That route could not verify the Mac's identity." + ?? "That route could not verify the computer's identity." resolve(requestId: requestId, result: .failure(NSError( domain: "ADE.AdoptChannel", code: 6, @@ -18015,7 +18015,7 @@ final class SyncService: ObservableObject { throw NSError(domain: "ADE", code: 26, userInfo: [NSLocalizedDescriptionKey: "This action needs the lane's project scope. Refresh lanes and try again."]) } guard canSendLiveRequests() else { - throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "Can’t reach this Mac right now."]) + throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "Can’t reach this computer right now."]) } let requestId = commandId ?? makeRequestId() let effectiveTimeoutNanoseconds = timeoutNanoseconds ?? SyncRequestTimeout.commandTimeoutNanoseconds(for: action) @@ -18856,7 +18856,7 @@ final class SyncService: ObservableObject { targetProjectId: String? = nil ) async throws -> Any { guard canSendLiveRequests() else { - throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "Can’t reach this Mac right now."]) + throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "Can’t reach this computer right now."]) } let requestId = makeRequestId() let raw = try await awaitResponse(requestId: requestId) { @@ -19017,11 +19017,11 @@ extension SyncService { } if connectionState.isHostUnreachable || nsError.domain == NSURLErrorDomain { - return "Reconnect to your Mac and try again." + return "Reconnect to your computer and try again." } if nsError.domain == "ADE", nsError.code == 15 { - return "Reconnect to your Mac and try again." + return "Reconnect to your computer and try again." } switch kind { diff --git a/apps/ios/ADE/Services/SyncTerminalInputQueue.swift b/apps/ios/ADE/Services/SyncTerminalInputQueue.swift index 64b1f1123..d8d680301 100644 --- a/apps/ios/ADE/Services/SyncTerminalInputQueue.swift +++ b/apps/ios/ADE/Services/SyncTerminalInputQueue.swift @@ -9,7 +9,7 @@ enum SyncTerminalInputQueueError: Error, Equatable, LocalizedError { case .chunkTooLarge(let maximumBytes): return "That terminal input is too large to send (maximum \(maximumBytes) bytes)." case .overflow(let maximumItems, let maximumBytes): - return "Terminal input is paused because \(maximumItems) queued chunks or \(maximumBytes) bytes are waiting for the Mac." + return "Terminal input is paused because \(maximumItems) queued chunks or \(maximumBytes) bytes are waiting for the computer." } } } diff --git a/apps/ios/ADE/Views/Account/AccountConnectionsSection.swift b/apps/ios/ADE/Views/Account/AccountConnectionsSection.swift index 583d479f3..68f353acf 100644 --- a/apps/ios/ADE/Views/Account/AccountConnectionsSection.swift +++ b/apps/ios/ADE/Views/Account/AccountConnectionsSection.swift @@ -75,7 +75,7 @@ struct AccountSignInPromptCard: View { Text("Continue to ADE") .font(.headline) .foregroundStyle(ADEColor.textPrimary) - Text("Connect to a Mac on another network. Use the same ADE account on your iPhone and Mac.") + Text("Connect to a computer on another network. Use the same ADE account on your iPhone and computer.") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -217,7 +217,7 @@ struct AccountMachinesList: View { if account.machines.isEmpty { AccountMachinesNote( icon: "desktopcomputer", - text: "No Macs are signed in to this account yet. Open ADE on your Mac and sign in there too." + text: "No computers are signed in to this account yet. Open ADE on your computer and sign in there too." ) } else { machineRows diff --git a/apps/ios/ADE/Views/Account/MobileAccessGateView.swift b/apps/ios/ADE/Views/Account/MobileAccessGateView.swift index 427ba1a3e..24bd4a81f 100644 --- a/apps/ios/ADE/Views/Account/MobileAccessGateView.swift +++ b/apps/ios/ADE/Views/Account/MobileAccessGateView.swift @@ -54,7 +54,7 @@ struct MobileAccessGateView: View { Text("Checking account…") } } else { - Text(accountSignedIn ? "View your Macs" : "Sign in") + Text(accountSignedIn ? "View your computers" : "Sign in") } } .font(.headline) @@ -179,7 +179,7 @@ struct MobileAccessGateView: View { Task { @MainActor in accountConnectionError = nil guard let authorization = AccountService.shared.currentPairingAuthorization else { - accountConnectionError = "Your account session ended. Sign in again, then choose your Mac." + accountConnectionError = "Your account session ended. Sign in again, then choose your computer." return } let connected = await syncService.pairWithAccountMachine( @@ -190,7 +190,7 @@ struct MobileAccessGateView: View { ADEHaptics.medium() onContinue() } else { - accountConnectionError = syncService.lastError ?? "ADE could not connect to that Mac. Try again." + accountConnectionError = syncService.lastError ?? "ADE could not connect to that computer. Try again." } } } diff --git a/apps/ios/ADE/Views/Components/MachineRowView.swift b/apps/ios/ADE/Views/Components/MachineRowView.swift index 75b533022..d8740c35e 100644 --- a/apps/ios/ADE/Views/Components/MachineRowView.swift +++ b/apps/ios/ADE/Views/Components/MachineRowView.swift @@ -193,7 +193,7 @@ func machineDeviceSymbol(deviceType: String?, platform: String?) -> String { } /// The unified status hint for a saved machine. Directory presence is only a -/// routing hint; absence never claims the Mac is powered off. Callers with a +/// routing hint; absence never claims the computer is powered off. Callers with a /// richer route label show that instead and fall back to this. func machineReachabilityText( isConnected: Bool, diff --git a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift index af13c7b17..369b9f3df 100644 --- a/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift +++ b/apps/ios/ADE/Views/Deeplinks/SendToMacCard.swift @@ -29,7 +29,7 @@ struct SendToMacTarget: Equatable, Identifiable { /// Best-effort parse of ADE's custom scheme and HTTPS mirror. Unknown /// shapes fall back to `.other` so the card can still render a generic - /// "Open this on your Mac" message rather than refusing to display. + /// "Open this on your computer" message rather than refusing to display. init(url: URL) { self.url = url self.envelope = SendToMacTarget.parseEnvelope(url) @@ -181,7 +181,7 @@ struct SendToMacTarget: Equatable, Identifiable { case .repoBranch(_, _, _): return "Branch shared with you" case .pr: return "Pull request shared with you" case .linearIssue: return "Linear issue shared with you" - case .other: return "Shared from your Mac" + case .other: return "Shared from your computer" } } @@ -295,12 +295,12 @@ struct SendToMacCard: View { .foregroundStyle(ADEColor.accent) .padding(.bottom, 4) - Text("Open on your Mac") + Text("Open on your computer") .font(.system(.title3, design: .rounded).weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .multilineTextAlignment(.center) - Text("This link works best on the desktop app. Send it to your paired Mac and it'll open there.") + Text("This link works best on the desktop app. Send it to your paired computer and it'll open there.") .font(.system(.footnote, design: .rounded)) .foregroundStyle(ADEColor.textSecondary) .multilineTextAlignment(.center) @@ -378,7 +378,7 @@ struct SendToMacCard: View { .background(ADEColor.recessedBackground, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } - /// Display name for the paired Mac. Prefers the live `hostName` published + /// Display name for the paired computer. Prefers the live `hostName` published /// by `SyncService`, falls back to a placeholder when no machine is /// attached so the card still reads correctly. The user can still try to /// send; the queueing path inside `SyncService` will surface the offline @@ -391,7 +391,7 @@ struct SendToMacCard: View { // TODO: thread the paired-device record through here once SyncService // exposes a richer "last paired" identity; today `hostName` is the only // stable display string we have. - return "Your Mac" + return "Your computer" } private var machineSecondaryLabel: String? { @@ -526,18 +526,18 @@ struct SendToMacCard: View { let branch = target.envelope?.branch, !repo.isEmpty, !branch.isEmpty { - return "Send to Mac to create a lane from \(branch)" + return "Send to computer to create a lane from \(branch)" } - return "Send to Mac" + return "Send to computer" } private var sendStatusMessage: String? { guard let sendOutcome else { return nil } switch sendOutcome { case .dispatched: - return "Sent to your Mac." + return "Sent to your computer." case .queued: - return "Queued for when your Mac reconnects." + return "Queued for when your computer reconnects." case .dropped(let message): return message.isEmpty ? "This command could not be sent." : message } diff --git a/apps/ios/ADE/Views/Hub/HubQuickConnect.swift b/apps/ios/ADE/Views/Hub/HubQuickConnect.swift index 4486bca07..b6730e4b5 100644 --- a/apps/ios/ADE/Views/Hub/HubQuickConnect.swift +++ b/apps/ios/ADE/Views/Hub/HubQuickConnect.swift @@ -15,7 +15,7 @@ func hubSavedMachineIsRecentlyReachable( /// One-tap connect cards shown on the no-machine home for account and saved /// machines. Directory/discovery presence is only a hint; saved secure records -/// remain attemptable without claiming the Mac is currently reachable. +/// remain attemptable without claiming the computer is currently reachable. struct HubQuickConnectSection: View { @EnvironmentObject private var syncService: SyncService @ObservedObject private var account = AccountService.shared @@ -49,7 +49,7 @@ struct HubQuickConnectSection: View { /// Account and saved records can describe the same Mac. Prefer the account /// card when both stable IDs match, while retaining a live saved card when - /// the account directory currently considers that Mac offline. + /// the account directory currently considers that computer offline. private var targets: [Target] { let accountTargets = accountMachines.map(Target.account) let accountIdentities = Set(accountMachines.compactMap { normalizedIdentity($0.deviceId) }) @@ -195,7 +195,7 @@ struct HubQuickConnectSection: View { errorText = nil Task { @MainActor in guard let authorization = AccountService.shared.currentPairingAuthorization else { - errorText = "Your account session ended. Sign in again, then choose your Mac." + errorText = "Your account session ended. Sign in again, then choose your computer." connectingId = nil return } @@ -209,7 +209,7 @@ struct HubQuickConnectSection: View { onConnectSuccess() } else { ADEHaptics.error() - errorText = syncService.lastError ?? "ADE could not connect to that Mac. Try again." + errorText = syncService.lastError ?? "ADE could not connect to that computer. Try again." } } } diff --git a/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift b/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift index cc5bf3553..dbd8bf6e6 100644 --- a/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift +++ b/apps/ios/ADE/Views/Linear/LinearConnectionScreen.swift @@ -160,7 +160,7 @@ struct LinearConnectionScreen: View { } if !supportsReconnect && !supportsDisconnect { - Text("Update ADE on your Mac to manage Linear connections from your phone.") + Text("Update ADE on your computer to manage Linear connections from your phone.") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) @@ -325,7 +325,7 @@ struct LinearConnectActions: View { } if !supportsOAuth && !supportsApiKey { - Text("Update ADE on your Mac to manage Linear connections from your phone.") + Text("Update ADE on your computer to manage Linear connections from your phone.") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift b/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift index 42a2fc1e9..87d059031 100644 --- a/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift +++ b/apps/ios/ADE/Views/Linear/LinearPaneSheet.swift @@ -54,7 +54,7 @@ struct LinearPaneSheet: View { } /// Machine name for connect copy — the connected host's display name when - /// known, else a neutral "your Mac". + /// known, else a neutral "your computer". private var machineName: String { syncService.machineDisplayName } private var connectPrompt: some View { diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index 524d38236..500199324 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -591,7 +591,7 @@ struct PrDetailView: View { title: "Pull request unavailable", message: isLive ? "ADE could not find \(unavailablePrLabel). Refresh the PR list and try again." - : "Reconnect to your Mac to load \(unavailablePrLabel).", + : "Reconnect to your computer to load \(unavailablePrLabel).", icon: "arrow.triangle.merge", tint: ADEColor.warning, actionTitle: "Retry", diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 3d8c871e1..7de2ca9c6 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -31,7 +31,7 @@ struct ConnectionSettingsView: View { // Pairing-only entry point (from the no-account gate): connection // status + the pair actions, nothing else. VStack(alignment: .leading, spacing: 12) { - SettingsSectionHeader(label: "MAC", hint: "Your Mac connection") + SettingsSectionHeader(label: "CONNECTION", hint: "Your computer connection") SettingsConnectionHeader( snapshot: presentationModel.connectionSnapshot, @@ -167,7 +167,7 @@ struct ConnectionSettingsView: View { } .background(SettingsAuroraBackground().ignoresSafeArea()) .adeNavigationGlass() - .navigationTitle(pairingOnly ? "Connect a Mac" : "Settings") + .navigationTitle(pairingOnly ? "Connect a computer" : "Settings") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -854,7 +854,7 @@ func settingsMachineRowErrorsRetiring( return remaining } -/// The CONNECTIONS machine list: a unified, deduplicated roster of the Macs a +/// The CONNECTIONS machine list: a unified, deduplicated roster of the computers a /// phone can reach — machines on the signed-in account plus previously-paired /// machines — ranked current → online → offline. Shows the top three inline /// with a "See all machines" sheet for the rest. Offline machines render grayed @@ -1165,7 +1165,7 @@ struct SettingsMachinesSection: View { case .account(let machine): guard let authorization = AccountService.shared.currentPairingAuthorization else { connectingId = nil - rowErrors[entry.id] = "Your account session ended. Sign in again, then choose your Mac." + rowErrors[entry.id] = "Your account session ended. Sign in again, then choose your computer." return } let connected = await syncService.pairWithAccountMachine( @@ -1180,7 +1180,7 @@ struct SettingsMachinesSection: View { rowErrors[entry.id] = settingsMachineRowErrorMessage( attemptFailure: syncService.lastConnectAttemptFailure, lastError: syncService.lastError, - fallback: "ADE could not connect to that Mac. Try again." + fallback: "ADE could not connect to that computer. Try again." ) } diff --git a/apps/ios/ADE/Views/Settings/SSHPairingView.swift b/apps/ios/ADE/Views/Settings/SSHPairingView.swift index dec42b439..5dbf3a559 100644 --- a/apps/ios/ADE/Views/Settings/SSHPairingView.swift +++ b/apps/ios/ADE/Views/Settings/SSHPairingView.swift @@ -59,7 +59,7 @@ struct SSHPairingView: View { } } message: { if case .needsHostConfirmation(let fingerprint) = model.state { - Text("Before continuing, compare this fingerprint with the one shown on your Mac:\n\n\(fingerprint)") + Text("Before continuing, compare this fingerprint with the one shown on your computer:\n\n\(fingerprint)") } } .onChange(of: model.state) { _, state in @@ -97,7 +97,7 @@ struct SSHPairingView: View { .autocorrectionDisabled() TextField("SSH port", value: $port, format: .number) .keyboardType(.numberPad) - TextField("Mac username", text: $username) + TextField("Computer username", text: $username) .textInputAutocapitalization(.never) .autocorrectionDisabled() } @@ -151,7 +151,7 @@ struct SSHPairingView: View { passphrase = "" } if let generated = model.generatedKey { - Text("Run this once on the Mac, then return here and pair:") + Text("Run this once on the computer, then return here and pair:") .font(.footnote) .foregroundStyle(.secondary) Text(generated.authorizationCommand) @@ -166,7 +166,7 @@ struct SSHPairingView: View { private var securitySection: some View { Section("Security") { - Label("ADE asks you to compare the Mac's fingerprint before trusting it.", systemImage: "checkmark.shield") + Label("ADE asks you to compare the computer's fingerprint before trusting it.", systemImage: "checkmark.shield") .font(.footnote) Label("SSH is used only for setup. ADE reconnects normally after that.", systemImage: "link.badge.plus") .font(.footnote) @@ -177,7 +177,7 @@ struct SSHPairingView: View { private var statusSection: some View { switch model.state { case .checkingHost: - Section { ProgressView("Checking the Mac…") } + Section { ProgressView("Checking the computer…") } case .pairing: Section { ProgressView("Connecting to ADE…") } case .paired(let machine, let warning): diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index 324223711..2b21c06a9 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -23,14 +23,14 @@ struct SettingsPairingSection: View { SettingsPairActionRow( icon: "qrcode.viewfinder", title: "Scan a pairing code", - subtitle: "Scan the code shown in ADE on your Mac" + subtitle: "Scan the code shown in ADE on your computer" ) { presentedSheet = .scan } SettingsPairActionRow( icon: "dot.radiowaves.left.and.right", - title: "Find a nearby Mac", + title: "Find a nearby computer", subtitle: discoverSubtitle ) { presentedSheet = .discover @@ -39,7 +39,7 @@ struct SettingsPairingSection: View { SettingsPairActionRow( icon: "terminal", title: "Set up with SSH", - subtitle: "Advanced: use SSH once to create an ADE pairing" + subtitle: "Advanced · macOS or Linux only" ) { presentedSheet = .ssh } @@ -54,7 +54,7 @@ struct SettingsPairingSection: View { .tint(ADEColor.textSecondary) Label( - awayFromMacHelp, + awayFromComputerHelp, systemImage: "network" ) .font(.footnote) @@ -63,23 +63,23 @@ struct SettingsPairingSection: View { } } - private var awayFromMacHelp: String { + private var awayFromComputerHelp: String { if accountService.identity != nil { - return "You're signed in, so your Macs stay reachable from any network." + return "You're signed in, so your computers stay reachable from any network." } - return "Sign in to reach your Macs from any network." + return "Sign in to reach your computers from any network." } private var discoverSubtitle: String? { let count = snapshot.discoveredHostCount let savedCount = snapshot.savedReconnectHostCount if count == 0, savedCount > 0 { - return savedCount == 1 ? "1 saved Mac" : "\(savedCount) saved Macs" + return savedCount == 1 ? "1 saved computer" : "\(savedCount) saved computers" } if count == 0 { - return "Choose your Mac, then enter its ADE PIN" + return "Choose your computer, then enter its ADE PIN" } - return count == 1 ? "1 nearby Mac found · enter its ADE PIN" : "\(count) nearby Macs found" + return count == 1 ? "1 nearby computer found · enter its ADE PIN" : "\(count) nearby computers found" } } @@ -470,7 +470,7 @@ struct DiscoverHostsSheet: View { VStack(spacing: 14) { ADESkeletonView(height: 56, cornerRadius: 14) ADESkeletonView(height: 56, cornerRadius: 14) - Text("Looking for Macs running ADE nearby…") + Text("Looking for computers running ADE nearby…") .font(.caption) .foregroundStyle(ADEColor.textSecondary) .padding(.top, 4) @@ -508,7 +508,7 @@ struct DiscoverHostsSheet: View { } .adeScreenBackground() .adeNavigationGlass() - .navigationTitle("Nearby Macs") + .navigationTitle("Nearby computers") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift b/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift index e034a4535..bc5117320 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPinSheet.swift @@ -59,7 +59,7 @@ struct SettingsPinSheet: View { if noPairingCode { noPairingCodeCard - // Escape hatch: a PIN may have been set on that Mac *after* its QR was + // Escape hatch: a PIN may have been set on that computer *after* its QR was // scanned (or after discovery reported no code). Let the user flip back // to the keypad and try a code anyway instead of dead-ending here. Button { @@ -73,7 +73,7 @@ struct SettingsPinSheet: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityHint("Shows the keypad in case a pairing code was set after this Mac was scanned.") + .accessibilityHint("Shows the keypad in case a pairing code was set after this computer was scanned.") } else { pinEntry } @@ -101,7 +101,7 @@ struct SettingsPinSheet: View { .onAppear { // Proactive: if the machine already told us (via discovery or the // scanned payload) that it has no pairing code, skip the keypad and - // show the "set one on that Mac" message — no point asking for a code + // show the "set one on that computer" message — no point asking for a code // that can't exist. if presetSaysNoPairingCode { noPairingCode = true } } @@ -125,7 +125,7 @@ struct SettingsPinSheet: View { .accessibilityLabel("Pairing PIN") .accessibilityValue(pin.isEmpty ? "No digits entered" : "\(pin.count) of 6 digits entered") - Text("You haven't connected to this Mac before. Enter the pairing code shown in ADE on that Mac.") + Text("You haven't connected to this computer before. Enter the pairing code shown in ADE on that computer.") .font(.footnote) .foregroundStyle(ADEColor.textSecondary) @@ -146,7 +146,7 @@ struct SettingsPinSheet: View { .foregroundStyle(ADEColor.warning) .frame(width: 30, height: 30) .background(ADEColor.warning.opacity(0.14), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - Text("That Mac has no pairing code set — set one in ADE on that Mac.") + Text("That computer has no pairing code set — set one in ADE on that computer.") .font(.subheadline) .foregroundStyle(ADEColor.textPrimary) .fixedSize(horizontal: false, vertical: true) @@ -286,7 +286,7 @@ struct SettingsPinSheet: View { } else if syncService.lastPairingFailure == .pinNotSet || syncService.lastPairingErrorCode == SyncService.pairingPinNotSetCode { // The host has no pairing code — swap the keypad for the friendly - // "set one on that Mac" message (M10) rather than a dead-end red error. + // "set one on that computer" message (M10) rather than a dead-end red error. ADEHaptics.warning() isSubmitting = false pin = "" @@ -295,7 +295,7 @@ struct SettingsPinSheet: View { // Wrong code: shake the boxes and say where the real one lives. ADEHaptics.error() isSubmitting = false - localError = "That code didn't match — it's shown in ADE on that Mac." + localError = "That code didn't match — it's shown in ADE on that computer." pin = "" withAnimation(.default) { shakeTrigger += 1 } } else { diff --git a/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift b/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift index 0a7eccabc..26031926a 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPushDeliverySection.swift @@ -117,7 +117,7 @@ struct SettingsPushDeliverySection: View { if !snapshot.canEnableNotifications { VStack(alignment: .leading, spacing: 6) { enableNotificationsButton(label: "Enable notifications", enabled: false, action: {}) - Text("Sign in or pair a Mac to enable notifications") + Text("Sign in or pair a computer to enable notifications") .font(.caption) .foregroundStyle(ADEColor.textMuted) .padding(.horizontal, 4) @@ -437,7 +437,7 @@ struct SettingsPushDeliverySection: View { private var refreshButtonLabel: String { if pushService.isRefreshingStatus { return "Checking relay…" } - return snapshot.canRefreshRelayStatus ? "Refresh status" : "Connect a Mac to refresh" + return snapshot.canRefreshRelayStatus ? "Refresh status" : "Connect a computer to refresh" } private var inlineStatusMessage: String? { diff --git a/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift b/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift index a359850e3..6a6bd4b8c 100644 --- a/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift +++ b/apps/ios/ADE/Views/Settings/SettingsSupportTypes.swift @@ -91,7 +91,7 @@ enum SettingsConnectionPresentation { case .connecting: return "Reconnecting" case .unreachable: - return "Can't reach this Mac" + return "Can't reach this computer" case .disconnected: return "Not connected" } diff --git a/apps/ios/ADEClip/ClipPairingClient.swift b/apps/ios/ADEClip/ClipPairingClient.swift index d79ba0b0e..60f7afbc7 100644 --- a/apps/ios/ADEClip/ClipPairingClient.swift +++ b/apps/ios/ADEClip/ClipPairingClient.swift @@ -21,9 +21,9 @@ enum ClipPairingError: LocalizedError, Equatable { case .unreachable: return "Couldn't reach the machine. Make sure your iPhone is on the same network." case .pinNotSet: - return "No pairing PIN is set on the computer. Open ADE on your Mac and set one first." + return "No pairing PIN is set on the computer. Open ADE on your computer and set one first." case .invalidPin: - return "That PIN doesn't match. Check the code shown on your Mac." + return "That PIN doesn't match. Check the code shown on your computer." case .failed(let message): return message } diff --git a/apps/ios/ADEClip/ClipPairingView.swift b/apps/ios/ADEClip/ClipPairingView.swift index 33f3d6038..c7bcfcb13 100644 --- a/apps/ios/ADEClip/ClipPairingView.swift +++ b/apps/ios/ADEClip/ClipPairingView.swift @@ -22,7 +22,7 @@ final class ClipPairingModel: ObservableObject { private let client = ClipPairingClient() var hostName: String { - payload?.hostIdentity.name ?? "your Mac" + payload?.hostIdentity.name ?? "your computer" } func handleInvocation(url: URL) { @@ -127,7 +127,7 @@ struct ClipPairingView: View { ProgressView() .padding(.top, 4) case .invalidInvocation: - Text("Open ADE on your Mac and scan the pairing code it shows in Settings.") + Text("Open ADE on your computer and scan the pairing code it shows in Settings.") .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -211,11 +211,11 @@ struct ClipPairingView: View { private var subtitle: String { switch model.phase { case .enterPin, .pairing: - return "Enter the PIN shown in ADE on your Mac." + return "Enter the PIN shown in ADE on your computer." case .paired: return "Your iPhone is trusted. ADE picks this pairing up automatically." default: - return "ADE pairs your iPhone with your Mac to control agents from anywhere." + return "ADE pairs your iPhone with your computer to control agents from anywhere." } } } diff --git a/apps/ios/ADEClip/Info.plist b/apps/ios/ADEClip/Info.plist index 106e4ff08..03c1101b3 100644 --- a/apps/ios/ADEClip/Info.plist +++ b/apps/ios/ADEClip/Info.plist @@ -67,7 +67,7 @@ NSLocalNetworkUsageDescription - ADE connects to your Mac on the local network to pair this device. + ADE connects to your computer on the local network to pair this device. NSBonjourServices _ade-sync._tcp diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 79f407145..79f47b52c 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -407,7 +407,7 @@ final class ADETests: XCTestCase { ] XCTAssertTrue(syncProtocolMismatchMessage( versions.merging(["updateTarget": "host"]) { _, right in right } - ).contains("Update ADE on your Mac")) + ).contains("Update ADE on your computer")) XCTAssertTrue(syncProtocolMismatchMessage( versions.merging(["updateTarget": "client"]) { _, right in right } ).contains("Update ADE on this iPhone")) @@ -1877,7 +1877,7 @@ final class ADETests: XCTestCase { load: .normal, lastFailureMessage: "timeout" )), - "Can't reach this Mac" + "Can't reach this computer" ) XCTAssertEqual( SettingsConnectionPresentation.statusLabel(for: SyncConnectionHealth( @@ -3908,7 +3908,7 @@ final class ADETests: XCTestCase { } // A host naming a cipher this build does not implement is a version gap, not - // evidence the Mac is an impostor: it must cost that route, not the attempt. + // evidence the computer is an impostor: it must cost that route, not the attempt. func testUnsupportedAdoptionCipherFailsOneRouteRatherThanTheWholeAttempt() { let compatibility = AccountAdoptionRouteCompatibilityError(machineName: "Arul's Mac") XCTAssertFalse(syncAccountAdoptionFailureIsFatal(compatibility)) @@ -4166,7 +4166,7 @@ final class ADETests: XCTestCase { code: 2, userInfo: [NSLocalizedDescriptionKey: "The host is offline."] ) - XCTAssertEqual(SyncUserFacingError.message(for: offlineError), "Can’t reach this Mac right now.") + XCTAssertEqual(SyncUserFacingError.message(for: offlineError), "Can’t reach this computer right now.") let authError = NSError( domain: "ADE", @@ -23895,8 +23895,8 @@ final class TerminalLiveTailPinningTests: XCTestCase { final class TerminalSessionInputStatusTests: XCTestCase { func testSuccessfulInputAcceptanceClearsStaleFailureWithoutMaskingRejection() { let controller = TerminalSessionController() - controller.handleStreamEventForTesting(.inputFailure(message: "The Mac did not confirm input.")) - XCTAssertEqual(controller.inputStatusMessage, "The Mac did not confirm input.") + controller.handleStreamEventForTesting(.inputFailure(message: "The computer did not confirm input.")) + XCTAssertEqual(controller.inputStatusMessage, "The computer did not confirm input.") controller.handleInputSubmissionForTesting(.queuedUntilReady(inputId: "stable-input-id")) XCTAssertNil(controller.inputStatusMessage) @@ -23907,7 +23907,7 @@ final class TerminalSessionInputStatusTests: XCTestCase { func testStreamRehydrationClearsStaleInputFailure() { let controller = TerminalSessionController() - controller.handleStreamEventForTesting(.inputFailure(message: "The Mac did not confirm input.")) + controller.handleStreamEventForTesting(.inputFailure(message: "The computer did not confirm input.")) controller.handleStreamEventForTesting(.hydrate( text: "Mac% ", diff --git a/apps/ios/ADETests/PairingAndDpopTests.swift b/apps/ios/ADETests/PairingAndDpopTests.swift index 45e9709f5..060d45f50 100644 --- a/apps/ios/ADETests/PairingAndDpopTests.swift +++ b/apps/ios/ADETests/PairingAndDpopTests.swift @@ -1275,6 +1275,18 @@ final class PairingAndDpopTests: XCTestCase { XCTAssertNil(payload.relayUrl) } + func testParsesWindowsDesktopHostIdentity() throws { + let json = #"{"version":3,"hostIdentity":{"deviceId":"windows-host","name":"Workstation","platform":"windows","deviceType":"desktop"},"port":8787,"addressCandidates":[]}"# + let payload = try XCTUnwrap(PairingQrPayload.parse(json)) + + XCTAssertEqual(payload.hostIdentity.platform, "windows") + XCTAssertEqual(payload.hostIdentity.deviceType, "desktop") + XCTAssertEqual( + machineDeviceSymbol(deviceType: payload.hostIdentity.deviceType, platform: payload.hostIdentity.platform), + "desktopcomputer" + ) + } + func testDropsNonWssRelayUrl() throws { let json = #"{"version":3,"hostIdentity":{"deviceId":"d1","name":"Box"},"port":8787,"addressCandidates":[],"relayUrl":"ws://relay.ade-app.dev/x"}"# let payload = try XCTUnwrap(PairingQrPayload.parse(json)) diff --git a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift index 719394358..9399b7ec5 100644 --- a/apps/ios/ADETests/SyncRecoveryPolicyTests.swift +++ b/apps/ios/ADETests/SyncRecoveryPolicyTests.swift @@ -393,7 +393,7 @@ final class SyncRecoveryPolicyTests: XCTestCase { } func testApplicationCloseCodeTableKeepsPrimaryCopyRouteNeutral() { - let interrupted = "Can’t reach this Mac right now. Reconnecting now." + let interrupted = "Can’t reach this computer right now. Reconnecting now." let cases: [(code: Int, reason: String, expected: String)] = [ (4000, "partner closed", interrupted), (4001, "heartbeat timed out", interrupted), @@ -403,7 +403,7 @@ final class SyncRecoveryPolicyTests: XCTestCase { (4008, "inbound connection stale", interrupted), (4501, "host offline", interrupted), (4502, "relay idle", interrupted), - (4503, "relay capacity", "This Mac is handling too many connections. Try again shortly."), + (4503, "relay capacity", "This computer is handling too many connections. Try again shortly."), (4505, "replaced by newer host", interrupted), (4506, "pre-pipe buffer overflow", interrupted), (4507, "bridge rejected", interrupted), @@ -1734,7 +1734,7 @@ final class SyncRecoveryPolicyTests: XCTestCase { ]) service.setActiveProjectForTesting(projectId: "project-a", rootPath: "/tmp/a") - // This iPhone's clock runs two minutes AHEAD of the Mac it is paired to. + // This iPhone's clock runs two minutes AHEAD of the computer it is paired to. let phoneNow = Date(timeIntervalSince1970: 1_780_000_000) let hostNow = phoneNow.addingTimeInterval(-120) // The machine's own baseline from an earlier snooze of the same row. diff --git a/apps/ios/ADEWidgets/ADELockScreenWidget.swift b/apps/ios/ADEWidgets/ADELockScreenWidget.swift index b25bf850e..70c88c435 100644 --- a/apps/ios/ADEWidgets/ADELockScreenWidget.swift +++ b/apps/ios/ADEWidgets/ADELockScreenWidget.swift @@ -176,7 +176,11 @@ private struct LockScreenPriorityStatus { let metrics = [ needsYou.isEmpty ? nil : Metric(id: "needs", label: "\(needsYou.count) need", symbol: "bell.fill"), live.isEmpty ? nil : Metric(id: "live", label: "\(live.count) live", symbol: "waveform.path.ecg"), - machines.isEmpty ? nil : Metric(id: "machines", label: "\(machines.count) Mac", symbol: "desktopcomputer"), + machines.isEmpty ? nil : Metric( + id: "machines", + label: machines.count == 1 ? "1 computer" : "\(machines.count) computers", + symbol: "desktopcomputer" + ), ].compactMap { $0 } guard let focus = ordered.first else { @@ -199,8 +203,8 @@ private struct LockScreenPriorityStatus { self = .init( kind: .offline, title: hideDetails - ? "Mac offline" - : (machines.count == 1 ? "\(focus.machine.name) offline" : "\(machines.count) Macs offline"), + ? "Computer offline" + : (machines.count == 1 ? "\(focus.machine.name) offline" : "\(machines.count) computers offline"), detail: hideDetails ? "Open ADE for details" : "Last known work · \(focus.project.name)", inlineText: "ADE · offline", count: 0, @@ -280,8 +284,8 @@ private struct LockScreenPriorityStatus { self = .init( kind: .offline, title: hideDetails - ? "Mac offline" - : "\((machine?.isEmpty == false ? machine : nil) ?? "Mac") offline", + ? "Computer offline" + : "\((machine?.isEmpty == false ? machine : nil) ?? "Computer") offline", detail: minutes == 1 ? "Last update 1 minute ago" : "Last update \(minutes) minutes ago", inlineText: "ADE · offline \(minutes)m", count: 0, @@ -408,7 +412,7 @@ private struct LockScreenPriorityStatus { } else if snapshot.connection.lowercased() == "disconnected" { self = .init( kind: .offline, - title: "Mac offline", + title: "Computer offline", detail: "Reconnect to update agents and PRs", inlineText: "ADE · offline", count: 0, diff --git a/apps/web/public/pair/index.html b/apps/web/public/pair/index.html index 29d07e27f..56f9f6b8e 100644 --- a/apps/web/public/pair/index.html +++ b/apps/web/public/pair/index.html @@ -58,11 +58,11 @@

Pair your iPhone with ADE

-

Scan the pairing code shown in ADE on your Mac with your iPhone camera to pair instantly.

+

Scan the pairing code shown in ADE on your computer with your iPhone camera to pair instantly.

  1. Open the Camera app on your iPhone
  2. -
  3. Scan the pairing QR shown in ADE on your Mac
  4. -
  5. Enter the PIN your Mac displays
  6. +
  7. Scan the pairing QR shown in ADE on your computer
  8. +
  9. Enter the PIN your computer displays

Have the ADE app already? Scanning the same code inside the app's Settings works too.

diff --git a/apps/web/src/app/pages/PairPage.tsx b/apps/web/src/app/pages/PairPage.tsx index 8ee6cdb65..a73536d58 100644 --- a/apps/web/src/app/pages/PairPage.tsx +++ b/apps/web/src/app/pages/PairPage.tsx @@ -46,7 +46,7 @@ export function PairPage() {

- Install ADE, then scan the pairing code on your Mac again to connect. + Install ADE, then scan the pairing code on your computer again to connect.

From 5bcd2c3a237e71773a8b874ebf5dd2c5f0511286 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 19:31:02 -0400 Subject: [PATCH 02/51] refactor(windows): isolate desktop IPC from packaging metadata Keep PR 3 independently buildable and defer release-repository IPC wiring to PR 4. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- apps/desktop/src/main/services/ipc/registerIpc.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 834fed1ee..8b9cf6d9a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -8,7 +8,6 @@ import { DEFAULT_AUTO_UPDATE_PREFERENCES } from "../../../shared/types"; import { buildGithubReleaseUrl, compareUpdateVersions, - DEFAULT_RELEASE_REPOSITORY, } from "../updates/autoUpdateVersions"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -1591,7 +1590,6 @@ export function registerIpc({ closeCurrentProject, closeProjectByPath, globalStatePath, - releaseRepository = DEFAULT_RELEASE_REPOSITORY, builtInBrowserService, productAnalyticsService, publishAttentionNotchSnapshot, @@ -1620,7 +1618,6 @@ export function registerIpc({ closeCurrentProject: () => Promise; closeProjectByPath: (projectRoot: string) => Promise; globalStatePath: string; - releaseRepository?: string; builtInBrowserService?: ReturnType | null; productAnalyticsService?: ProductAnalyticsService; publishAttentionNotchSnapshot?: (snapshot: AttentionSnapshot) => void; @@ -3989,7 +3986,7 @@ export function registerIpc({ if (!version) return null; return { version, - htmlUrl: buildGithubReleaseUrl(version, releaseRepository), + htmlUrl: buildGithubReleaseUrl(version), publishedAt: null, updateAvailable: compareUpdateVersions(version, app.getVersion()) > 0, }; From fdd79e09b4f85abac5bf552a7ce27ebbf19c7e71 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 19:35:51 -0400 Subject: [PATCH 03/51] fix(windows): bind shared credentials to DPAPI Protect the shared desktop and background-brain credential key with Windows CurrentUser DPAPI, preserve legacy ciphertext reads, and add the installed-build PR 3 proof procedure. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- .../credentials/credentialStore.test.ts | 68 ++++ .../services/credentials/credentialStore.ts | 20 +- .../credentials/windowsDpapiMaterial.ts | 260 +++++++++++++++ .../syncPairedMachineStore.test.ts | 4 +- .../windows-pr3-computer-use-proof.md | 296 ++++++++++++++++++ 5 files changed, 643 insertions(+), 5 deletions(-) create mode 100644 apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts create mode 100644 docs/playbooks/windows-pr3-computer-use-proof.md diff --git a/apps/ade-cli/src/services/credentials/credentialStore.test.ts b/apps/ade-cli/src/services/credentials/credentialStore.test.ts index dbcce21da..c1369516f 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.test.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.test.ts @@ -10,6 +10,10 @@ import { KeytarCredentialStore, createDefaultCredentialStore, } from "./credentialStore"; +import { + readOrCreateWindowsDpapiMaterial, + readOrCreateWindowsDpapiMaterialAsync, +} from "./windowsDpapiMaterial"; let tempDir = ""; @@ -22,6 +26,70 @@ afterEach(() => { }); describe("EncryptedFileCredentialStore", () => { + it.runIf(process.platform === "win32")( + "binds headless credential encryption to the current Windows account with DPAPI", + async () => { + const previousNodeEnv = process.env.NODE_ENV; + const previousVitest = process.env.VITEST; + delete process.env.NODE_ENV; + delete process.env.VITEST; + try { + const syncDir = path.join(tempDir, "sync-dpapi"); + const syncMaterial = readOrCreateWindowsDpapiMaterial(syncDir); + const protectedKeyPath = path.join(syncDir, ".credential-key.dpapi"); + const protectedKey = fs.readFileSync(protectedKeyPath, "utf8"); + + expect(syncMaterial).toHaveLength(32); + expect(protectedKey).toContain("ADE_WINDOWS_DPAPI_KEY_V1"); + expect(protectedKey).not.toContain(syncMaterial.toString("base64")); + expect(readOrCreateWindowsDpapiMaterial(syncDir)).toEqual(syncMaterial); + + const store = new EncryptedFileCredentialStore({ secretsDir: syncDir }); + store.setSync("account.session.v1", "windows-account-session"); + const credentialsPath = path.join(syncDir, "credentials.json.enc"); + const machineKeyPath = path.join(syncDir, ".machine-key"); + expect(fs.readFileSync(credentialsPath, "utf8")) + .not.toContain("windows-account-session"); + + const explicitPathReader = new EncryptedFileCredentialStore({ + credentialsPath, + machineKeyPath, + }); + expect(explicitPathReader.getSync("account.session.v1")) + .toBe("windows-account-session"); + await expect(explicitPathReader.get("account.session.v1")) + .resolves.toBe("windows-account-session"); + + const customCredentialDir = path.join(tempDir, "custom-credential-dir"); + const customKeyDir = path.join(tempDir, "custom-key-dir"); + const customMachineKeyPath = path.join(customKeyDir, ".machine-key"); + const customStore = new EncryptedFileCredentialStore({ + secretsDir: customCredentialDir, + machineKeyPath: customMachineKeyPath, + }); + customStore.setSync("account.session.v1", "custom-key-location"); + expect(fs.existsSync(path.join(customKeyDir, ".credential-key.dpapi"))).toBe(true); + expect(fs.existsSync(path.join(customCredentialDir, ".credential-key.dpapi"))).toBe(false); + expect(new EncryptedFileCredentialStore({ + credentialsPath: path.join(customCredentialDir, "credentials.json.enc"), + machineKeyPath: customMachineKeyPath, + }).getSync("account.session.v1")).toBe("custom-key-location"); + + const asyncDir = path.join(tempDir, "async-dpapi"); + const asyncMaterial = await readOrCreateWindowsDpapiMaterialAsync(asyncDir); + expect(asyncMaterial).toHaveLength(32); + expect(fs.readFileSync(path.join(asyncDir, ".credential-key.dpapi"), "utf8")) + .not.toContain(asyncMaterial.toString("base64")); + } finally { + if (previousNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = previousNodeEnv; + if (previousVitest === undefined) delete process.env.VITEST; + else process.env.VITEST = previousVitest; + } + }, + 20_000, + ); + it("persists credentials encrypted on disk", async () => { const store = new EncryptedFileCredentialStore({ secretsDir: tempDir }); diff --git a/apps/ade-cli/src/services/credentials/credentialStore.ts b/apps/ade-cli/src/services/credentials/credentialStore.ts index 4616f78e7..c61b54fa8 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.ts @@ -3,6 +3,10 @@ import { execFile, execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { resolveMachineAdeLayout } from "../projects/machineLayout"; +import { + readOrCreateWindowsDpapiMaterial, + readOrCreateWindowsDpapiMaterialAsync, +} from "./windowsDpapiMaterial"; export interface CredentialStore { get(key: string): Promise; @@ -599,11 +603,14 @@ async function readMacKeychainMaterialAsync(): Promise { }); } -function readDefaultOsBoundKeyMaterial(): Buffer | null { +function readDefaultOsBoundKeyMaterial(secretsDir: string): Buffer | null { const envMaterial = readCredentialPassphraseFromEnv(); if (envMaterial) return envMaterial; if (process.env.ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING === "1") return null; if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") return null; + if (process.platform === "win32") { + return readOrCreateWindowsDpapiMaterial(secretsDir); + } if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial; const material = readOrCreateMacKeychainMaterial(); if (material) { @@ -613,11 +620,14 @@ function readDefaultOsBoundKeyMaterial(): Buffer | null { return material; } -async function readDefaultOsBoundKeyMaterialAsync(): Promise { +async function readDefaultOsBoundKeyMaterialAsync(secretsDir: string): Promise { const envMaterial = readCredentialPassphraseFromEnv(); if (envMaterial) return envMaterial; if (process.env.ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING === "1") return null; if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") return null; + if (process.platform === "win32") { + return await readOrCreateWindowsDpapiMaterialAsync(secretsDir); + } if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial; if ( lastMissingDefaultOsBoundKeyMaterialAt > 0 @@ -675,12 +685,14 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { const secretsDir = args.secretsDir ?? resolveMachineAdeLayout().secretsDir; this.credentialsPath = args.credentialsPath ?? path.join(secretsDir, DEFAULT_CREDENTIALS_FILE); this.machineKeyPath = args.machineKeyPath ?? path.join(secretsDir, DEFAULT_MACHINE_KEY_FILE); + const osBindingDir = path.dirname(this.machineKeyPath); this.lockPath = args.lockPath ?? defaultLockPath(this.credentialsPath); - this.keyMaterialProvider = args.keyMaterialProvider ?? readDefaultOsBoundKeyMaterial; + this.keyMaterialProvider = args.keyMaterialProvider + ?? (() => readDefaultOsBoundKeyMaterial(osBindingDir)); this.keyMaterialProviderAsync = args.keyMaterialProviderAsync ?? (args.keyMaterialProvider ? async () => args.keyMaterialProvider?.() ?? null - : readDefaultOsBoundKeyMaterialAsync); + : () => readDefaultOsBoundKeyMaterialAsync(osBindingDir)); this.credentialChangePollIntervalMs = args.credentialChangePollIntervalMs === undefined ? CREDENTIAL_CHANGE_POLL_INTERVAL_MS : args.credentialChangePollIntervalMs; diff --git a/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts new file mode 100644 index 000000000..c5cf930d8 --- /dev/null +++ b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts @@ -0,0 +1,260 @@ +import crypto from "node:crypto"; +import { spawn, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const WINDOWS_DPAPI_KEY_FILE = ".credential-key.dpapi"; +const WINDOWS_DPAPI_KEY_MAGIC = "ADE_WINDOWS_DPAPI_KEY_V1"; +const WINDOWS_DPAPI_TIMEOUT_MS = 5_000; +const WINDOWS_DPAPI_MAX_OUTPUT_BYTES = 64 * 1024; + +const cachedKeyMaterial = new Map(); +const keyMaterialReadInFlight = new Map>(); + +const WINDOWS_DPAPI_SCRIPT = [ + "$ErrorActionPreference = 'Stop'", + "Add-Type -AssemblyName System.Security", + "$inputBytes = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim())", + "$scope = [Security.Cryptography.DataProtectionScope]::CurrentUser", + "if ($env:ADE_DPAPI_OPERATION -eq 'protect') {", + " $outputBytes = [Security.Cryptography.ProtectedData]::Protect($inputBytes, $null, $scope)", + "} elseif ($env:ADE_DPAPI_OPERATION -eq 'unprotect') {", + " $outputBytes = [Security.Cryptography.ProtectedData]::Unprotect($inputBytes, $null, $scope)", + "} else {", + " throw 'Unknown DPAPI operation.'", + "}", + "[Console]::Out.Write([Convert]::ToBase64String($outputBytes))", +].join("; "); + +function isNodeErrorCode(error: unknown, code: string): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === code; +} + +function ensureDirectory(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); +} + +function parseProtectedKeyFile(raw: string): Buffer { + const [magic, encoded, ...rest] = raw.trim().split(/\r?\n/); + if (magic !== WINDOWS_DPAPI_KEY_MAGIC || !encoded || rest.length > 0) { + throw new Error("ADE Windows credential key has an unsupported format."); + } + const protectedKey = Buffer.from(encoded, "base64"); + if (protectedKey.length === 0) { + throw new Error("ADE Windows credential key is invalid."); + } + return protectedKey; +} + +function decodeDpapiResult(raw: string): Buffer { + const value = raw.trim(); + const decoded = value ? Buffer.from(value, "base64") : Buffer.alloc(0); + if (decoded.length === 0) { + throw new Error("Windows DPAPI returned an empty credential key."); + } + return decoded; +} + +function dpapiChildEnv(operation: "protect" | "unprotect"): NodeJS.ProcessEnv { + const allowed = new Set([ + "comspec", + "path", + "pathext", + "psmodulepath", + "systemroot", + "temp", + "tmp", + "windir", + ]); + const env: NodeJS.ProcessEnv = { ADE_DPAPI_OPERATION: operation }; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined && allowed.has(key.toLowerCase())) env[key] = value; + } + return env; +} + +function dpapiArguments(): string[] { + return [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + WINDOWS_DPAPI_SCRIPT, + ]; +} + +function runDpapiSync(operation: "protect" | "unprotect", value: Buffer): Buffer { + const result = spawnSync("powershell.exe", dpapiArguments(), { + encoding: "utf8", + env: dpapiChildEnv(operation), + input: value.toString("base64"), + maxBuffer: WINDOWS_DPAPI_MAX_OUTPUT_BYTES, + timeout: WINDOWS_DPAPI_TIMEOUT_MS, + windowsHide: true, + }); + if (result.error) { + throw new Error("Windows DPAPI credential protection is unavailable."); + } + if (result.status !== 0) { + throw new Error("Windows DPAPI credential protection failed."); + } + return decodeDpapiResult(result.stdout ?? ""); +} + +function runDpapiAsync(operation: "protect" | "unprotect", value: Buffer): Promise { + return new Promise((resolve, reject) => { + const child = spawn("powershell.exe", dpapiArguments(), { + stdio: ["pipe", "pipe", "pipe"], + env: dpapiChildEnv(operation), + windowsHide: true, + }); + const stdout: Buffer[] = []; + let stdoutBytes = 0; + let settled = false; + const finish = (error: Error | null, output?: Buffer): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else resolve(output ?? Buffer.alloc(0)); + }; + const timeout = setTimeout(() => { + child.kill(); + finish(new Error("Windows DPAPI credential protection timed out.")); + }, WINDOWS_DPAPI_TIMEOUT_MS); + timeout.unref?.(); + child.once("error", () => { + finish(new Error("Windows DPAPI credential protection is unavailable.")); + }); + child.stdout.on("data", (chunk: Buffer | string) => { + const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + stdoutBytes += next.length; + if (stdoutBytes > WINDOWS_DPAPI_MAX_OUTPUT_BYTES) { + child.kill(); + finish(new Error("Windows DPAPI credential protection returned too much data.")); + return; + } + stdout.push(next); + }); + // Drain stderr without retaining it. PowerShell errors can contain host + // details, and diagnostics never need the protected key or credential input. + child.stderr.resume(); + child.stdin.once("error", () => { + finish(new Error("Windows DPAPI credential protection input failed.")); + }); + child.once("close", (code) => { + if (settled) return; + if (code !== 0) { + finish(new Error("Windows DPAPI credential protection failed.")); + return; + } + try { + finish(null, decodeDpapiResult(Buffer.concat(stdout).toString("utf8"))); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }); + child.stdin.end(value.toString("base64")); + }); +} + +function protectedKeyPath(secretsDir: string): string { + return path.resolve(secretsDir, WINDOWS_DPAPI_KEY_FILE); +} + +function unprotectKey(keyPath: string): Buffer { + const material = runDpapiSync( + "unprotect", + parseProtectedKeyFile(fs.readFileSync(keyPath, "utf8")), + ); + if (material.length !== 32) throw new Error("ADE Windows credential key is invalid."); + return material; +} + +async function unprotectKeyAsync(keyPath: string): Promise { + const material = await runDpapiAsync( + "unprotect", + parseProtectedKeyFile(await fs.promises.readFile(keyPath, "utf8")), + ); + if (material.length !== 32) throw new Error("ADE Windows credential key is invalid."); + return material; +} + +/** + * Returns a per-user, per-ADE-home key protected by Windows DPAPI. The random + * key crosses the PowerShell boundary only on stdin/stdout and the persisted + * blob is unusable from another Windows account. + */ +export function readOrCreateWindowsDpapiMaterial(secretsDir: string): Buffer { + const keyPath = protectedKeyPath(secretsDir); + const cached = cachedKeyMaterial.get(keyPath); + if (cached) return cached; + + let material: Buffer; + try { + material = unprotectKey(keyPath); + } catch (error) { + if (!isNodeErrorCode(error, "ENOENT")) throw error; + material = crypto.randomBytes(32); + const protectedKey = runDpapiSync("protect", material); + ensureDirectory(path.dirname(keyPath)); + try { + fs.writeFileSync( + keyPath, + `${WINDOWS_DPAPI_KEY_MAGIC}\n${protectedKey.toString("base64")}\n`, + { flag: "wx", mode: 0o600 }, + ); + } catch (writeError) { + if (!isNodeErrorCode(writeError, "EEXIST")) throw writeError; + material = unprotectKey(keyPath); + } + } + cachedKeyMaterial.set(keyPath, material); + return material; +} + +/** Async counterpart used by brain-facing credential reads. */ +export async function readOrCreateWindowsDpapiMaterialAsync(secretsDir: string): Promise { + const keyPath = protectedKeyPath(secretsDir); + const cached = cachedKeyMaterial.get(keyPath); + if (cached) return cached; + const existing = keyMaterialReadInFlight.get(keyPath); + if (existing) return await existing; + + const read = (async () => { + let material: Buffer; + try { + material = await unprotectKeyAsync(keyPath); + } catch (error) { + if (!isNodeErrorCode(error, "ENOENT")) throw error; + material = crypto.randomBytes(32); + const protectedKey = await runDpapiAsync("protect", material); + await fs.promises.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 }); + try { + await fs.promises.writeFile( + keyPath, + `${WINDOWS_DPAPI_KEY_MAGIC}\n${protectedKey.toString("base64")}\n`, + { flag: "wx", mode: 0o600 }, + ); + } catch (writeError) { + if (!isNodeErrorCode(writeError, "EEXIST")) throw writeError; + material = await unprotectKeyAsync(keyPath); + } + } + cachedKeyMaterial.set(keyPath, material); + return material; + })(); + keyMaterialReadInFlight.set(keyPath, read); + try { + return await read; + } finally { + if (keyMaterialReadInFlight.get(keyPath) === read) { + keyMaterialReadInFlight.delete(keyPath); + } + } +} diff --git a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts index 558276f01..69bbdef6e 100644 --- a/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/syncPairedMachineStore.test.ts @@ -297,7 +297,9 @@ describe("DesktopPairedMachineStore", () => { ], }); expect(store.path).toBe(path.join(adeHome, "secrets", "desktop-paired-machines.json")); - expect(fs.statSync(store.path).mode & 0o777).toBe(0o600); + if (process.platform !== "win32") { + expect(fs.statSync(store.path).mode & 0o777).toBe(0o600); + } expect(new DesktopPairedMachineStore().get("mac-studio-host")).toEqual(paired); expect(new DesktopPairedMachineStore().get("machine-123")).toEqual(paired); expect(new DesktopPairedMachineStore().getForReference({ diff --git a/docs/playbooks/windows-pr3-computer-use-proof.md b/docs/playbooks/windows-pr3-computer-use-proof.md new file mode 100644 index 000000000..12b40ce5a --- /dev/null +++ b/docs/playbooks/windows-pr3-computer-use-proof.md @@ -0,0 +1,296 @@ +# Windows desktop, account, and sync Computer Use proof + +Use this runbook to prove the Windows stacked-PR worker scope after the +coordinator has assembled the stack. It is an installed-build acceptance test, +not a source-development checklist. Run it once on Windows 10 22H2 x64 and once +on Windows 11 x64. + +Do not enable the public Windows release or download flags from this runbook. +Windows ARM64, WSL-backed execution, native Windows computer use, and Windows +SSH bootstrap are out of scope. + +## Safety and evidence rules + +- Use disposable repositories and test ADE accounts. Never use a production + repository or a personal account with unrelated machines. +- Do not open, capture, print, or attach files below an ADE `secrets` directory. + Do not capture OAuth query strings, authorization codes, access/refresh + tokens, pairing secrets, DPoP material, cookies, or the contents of credential + files. +- Pause recording before typing a pairing code, account credential, provider + credential, or SSH credential. Resume only after the secret-bearing surface + is gone. +- Browser DevTools Network and Application panels are not proof surfaces for + OAuth. Prove the user-visible redirect and resulting signed-in state only. +- Prefer screenshots for steady state and short videos for transitions. Name + artifacts `win-pr3----` and record the ADE version, + package channel, Windows build, origin host OS, client OS, and route in the + artifact note. +- A pass needs visible product state plus one independent origin-host check. + Logs alone are supporting evidence. Redact usernames, hostnames, repository + remotes, IP addresses, and email addresses before sharing logs. + +## Required test topology + +Prepare these machines or VMs: + +| ID | System | Role | +| --- | --- | --- | +| W | Windows 10/11 x64 standard user | Installed ADE under test; test as both host and controller | +| M | Supported macOS | ADE host/controller and iOS pairing station | +| L | Supported Linux x64 | ADE host/controller | +| I | Physical iPhone on a supported iOS version | Mobile controller | +| B | Chrome/Edge profile with no ADE site data | Hosted-web controller | + +Use the same disposable Git repository on W, M, and L, with a distinct clone on +each machine. Create one branch and one harmless unpushed commit per machine so +machine ownership and divergence are visible. Install Stable and Beta side by +side on W for the isolation phase. + +Record a sanitized matrix before starting: + +```text +ADE commit/version: +Package channel: +Windows edition/build/DPI: +W/M/L machine labels: +Test repository alias: +iOS version: +Expected account owner alias: +``` + +## 1. Windows desktop baseline + +1. Launch installed ADE from the Start menu as a standard user. Capture the + first visible window and confirm no console window flashes or remains open. +2. Exercise minimize, restore, maximize, double-click title-bar maximize, + Windows 11 Snap Layouts, and 100/125/150/200% DPI. Confirm caption buttons, + drag regions, and focus remain usable. +3. Open the disposable repository with the picker. Confirm the project path and + recent-project row use normal Windows paths and no raw IPC error appears. +4. In Lanes, create a lane from the local primary branch, rename its color, and + open Git Actions. Stage an untracked file, commit it, view history and diff, + then restore a stash that includes an untracked file. +5. In Files, create/edit/rename/delete a text fixture, use Quick Open and content + search, open Changes/Staged/Commit views, and copy a Windows path. Confirm + drive-letter and UNC-looking text do not break navigation. +6. In PRs, open the lane's PR detail or the empty/no-PR state, refresh it, and + exercise a non-mutating check/diff control. Do not create or merge a real PR. +7. In Work, open Chat, CLI, and Shell surfaces; switch tab/grid layout; resize the + session list and tools pane; open Git, Files, App Control, and Browser. Confirm + all tools remain reachable at a narrow width. +8. In a PowerShell PTY and a cmd PTY, print a Unicode fixture and a string + containing spaces, quotes, `$`, `%`, `&`, and backticks. Resize, send Ctrl+C, + close, and reopen the session. +9. Exercise fresh launch and resume UI for every installed provider. A provider + that is not configured must show an actionable auth state without exposing a + token. Do not add a real provider credential solely for this proof. +10. Open Browser, navigate between two benign pages, use back/forward/reload, + download a disposable file, inspect a visible element, and capture a browser + proof. Confirm App Control/CDP proof remains offered. +11. Invoke microphone dictation without granting access, confirm Windows privacy + guidance, grant access in Windows Settings, relaunch ADE, and confirm the + denial guidance clears. Do not record actual speech containing private data. +12. Trigger a harmless ADE notification and click it. Confirm it carries ADE's + app identity and returns focus to the correct session. +13. Confirm iOS Simulator, Xcode Preview, macOS Attention Notch, and native OS + computer-use actions are hidden or capability-blocked. Browser/App Control + and proof ingestion must remain available. + +Required evidence: one overview video plus screenshots of Lanes Git, Work tools, +Files, Browser proof, microphone guidance, notification routing, and the +capability-gated Windows UI. + +## 2. Deep links and project ownership + +Use a generated disposable ADE link whose target is already visible in the UI. +Do not include account tokens or pairing data. + +1. With ADE running, paste an `ade://` session or lane link into the Windows Run + dialog. Confirm the existing process focuses and navigates to the exact + target; no second ADE window/process remains. +2. Quit ADE completely and invoke the same link. Confirm cold launch opens the + correct project and target after initialization. +3. Repeat with a file link containing a Windows-relevant path and line number. +4. Connect a remote project, generate an owner-scoped link for a session on that + machine, and invoke it while another project is focused. Confirm ADE selects + or reconnects the owning machine/project rather than opening a same-ID local + row. +5. Repeat hot and cold on Stable and Beta. Stable owns the OS protocol binding; + Beta must not steal it. + +Required evidence: hot-link video, cold-link video, owner-scoped remote target, +and Stable/Beta process list after each invocation. + +## 3. ADE account and Google OAuth + +This is the first phase that performs a real login. The coordinator must run it +only after receiving explicit approval for the disposable account. + +1. Start signed out. Open Account and press the Google sign-in action once. + Confirm ADE opens the system default browser, not the built-in ADE Browser. +2. Pause recording before authentication. Complete Google/Clerk authentication. + Resume after the browser shows ADE's success page and the callback query is + no longer visible. +3. Confirm the desktop changes to signed in without restart and shows the + expected provider/account identity and account machine directory. +4. Close and reopen ADE, then log off/on Windows. Confirm the account session is + still available and no plaintext credential file is shown or inspected. +5. Sign out from Account. Confirm account-owned directory/Relay access closes, + the signed-out UI appears, and directly paired machine trust remains listed. +6. Reauthenticate with the same disposable account. Confirm the machine + directory repopulates and account-owned routes reconnect. +7. Begin another login, cancel before completing it, then finish the stale + browser page. Confirm the cancelled callback cannot silently sign ADE in. +8. Sign in as a second disposable account and confirm machines owned by the + first account do not reappear. Return to the first account only if needed for + later phases. + +Independent Windows check: while signed in, confirm the machine-owned account +session survives desktop exit because the background brain remains running; +after sign-out, confirm Relay closes without stopping local projects, agents, or +PTYs. Do not inspect credential contents. + +## 4. Route matrix and origin-host execution + +For every connection below, create or open a lane on the destination and start +a long-running harmless shell command that prints the destination OS, hostname, +and PID, then waits. From the controller, open its session and perform Git/Files/ +PR/Browser reads. On the destination, independently confirm that PID exists. On +the controller, confirm no matching worker process exists. This proves live +processes stay on the origin host. + +Run all rows: + +| Controller | Origin host | Route | Expected | +| --- | --- | --- | --- | +| W | M | LAN, then Tailscale, then Relay | Same remote project/session; route changes without moving the process | +| W | L | LAN, then Tailscale, then Relay | Same | +| M | W | LAN, then Tailscale, then Relay | Windows project and ConPTY session remain on W | +| L | W | LAN, then Tailscale, then Relay | Windows project and ConPTY session remain on W | + +For each row: + +1. Start with LAN available. Connect from Machines and record the displayed + route. Exercise project catalog, project open, lane list, Work union, Git, + Files, PR snapshot, terminal input/resize, and remote browser preview. +2. Disable only the LAN path while leaving Tailscale available. Wait for the + reconnect state, then confirm the same session resumes over Tailscale. +3. Disable the direct routes while both machines remain signed in. Confirm Relay + becomes the observed route and the same session resumes. +4. Sign the controller out. Relay must close. Restore LAN or Tailscale and + confirm direct device-bound pairing reconnects without account access. +5. Sign back in and confirm Relay becomes eligible again without replacing the + direct pairing record. +6. Restart the destination brain while the controller is open. Confirm the UI + enters reconnecting, returns to connected, rehydrates project/session state, + and does not duplicate the session or execute a command twice. +7. Reboot the destination. Confirm the per-user background brain returns after + login and the controller reconnects within the bounded retry policy. + +Never use WSL or SSH bootstrap to make W look like a Linux host. A Windows host +must advertise platform `windows` and execute through its packaged native brain. + +## 5. Windows Defender Firewall, Tailscale, and Relay + +Run on W as the origin host with I or another desktop as controller. + +1. With the applicable ADE inbound firewall permission allowed, connect by LAN + and record the route. +2. Block ADE inbound traffic in Windows Defender Firewall without stopping ADE. + Confirm LAN fails with actionable connection state; no false connected state + may be shown merely because loopback health is green. +3. With Tailscale running on both machines, confirm the controller reconnects by + Tailscale. Stop Tailscale and confirm that route becomes unavailable. +4. With both machines signed in, confirm Relay reconnects after direct routes + fail. Sign out on W and confirm Relay closes immediately. +5. Restore the firewall rule and LAN. Refresh discovery and confirm LAN becomes + preferred again. +6. Repeat one connection after Windows logoff/logon and one after full reboot. + +Capture the product route/status UI, not firewall rule details containing user +or network identifiers. + +## 6. CRR, sessions, remote commands, web, and iOS compatibility + +Use one Windows host and one macOS/Linux host. Test current-current first, then +repeat with the oldest supported released controller against the current host. + +1. Pair I to W and select the disposable project. Create a lane-local state + change on W that is represented in CRR data; confirm it appears on I. +2. Perform an allowed state mutation on I (for example settle/unsettle or a + harmless draft/state change); confirm it appears on W without duplicate rows. +3. Start a Windows Work chat and PTY. Open both on I; verify transcript hydration, + live events, terminal offsets, input ACK behavior, resize, disconnect, and + replay after reconnect. +4. Invoke only advertised iOS remote commands: list/open project, list lanes and + sessions, open Files/PR detail, settle/unsettle, and one harmless host-executed + command. Confirm unsupported optional actions are hidden or return update + guidance rather than breaking the socket. +5. Open B, sign in, adopt W through the account directory, and select the same + project. Confirm web has no local shell/browser/App Control surface, but live + project reads, Work transcript paging, terminal streaming, lifecycle controls, + Files, and PR snapshot work through the host. +6. Disconnect/reconnect B during chat streaming. Confirm the hydration barrier + produces neither a missing event nor a duplicate assistant row and older-page + Retry preserves its cursor after a transient failure. +7. Run the same iOS and web checks with M or L as host while W remains a connected + desktop controller. Confirm platform labels and machine ownership remain + correct in every client. +8. With the oldest supported controller, confirm additive hello fields are + ignored safely, required mobile actions determine limited mode, optional + actions are feature-detected, and legacy session/CRR rows remain readable. + +Required evidence: Windows CRR roundtrip, iOS command result, web project view, +chat reconnect with no duplication, terminal resume, and compatibility/limited +mode where applicable. + +## 7. Stable/Beta and Windows-user isolation + +1. Run Stable and Beta simultaneously under the same Windows account. Confirm + distinct ADE homes, background brains, account-directory names, sync ports, + runtime pipes, desktop-bridge pipes, projects, and sessions. +2. Invoke Stable's `ade://` link and confirm Beta does not claim it. +3. Sign in or pair only one channel and confirm the other does not inherit the + session or pairing. +4. Repeat launch and local-project checks from a second standard Windows user. + Confirm neither user's project catalog, account state, pairings, or runtime + endpoint is visible to the other. + +Do not prove isolation by opening either user's credential files. Prove it from +the visible product state and process/pipe names with user and hash values +redacted from shared artifacts. + +## 8. Final recovery and negative checks + +1. Quit the desktop while a harmless background brain-owned session is active. + Confirm the process continues on its origin host; reopen ADE and reattach. +2. Restart the brain during an idle chat, an active terminal, and a pending + controller reconnect. Confirm bounded recovery and no duplicate command. +3. Log out/in and reboot W. Confirm account state, project catalog, paired direct + trust, and reconnect policy recover as designed. +4. Uninstall ADE. Confirm its background startup entry, owned terminal shim, and + owned user `PATH` entry are removed, with unrelated user data untouched. +5. Record explicit non-goals: no Windows ARM64 package, no WSL execution path, + no native Windows computer-use backend, no iOS Simulator/Xcode surface, and + no Windows SSH-bootstrap promise. + +## Pass report template + +```text +Result: PASS | FAIL | BLOCKED +ADE version/commit: +Windows versions: +Client/host matrix completed: +Routes completed: LAN | Tailscale | Relay +OAuth/account completed by authorized coordinator: yes/no +CRR current-current: pass/fail +CRR oldest-supported compatibility: pass/fail +Origin-host process proof: pass/fail +Logout/reboot/brain-restart recovery: pass/fail +Stable/Beta/user isolation: pass/fail +Evidence artifact IDs: +Sanitized logs attached: +Defects/blockers with exact reproduction: +Public release flags changed: no +``` From 1f6f0c4f8b55274bd32eb19927d386456ea24aa1 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 22:36:47 -0400 Subject: [PATCH 04/51] fix(windows): harden DPAPI credential migration Resolve Windows PowerShell through the kernel SystemRoot namespace and atomically bind legacy ciphertext on first read. Co-authored-by: David Whatley Based-on: nsxdavid/ADE#999 --- .../credentials/credentialStore.test.ts | 59 +++++++++++++++++-- .../services/credentials/credentialStore.ts | 45 ++++++++++---- .../credentials/windowsDpapiMaterial.ts | 33 ++++++++++- 3 files changed, 119 insertions(+), 18 deletions(-) diff --git a/apps/ade-cli/src/services/credentials/credentialStore.test.ts b/apps/ade-cli/src/services/credentials/credentialStore.test.ts index c1369516f..187ce4498 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.test.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.test.ts @@ -13,6 +13,7 @@ import { import { readOrCreateWindowsDpapiMaterial, readOrCreateWindowsDpapiMaterialAsync, + resolveWindowsDpapiPowerShellPath, } from "./windowsDpapiMaterial"; let tempDir = ""; @@ -26,6 +27,29 @@ afterEach(() => { }); describe("EncryptedFileCredentialStore", () => { + it.runIf(process.platform === "win32")( + "resolves Windows DPAPI PowerShell through kernel SystemRoot despite poisoned environment paths", + () => { + const previousSystemRoot = process.env.SystemRoot; + const previousWinDir = process.env.windir; + process.env.SystemRoot = path.join(tempDir, "attacker-system-root"); + process.env.windir = path.join(tempDir, "attacker-windir"); + try { + const resolved = resolveWindowsDpapiPowerShellPath(); + expect(path.win32.isAbsolute(resolved)).toBe(true); + expect(resolved.toLowerCase()).toMatch( + /\\system32\\windowspowershell\\v1\.0\\powershell\.exe$/, + ); + expect(resolved.toLowerCase()).not.toContain(tempDir.toLowerCase()); + } finally { + if (previousSystemRoot === undefined) delete process.env.SystemRoot; + else process.env.SystemRoot = previousSystemRoot; + if (previousWinDir === undefined) delete process.env.windir; + else process.env.windir = previousWinDir; + } + }, + ); + it.runIf(process.platform === "win32")( "binds headless credential encryption to the current Windows account with DPAPI", async () => { @@ -263,6 +287,31 @@ new EncryptedFileCredentialStore({ secretsDir }).setSync(key, value); expect(unbound.getSync("linear.token.v1")).toBeNull(); }); + it("atomically binds legacy Windows ciphertext on the first asynchronous credential read", async () => { + const legacyStore = new EncryptedFileCredentialStore({ + secretsDir: tempDir, + keyMaterialProvider: () => null, + }); + legacyStore.setSync("account.session.v1", "legacy-async-windows-session"); + const credentialsPath = path.join(tempDir, "credentials.json.enc"); + const legacyCiphertext = fs.readFileSync(credentialsPath, "utf8"); + const osMaterial = Buffer.from("windows-async-account-bound-material"); + + const upgraded = new EncryptedFileCredentialStore({ + secretsDir: tempDir, + keyMaterialProvider: () => { + throw new Error("async migration must not use synchronous key access"); + }, + keyMaterialProviderAsync: async () => osMaterial, + }); + await expect(upgraded.get("account.session.v1")).resolves.toBe("legacy-async-windows-session"); + expect(fs.readFileSync(credentialsPath, "utf8")).not.toBe(legacyCiphertext); + expect(new EncryptedFileCredentialStore({ + secretsDir: tempDir, + keyMaterialProvider: () => null, + }).getSync("account.session.v1")).toBeNull(); + }); + it("uses the asynchronous key-material path for asynchronous reads", async () => { const osMaterial = Buffer.from("test-os-material"); new EncryptedFileCredentialStore({ @@ -319,23 +368,21 @@ new EncryptedFileCredentialStore({ secretsDir }).setSync(key, value); expect(asyncProvider).not.toHaveBeenCalled(); }); - it("can read legacy machine-key ciphertext before rewriting with OS-bound key material", async () => { + it("atomically binds legacy Windows ciphertext on the first synchronous credential read", () => { const legacy = new EncryptedFileCredentialStore({ secretsDir: tempDir, keyMaterialProvider: () => null, }); legacy.setSync("agent.token", "legacy_secret"); + const credentialPath = path.join(tempDir, "credentials.json.enc"); + const legacyCiphertext = fs.readFileSync(credentialPath, "utf8"); const upgraded = new EncryptedFileCredentialStore({ secretsDir: tempDir, keyMaterialProvider: () => Buffer.from("test-os-material"), }); expect(upgraded.getSync("agent.token")).toBe("legacy_secret"); - expect(legacy.getSync("agent.token")).toBe("legacy_secret"); - - upgraded.setSync("agent.token", "bound_secret"); - - expect(upgraded.getSync("agent.token")).toBe("bound_secret"); + expect(fs.readFileSync(credentialPath, "utf8")).not.toBe(legacyCiphertext); expect(legacy.getSync("agent.token")).toBeNull(); }); diff --git a/apps/ade-cli/src/services/credentials/credentialStore.ts b/apps/ade-cli/src/services/credentials/credentialStore.ts index c61b54fa8..c124c55d4 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.ts @@ -719,7 +719,9 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { getSync(key: string): string | null { const normalized = normalizeKey(key); - return this.readAll({ allowRewrite: false })[normalized] ?? null; + return this.withLock( + () => this.readAll({ allowRewrite: false, migrateLegacy: true })[normalized] ?? null, + ); } getLastReadState(): CredentialStoreReadState { @@ -786,7 +788,7 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { return this.readAll({ allowRewrite: false }); } - private readAll(args: { allowRewrite: boolean }): Record { + private readAll(args: { allowRewrite: boolean; migrateLegacy?: boolean }): Record { const credentialsExist = fs.existsSync(this.credentialsPath); const raw = readJsonObject(this.credentialsPath); const machineKey = readOrCreateMachineKey(this.machineKeyPath); @@ -809,12 +811,8 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { throw error; } this.lastReadState = credentialsExist ? "available" : "missing"; - if (args.allowRewrite) { - try { - this.writeAll(values); - } catch { - // Preserve read compatibility if migration cannot rewrite right now. - } + if (args.allowRewrite || args.migrateLegacy) { + this.writeAllWithKey(values, key); } return values; } @@ -843,7 +841,8 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { throw new Error("Unsupported ADE credential store format."); } const machineKey = await readOrCreateMachineKeyAsync(this.machineKeyPath); - const key = deriveOsBoundCredentialKey(machineKey, await this.keyMaterialProviderAsync()); + const osMaterial = await this.keyMaterialProviderAsync(); + const key = deriveOsBoundCredentialKey(machineKey, osMaterial); if (!key.equals(machineKey)) { try { const values = deserializeStore(raw, key, { emptyOnDecryptFailure: false }); @@ -851,7 +850,16 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { return values; } catch { try { - const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false }); + deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false }); + } catch (error) { + this.lastReadState = "unreadable"; + throw error; + } + try { + if (!osMaterial || osMaterial.length === 0) { + throw new Error("OS-bound credential material is unavailable during migration."); + } + const values = this.withLock(() => this.migrateLegacyUnderLock(osMaterial)); this.lastReadState = "available"; return values; } catch (error) { @@ -873,9 +881,26 @@ export class EncryptedFileCredentialStore implements SyncCredentialStore { private writeAll(values: Record): void { const machineKey = readOrCreateMachineKey(this.machineKeyPath); const key = deriveOsBoundCredentialKey(machineKey, this.keyMaterialProvider()); + this.writeAllWithKey(values, key); + } + + private writeAllWithKey(values: Record, key: Buffer): void { writeFileAtomic(this.credentialsPath, `${JSON.stringify(serializeStore(values, key), null, 2)}\n`); } + private migrateLegacyUnderLock(osMaterial: Buffer): Record { + const raw = readJsonObject(this.credentialsPath); + const machineKey = readOrCreateMachineKey(this.machineKeyPath); + const key = deriveOsBoundCredentialKey(machineKey, osMaterial); + try { + return deserializeStore(raw, key, { emptyOnDecryptFailure: false }); + } catch { + const values = deserializeStore(raw, machineKey, { emptyOnDecryptFailure: false }); + this.writeAllWithKey(values, key); + return values; + } + } + private withLock(fn: () => T): T { return withCredentialFileLock(this.lockPath, fn); } diff --git a/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts index c5cf930d8..9d3a2e44a 100644 --- a/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts +++ b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts @@ -7,6 +7,8 @@ const WINDOWS_DPAPI_KEY_FILE = ".credential-key.dpapi"; const WINDOWS_DPAPI_KEY_MAGIC = "ADE_WINDOWS_DPAPI_KEY_V1"; const WINDOWS_DPAPI_TIMEOUT_MS = 5_000; const WINDOWS_DPAPI_MAX_OUTPUT_BYTES = 64 * 1024; +const WINDOWS_DPAPI_POWERSHELL_KERNEL_PATH = + "\\\\?\\GLOBALROOT\\SystemRoot\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; const cachedKeyMaterial = new Map(); const keyMaterialReadInFlight = new Map>(); @@ -88,8 +90,35 @@ function dpapiArguments(): string[] { ]; } +/** + * Resolve Windows PowerShell through the kernel-owned SystemRoot link. The + * mutable SystemRoot/windir environment and CreateProcess executable search + * are intentionally not involved, so an untrusted project or poisoned launch + * environment cannot redirect the DPAPI helper. + */ +export function resolveWindowsDpapiPowerShellPath(): string { + try { + const resolved = path.win32.normalize( + fs.realpathSync.native(WINDOWS_DPAPI_POWERSHELL_KERNEL_PATH), + ); + const parsed = path.win32.parse(resolved); + const expectedSuffix = "\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; + if ( + !path.win32.isAbsolute(resolved) + || !/^[A-Za-z]:\\$/.test(parsed.root) + || !resolved.toLowerCase().endsWith(expectedSuffix.toLowerCase()) + || !fs.statSync(resolved).isFile() + ) { + throw new Error("invalid system PowerShell path"); + } + return resolved; + } catch { + throw new Error("Windows DPAPI credential protection is unavailable."); + } +} + function runDpapiSync(operation: "protect" | "unprotect", value: Buffer): Buffer { - const result = spawnSync("powershell.exe", dpapiArguments(), { + const result = spawnSync(resolveWindowsDpapiPowerShellPath(), dpapiArguments(), { encoding: "utf8", env: dpapiChildEnv(operation), input: value.toString("base64"), @@ -108,7 +137,7 @@ function runDpapiSync(operation: "protect" | "unprotect", value: Buffer): Buffer function runDpapiAsync(operation: "protect" | "unprotect", value: Buffer): Promise { return new Promise((resolve, reject) => { - const child = spawn("powershell.exe", dpapiArguments(), { + const child = spawn(resolveWindowsDpapiPowerShellPath(), dpapiArguments(), { stdio: ["pipe", "pipe", "pipe"], env: dpapiChildEnv(operation), windowsHide: true, From bbac621c64af3470d4753f8981065d2509adc067 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sat, 1 Aug 2026 23:15:27 -0400 Subject: [PATCH 05/51] fix(windows): align App Control startup shell syntax Select App Control's compound package-script command after the PTY has actually chosen PowerShell, cmd, or Git Bash. Add native Git Bash path rendering and regressions across the service-to-PTY contract. Based-on: nsxdavid/ADE#999 --- .../appControlLaunchCommand.test.ts | 13 ++++++ .../appControl/appControlLaunchCommand.ts | 22 +++++++-- .../appControl/appControlService.test.ts | 45 +++++++++++++++++++ .../services/appControl/appControlService.ts | 36 +++++++++++---- .../src/main/services/pty/ptyService.test.ts | 29 ++++++++++++ .../src/main/services/pty/ptyService.ts | 13 ++++-- apps/desktop/src/shared/cliLaunch.ts | 8 ++-- apps/desktop/src/shared/types/sessions.ts | 8 ++++ 8 files changed, 156 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts index 6e66d4751..0522761bb 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts @@ -98,6 +98,19 @@ describe("appControlLaunchCommand", () => { expect(cmd).toContain('set "PATH='); expect(cmd).toContain(';%PATH%" &&'); expect(cmd).not.toContain(":$PATH"); + + const gitBash = rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32", shell: "git-bash" }, + ); + expect(gitBash).toMatch(/^cd -- '?\/[a-z]\//); + expect(gitBash).toContain("/node_modules/.bin'"); + expect(gitBash).toContain(":$PATH"); + expect(gitBash).toContain(" && "); + expect(gitBash).not.toContain("Set-Location"); + expect(gitBash).not.toContain('set "PATH='); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts index af146205d..8cd8b8d9b 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { commandArrayToLine, parseCommandLine } from "../../../shared/shell"; +import type { WindowsShellKind } from "../../../shared/types"; export type AppControlDirectLaunch = { command: string; @@ -13,10 +14,9 @@ export type AppControlPackageLaunch = AppControlDirectLaunch & { cwd: string; }; -type WindowsShell = "powershell" | "cmd"; type LaunchOptions = { platform?: NodeJS.Platform; - shell?: WindowsShell; + shell?: WindowsShellKind; }; export function shellQuote(value: string): string { @@ -193,6 +193,13 @@ function quoteCmdSetValue(value: string): string { .replace(/[\r\n]/g, " "); } +function gitBashPath(value: string): string { + const normalized = value.split(String.fromCharCode(92)).join("/"); + const drivePath = normalized.match(/^([a-z]):\/(.*)$/i); + if (!drivePath) return normalized; + return `/${drivePath[1]!.toLowerCase()}/${drivePath[2]}`; +} + export function rewritePackageScriptElectronLaunch( command: string, debugFlags: string[], @@ -220,7 +227,8 @@ export function rewritePackageScriptElectronLaunch( const platform = options.platform ?? process.platform; if (platform === "win32") { const parsedEnv = takeLeadingEnv(envPrefix).env; - if ((options.shell ?? "powershell") === "cmd") { + const shell = options.shell ?? "powershell"; + if (shell === "cmd") { const assignments = [ `cd /d "${quoteCmdSetValue(packageDir)}"`, `set "PATH=${quoteCmdSetValue(packageBinPath)};%PATH%"`, @@ -231,6 +239,14 @@ export function rewritePackageScriptElectronLaunch( return `${assignments.join(" && ")} && ${rewrittenScript}`; } + if (shell === "git-bash") { + const expandedEnvPrefix = [ + `PATH=${shellQuote(gitBashPath(packageBinPath))}:$PATH`, + ...Object.entries(parsedEnv).map(([key, value]) => `${key}=${shellQuote(value)}`), + ].join(" "); + return `cd -- ${shellQuote(gitBashPath(packageDir))} && ${prependEnvToShellSegments(rewrittenScript, expandedEnvPrefix)}`; + } + const assignments = [ `Set-Location -LiteralPath ${quotePowerShellLiteral(packageDir)}`, `$env:PATH = ${quotePowerShellLiteral(`${packageBinPath};`)} + $env:PATH`, diff --git a/apps/desktop/src/main/services/appControl/appControlService.test.ts b/apps/desktop/src/main/services/appControl/appControlService.test.ts index 07c6b5271..09ef69a7f 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.test.ts @@ -1,4 +1,7 @@ import type { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Logger } from "../logging/logger"; @@ -196,6 +199,48 @@ describe("appControlService", () => { } }); + it("passes shell-specific Windows package-script commands through to the PTY", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + const create = vi.fn(async (_input: Record) => ({ + sessionId: "terminal-windows-shells", + ptyId: "pty-windows-shells", + pid: 42, + })); + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-app-control-shells-")); + fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({ + scripts: { dev: "echo preparing && electron ." }, + }), "utf8"); + const service = createAppControlService({ + projectRoot, + logger: createLogger(), + resolveLaneId: () => "lane-1", + ptyService: { + create, + onExit: vi.fn(() => () => {}), + signalTerminal: vi.fn(), + } as any, + }); + + try { + await service.launch({ command: "npm run dev", cwd: projectRoot }); + + const createArgs = create.mock.calls[0]?.[0] as Record; + expect(createArgs).not.toHaveProperty("command"); + expect(createArgs.windowsStartupCommands.powershell).toContain("Set-Location -LiteralPath"); + expect(createArgs.windowsStartupCommands.powershell).not.toContain(" && "); + expect(createArgs.windowsStartupCommands.cmd).toContain('cd /d "'); + expect(createArgs.windowsStartupCommands.cmd).toContain(" && "); + expect(createArgs.windowsStartupCommands["git-bash"]).toMatch(/^cd -- \/[a-z]\//); + expect(createArgs.windowsStartupCommands["git-bash"]).toContain(" && "); + expect(createArgs.startupCommand).toBe(createArgs.windowsStartupCommands.powershell); + } finally { + service.dispose(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + } + }); + it("preserves shell environment expansion for Electron launches outside Windows", async () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); diff --git a/apps/desktop/src/main/services/appControl/appControlService.ts b/apps/desktop/src/main/services/appControl/appControlService.ts index c58f2ad77..fa07580c5 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.ts @@ -27,6 +27,7 @@ import type { AppControlStopArgs, AppControlTarget, AppControlTypeTextArgs, + WindowsShellKind, } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { createPtyService } from "../pty/ptyService"; @@ -155,6 +156,7 @@ type ResolvedLaunch = { command?: string; args?: string[]; env?: Record; + windowsStartupCommands?: Partial>; }; function nowIso(): string { @@ -1373,6 +1375,7 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { } } let command = rawCommand; + let windowsStartupCommands: Partial> | undefined; if (!commandForwardsAppControlDebug(command)) { if (process.platform === "win32") { const structuredPackage = resolvePackageScriptElectronLaunch( @@ -1409,15 +1412,26 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { } if (commandLooksLikePackageScriptLaunch(command)) { - command = rewritePackageScriptElectronLaunch( - command, - autoDebugFlags, - cwd, - { + if (process.platform === "win32") { + const originalCommand = command; + windowsStartupCommands = Object.fromEntries( + (["powershell", "cmd", "git-bash"] as const) + .map((shell) => [ + shell, + rewritePackageScriptElectronLaunch(originalCommand, autoDebugFlags, cwd, { + platform: "win32", + shell, + }), + ] as const) + .filter((entry): entry is readonly [WindowsShellKind, string] => Boolean(entry[1])), + ) as Partial>; + command = windowsStartupCommands.powershell + ?? `${originalCommand} -- ${autoDebugFlags.map(shellQuote).join(" ")}`; + } else { + command = rewritePackageScriptElectronLaunch(command, autoDebugFlags, cwd, { platform: process.platform, - shell: process.platform === "win32" ? "powershell" : undefined, - }, - ) ?? `${command} -- ${autoDebugFlags.map(shellQuote).join(" ")}`; + }) ?? `${command} -- ${autoDebugFlags.map(shellQuote).join(" ")}`; + } } else if (commandLooksLikeDirectElectronLaunch(command)) { command = insertDebugFlagsIntoDirectElectronCommand(command, autoDebugFlags); } @@ -1429,6 +1443,9 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { label: launchArgs.label?.trim() || rawCommand, cwd, commandForDisplay: command, + ...(windowsStartupCommands && Object.keys(windowsStartupCommands).length + ? { windowsStartupCommands } + : {}), }; } @@ -1600,6 +1617,9 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { // its parent chat. toolType: "shell", startupCommand: resolved.commandForDisplay, + ...(resolved.windowsStartupCommands + ? { windowsStartupCommands: resolved.windowsStartupCommands } + : {}), ...(resolved.command ? { command: resolved.command, args: resolved.args ?? [] } : {}), diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 1a206c3fb..6a79e0e3b 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -3134,6 +3134,35 @@ describe("ptyService", () => { ); }); + it.each([ + ["powershell.exe", "powershell-command"], + ["cmd.exe", "cmd-command"], + [["C:", "Program Files", "Git", "bin", "bash.exe"].join(String.fromCharCode(92)), "git-bash-command"], + ])("types the startup command for the selected Windows shell: %s", async (shell, expectedCommand) => { + setPlatform("win32"); + process.env.SHELL = shell; + const { service, mockPty, loadPty } = createHarness(); + + await service.create({ + laneId: "lane-1", + title: "App Control", + cols: 80, + rows: 24, + toolType: "shell", + startupCommand: "display-command", + windowsStartupCommands: { + powershell: "powershell-command", + cmd: "cmd-command", + "git-bash": "git-bash-command", + }, + startupDelayMs: 0, + }); + + const ptyLib = loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + expect(ptyLib.spawn.mock.calls[0]?.[0]).toBe(shell); + expect(mockPty.write).toHaveBeenCalledWith(`${expectedCommand}\r`); + }); + it("launches Cursor through the legacy Windows agent.cmd alias when needed", async () => { setPlatform("win32"); const previousAppData = process.env.APPDATA; diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 33e71f702..e64e082bb 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -92,6 +92,7 @@ import { withClaudePluginInCommandLine, withCodexNoAltScreen, resolveWindowsShellLaunchFields, + resolveWindowsShellKind, } from "../../../shared/cliLaunch"; import { claudeAgentSkillPluginRoots } from "../skills/agentSkillRuntimeService"; import { stripAnsi } from "../../utils/ansiStrip"; @@ -5214,12 +5215,18 @@ export function createPtyService({ // interactive shell. Direct command launches already received argv; if a // direct launch fell back to shell, startupCommand keeps compatibility // with CLIs that are only available through shell startup files. - if (startupCommand && !launchedDirectCommand && selectedShell) { + const selectedWindowsShellKind = process.platform === "win32" && selectedShell + ? resolveWindowsShellKind(selectedShell.file) + : null; + const selectedStartupCommand = selectedWindowsShellKind + ? effectiveArgs.windowsStartupCommands?.[selectedWindowsShellKind] ?? startupCommand + : startupCommand; + if (selectedStartupCommand && !launchedDirectCommand && selectedShell) { const writeStartupCommand = () => { entry.startupTimer = null; if (entry.disposed) return; try { - pty.write(`${startupCommand}\r`); + pty.write(`${selectedStartupCommand}\r`); setRuntimeState(sessionId, "running"); scheduleIdleTransition(sessionId); } catch (err) { @@ -5234,7 +5241,7 @@ export function createPtyService({ }); } }; - const startupDelayMs = normalizeStartupCommandDelayMs(effectiveArgs.startupDelayMs); + const startupDelayMs = normalizeStartupCommandDelayMs(effectiveArgs.startupDelayMs); if (startupDelayMs > 0) { entry.startupTimer = setTimeout(writeStartupCommand, startupDelayMs); entry.startupTimer.unref?.(); diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index af778a4e2..e15fcc59f 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -8,6 +8,7 @@ import type { TerminalResumeMetadata, TerminalSessionSummary, TerminalToolType, + WindowsShellKind, } from "./types"; import { ADE_AGENT_SKILLS_DIRS_ENV, @@ -53,10 +54,9 @@ function unquoteWindowsShellPath(value: string | null | undefined): string { return trimmed; } -type WindowsShellKind = "powershell" | "cmd" | "git-bash"; export type WindowsShellLaunchMode = "interactive" | "clean" | "login"; -function windowsShellKind(command: string): WindowsShellKind | null { +export function resolveWindowsShellKind(command: string): WindowsShellKind | null { const normalized = command.replace(/\//g, "\\"); if (/^\\\\(?:wsl\$|wsl\.localhost)\\/i.test(normalized)) return null; const basename = normalized.split("\\").pop()?.toLowerCase() ?? ""; @@ -85,7 +85,7 @@ export function resolveWindowsShellLaunchFields( ): CleanShellLaunchFields | null { const command = unquoteWindowsShellPath(value); if (!command) return null; - const kind = windowsShellKind(command); + const kind = resolveWindowsShellKind(command); const mode = options.mode ?? "interactive"; if (kind === "powershell") { return { @@ -357,7 +357,7 @@ export function resolveCleanShellLaunchFields(args: { // ComSpec is normally cmd.exe even when ADE was launched from PowerShell. // Preserve PowerShell as ADE's default, while still honoring an explicitly // configured PowerShell executable in ComSpec. - if (comSpec && windowsShellKind(comSpec.command) === "powershell") { + if (comSpec && resolveWindowsShellKind(comSpec.command) === "powershell") { return comSpec; } return { command: "powershell.exe", args: ["-NoLogo", "-NoProfile"] }; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 8277dfaff..ef588a2d1 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -291,6 +291,8 @@ export type ClaudeSessionPointer = { updatedAt: string; }; +export type WindowsShellKind = "powershell" | "cmd" | "git-bash"; + export type PtyCreateArgs = { sessionId?: string; /** Allow callers to pre-assign a new session id instead of only resuming an existing tracked session. */ @@ -309,6 +311,12 @@ export type PtyCreateArgs = { tracked?: boolean; toolType?: TerminalToolType | null; startupCommand?: string; + /** + * Shell-specific variants of startupCommand. On Windows the PTY selects the + * entry only after a native shell has spawned, so fallback shell selection + * cannot accidentally receive another shell's syntax. + */ + windowsStartupCommands?: Partial>; startupDelayMs?: number; /** Optional input to send to the PTY after the process starts. */ initialInput?: string; From d8472edf2ca653a969bae6c2b5f6b0e8b6b51602 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 13:36:25 -0400 Subject: [PATCH 06/51] test(windows): derive Git Bash cd expectations from the fixture path The App Control Git Bash startup-command assertions hard coded an MSYS drive-letter root (`^cd -- '?/[a-z]/`) while the fixture package directory came from `os.tmpdir()`. On windows-latest the two coincide; on ubuntu-latest the fixture is `/tmp/...`, which has no drive letter, so the assertion could never match and `test-desktop` failed 2 of 1418. `gitBashPath` itself is correct: it folds a drive-letter root into MSYS form and passes a rootless path through untouched, because Git Bash accepts POSIX and UNC paths as-is. Only the tests encoded a host-dependent expectation while forcing `platform: "win32"`. Both assertions now derive the expected `cd` target from the fixture's own path, so each runner asserts the exact command it really produces, and the drive-letter rewrite is pinned separately by a `gitBashPath` unit test over literal Windows inputs. That test runs on every host, so the MSYS conversion is now genuinely covered on the Linux runner instead of merely failing there. Based-on: nsxdavid/ADE#999 (cherry picked from commit de007f02c9d0930badb4842dcf439a6a7741fd8d) --- .../appControlLaunchCommand.test.ts | 22 ++++++++++++++++++- .../appControl/appControlLaunchCommand.ts | 9 +++++++- .../appControl/appControlService.test.ts | 11 +++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts index 0522761bb..fafdc7d54 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts @@ -6,6 +6,7 @@ import { commandForwardsAppControlDebug, commandLooksLikeDirectElectronLaunch, commandLooksLikePackageScriptLaunch, + gitBashPath, insertDebugFlagsIntoDirectElectronCommand, resolveDirectElectronLaunch, resolvePackageScriptElectronLaunch, @@ -105,7 +106,14 @@ describe("appControlLaunchCommand", () => { projectRoot, { platform: "win32", shell: "git-bash" }, ); - expect(gitBash).toMatch(/^cd -- '?\/[a-z]\//); + // The fixture's package directory is a host-native temp dir: a drive-letter + // path on Windows, a plain POSIX path on a Linux runner. Deriving the + // expected `cd` target from the fixture keeps this assertion exact on every + // host; the drive-letter -> MSYS rewrite itself is pinned host-independently + // by the `gitBashPath` case below. + const expectedCd = `cd -- ${shellQuote(gitBashPath(projectRoot))} && `; + expect(gitBash?.slice(0, expectedCd.length)).toBe(expectedCd); + expect(gitBash).not.toContain(String.fromCharCode(92)); expect(gitBash).toContain("/node_modules/.bin'"); expect(gitBash).toContain(":$PATH"); expect(gitBash).toContain(" && "); @@ -116,6 +124,18 @@ describe("appControlLaunchCommand", () => { } }); + it("rewrites Windows drive-letter paths into MSYS form for Git Bash", () => { + // Git Bash receives MSYS paths, not native Windows ones. This runs on every + // host because the inputs are literals rather than host-native temp dirs, so + // the conversion stays covered on the Linux unit runner as well as Windows. + expect(gitBashPath("C:\\Users\\ade\\my app")).toBe("/c/Users/ade/my app"); + expect(gitBashPath("D:/work/app/node_modules/.bin")).toBe("/d/work/app/node_modules/.bin"); + expect(gitBashPath("c:\\ade")).toBe("/c/ade"); + // Rootless and UNC paths have no drive letter to fold; MSYS takes them as-is. + expect(gitBashPath("/tmp/ade-app-control")).toBe("/tmp/ade-app-control"); + expect(gitBashPath("\\\\server\\share\\app")).toBe("//server/share/app"); + }); + it("detects direct Electron launches and injects debug flags after electron", () => { expect(commandLooksLikeDirectElectronLaunch("FOO=bar npx electron .")).toBe(true); diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts index 8cd8b8d9b..85025835e 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts @@ -193,7 +193,14 @@ function quoteCmdSetValue(value: string): string { .replace(/[\r\n]/g, " "); } -function gitBashPath(value: string): string { +/** + * Rewrites a native Windows path into the MSYS form Git Bash expects: + * `C:\Users\ade` becomes `/c/Users/ade`. Only a drive-letter root is rewritten; + * a path that already has no drive (a UNC share, or a POSIX path) is passed + * through with separators normalised, because MSYS understands those as-is and + * inventing a drive letter for them would corrupt them. + */ +export function gitBashPath(value: string): string { const normalized = value.split(String.fromCharCode(92)).join("/"); const drivePath = normalized.match(/^([a-z]):\/(.*)$/i); if (!drivePath) return normalized; diff --git a/apps/desktop/src/main/services/appControl/appControlService.test.ts b/apps/desktop/src/main/services/appControl/appControlService.test.ts index 09ef69a7f..923090e22 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Logger } from "../logging/logger"; +import { gitBashPath, shellQuote } from "./appControlLaunchCommand"; type FakeCdpTarget = { id: string; @@ -231,7 +232,15 @@ describe("appControlService", () => { expect(createArgs.windowsStartupCommands.powershell).not.toContain(" && "); expect(createArgs.windowsStartupCommands.cmd).toContain('cd /d "'); expect(createArgs.windowsStartupCommands.cmd).toContain(" && "); - expect(createArgs.windowsStartupCommands["git-bash"]).toMatch(/^cd -- \/[a-z]\//); + // `projectRoot` is a host-native temp dir, so its shape differs per runner: + // a drive-letter path on windows-latest, a plain POSIX path on ubuntu. + // Derive the expected MSYS `cd` target from the fixture rather than hard + // coding a drive-letter root; appControlLaunchCommand.test.ts pins the + // drive-letter -> MSYS rewrite itself with literal inputs on every host. + const expectedGitBashCd = `cd -- ${shellQuote(gitBashPath(projectRoot))} && `; + expect(createArgs.windowsStartupCommands["git-bash"].slice(0, expectedGitBashCd.length)) + .toBe(expectedGitBashCd); + expect(createArgs.windowsStartupCommands["git-bash"]).not.toContain(String.fromCharCode(92)); expect(createArgs.windowsStartupCommands["git-bash"]).toContain(" && "); expect(createArgs.startupCommand).toBe(createArgs.windowsStartupCommands.powershell); } finally { From bc1a4b9a965c2473ca9e847dc0d15bae92ed22f0 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 13:43:05 -0400 Subject: [PATCH 07/51] fix(windows): source Settings and Account machine copy from the identity helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings and Account still spelled the local machine's name out by hand. The literals happened to read "This computer" here, but nothing tied them to `shared/machineIdentity`, so the next time that name moves these two screens drift again — which is exactly how they missed the macOS-only "This Mac" rename. Compose the badge, the pairing-code note, the phone-tab cross-references, and the unnamed-machine fallback from THIS_MACHINE_NAME instead, and pin the tests to the helper rather than to a copy of its output. The account removal sheet also borrowed the local machine's name for a machine that is never the local one: removal is only reachable from a non-local row's options menu. It now names the machine being removed in the title, and the options button's accessible name falls back to the same "Unnamed computer" label the row itself uses. Based-on: nsxdavid/ADE#999 (cherry picked from commit aeed77d0059aff4e6d4e71e216f9531623b3a106) --- .../components/account/AccountPage.test.tsx | 11 ++++++++-- .../components/account/AccountPage.tsx | 19 +++++++++++++----- .../settings/SyncDevicesSection.test.tsx | 8 ++++++-- .../settings/SyncDevicesSection.tsx | 20 +++++++++++-------- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx index 3a8476695..bba3c4eec 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx @@ -7,6 +7,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom"; import { AccountPage, SignInCard } from "./AccountPage"; import { docs } from "../../onboarding/docsLinks"; import type { AdeAccountMachine, AdeAccountStatus } from "../../../shared/types"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; const beginLogin = vi.fn(async () => undefined); const refreshAccount = vi.fn(async () => SIGNED_OUT); @@ -209,7 +210,10 @@ describe("AccountPage signed-in", () => { const rows = screen.getAllByText(/MacBook Pro|Studio/); expect(rows[0].textContent).toBe("MacBook Pro"); - expect(screen.getByText("This computer")).toBeTruthy(); + // Composed from THIS_MACHINE_NAME, never spelled out: this badge was + // macOS-only copy ("This Mac") until ADE shipped on Windows, and pinning the + // literal here is what let Settings and Account miss the rename. + expect(screen.getByText(THIS_MACHINE_NAME)).toBeTruthy(); // Only the other computer exposes an options (removal) menu. expect(screen.queryByRole("button", { name: /Options for MacBook Pro/ })).toBeNull(); @@ -241,7 +245,10 @@ describe("AccountPage signed-in", () => { fireEvent.click(screen.getByRole("menuitem", { name: /Remove from account/ })); const dialog = screen.getByRole("dialog"); - expect(within(dialog).getByText(/Remove this computer from your account\?/)).toBeTruthy(); + // The sheet names the machine being removed. "This computer" would collide + // with the local-machine badge, and removal never targets the local machine. + expect(within(dialog).getByText(/Remove Studio from your account\?/)).toBeTruthy(); + expect(dialog.textContent).not.toContain(THIS_MACHINE_NAME); fireEvent.click(within(dialog).getByRole("button", { name: "Remove" })); await waitFor(() => expect(removeMachine).toHaveBeenCalledWith("studio-key")); diff --git a/apps/desktop/src/renderer/components/account/AccountPage.tsx b/apps/desktop/src/renderer/components/account/AccountPage.tsx index 3dc2aae8f..ef13b1006 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -21,6 +21,7 @@ import type { GitHubStatus, } from "../../../shared/types"; import { accountMachineDisplayName } from "../../../shared/accountDirectory"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { COLORS, RADII, @@ -609,7 +610,7 @@ function YourMacsCard() { {thisMac ? ( - This computer + {THIS_MACHINE_NAME} ) : null} @@ -631,7 +632,9 @@ function YourMacsCard() { ) : (
@@ -370,7 +374,7 @@ function acceptsConnectionsState( host: boolean, ): { ready: boolean; label: string } { if (!host) { - return { ready: false, label: "This computer connects through your main ADE host" }; + return { ready: false, label: `${THIS_MACHINE_NAME} connects through your main ADE host` }; } if (isCrdtSyncUnavailable(status)) { return { ready: false, label: "Phone sync is unavailable" }; @@ -476,8 +480,8 @@ function PinManagerRemoteNote({ const stateLine = !pinConfigured ? "No pairing code set on this computer yet." : pin - ? `This computer's pairing code is ${pin}.` - : "This computer has a pairing code set."; + ? `${THIS_MACHINE_NAME}'s pairing code is ${pin}.` + : `${THIS_MACHINE_NAME} has a pairing code set.`; return (
Pairing code
@@ -495,7 +499,7 @@ function PinManagerRemoteNote({ // --------------------------------------------------------------------------- // Section title that names which machine a device list belongs to. The list is -// always scoped to this physical Mac; the "on {name}" line only appears when a +// always scoped to this physical machine; the "on {name}" line only appears when a // remote binding could otherwise make the reader assume it is the bound machine. function ScopedListTitle({ title, sync }: { title: string; sync: SyncConnections }) { return ( @@ -553,8 +557,8 @@ function ConnectNewPhone({ status }: { status: SyncRoleSnapshot }) { const pinReadout = status.pairingPin ? status.pairingPin : status.pairingPinConfigured - ? "Pairing code is set — see This computer above" - : "Set a pairing code in This computer above"; + ? `Pairing code is set — see ${THIS_MACHINE_NAME} above` + : `Set a pairing code in ${THIS_MACHINE_NAME} above`; return (
From d96f1de5168da98153c97366d6b9be78f6db2b19 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 13:46:17 -0400 Subject: [PATCH 08/51] ci(windows): run App Control launch-command contracts on Windows appControlLaunchCommand.test.ts holds gitBashPath, the repo's only drive-letter to MSYS conversion, and ran on no Windows runner. Nothing in it is platform-gated today, so this is coverage rather than a validator fix -- but a future win32 gate there would have had no runner to satisfy it. Based-on: nsxdavid/ADE#999 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c87a4799c..36d85d70e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -492,6 +492,7 @@ jobs: - name: Test Windows path, spawn, window, and update contracts run: >- cd apps/desktop && npx vitest run + src/main/services/appControl/appControlLaunchCommand.test.ts src/main/services/appControl/appControlService.test.ts src/main/services/shared/processExecution.test.ts src/main/services/updates/autoUpdateService.test.ts From fce6a185f3b5913f04c5a015ebcc30a9abd86351 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 14:46:39 -0400 Subject: [PATCH 09/51] fix(windows): stop naming the local machine "This Mac" in settings `machineIdentity.THIS_MACHINE_NAME` made the local-machine label platform-neutral, but three settings surfaces kept the macOS literal and rendered it ungated on Windows. `ScopeChip`'s machine scope is the worst of them: it appears on every machine-scoped setting row, and the same object's `affects` line already read "Only this computer", so one object named two different machines. It now sources the label. The signed-out Activity banner promised "the notch settings below still apply to this Mac". Off macOS the notch cards are disabled by `!notchSupported` and everything else is disabled by `signedOut`, so nothing below applies at all; the sentence is dropped there rather than reworded. The popover's notch section header and badge follow `ScopeChip`. They only render on macOS, but they are scope labels for the same rows the settings page chips, and this component exists so the two surfaces cannot say different things about one setting. The group description "the menu bar on this Mac" stays macOS-specific: it is prose about a macOS surface, and "menu bar" already commits it. Its sibling fallback did need fixing. `notchSupported` is false both in the web client and on any non-macOS desktop, and the copy assumed only the first, so a Windows user was told they were running the web client. Based-on: nsxdavid/ADE#999 (cherry picked from commit 30eb87359361f4c920f03ddcb2ff7e36e9b3d04e) --- .../components/settings/ActivitySection.tsx | 14 ++++++++-- .../settings/ActivitySettingsControls.tsx | 26 ++++++++++++++++--- .../settings/primitives/ScopeChip.tsx | 8 ++++-- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/components/settings/ActivitySection.tsx b/apps/desktop/src/renderer/components/settings/ActivitySection.tsx index 0fb883de3..648734b33 100644 --- a/apps/desktop/src/renderer/components/settings/ActivitySection.tsx +++ b/apps/desktop/src/renderer/components/settings/ActivitySection.tsx @@ -31,8 +31,18 @@ export function ActivitySection() { borderRadius: 10, }} > - Sign in to ADE to sync Activity across your machines. The notch settings - below still apply to this Mac. + {/* + The second sentence is a promise about what still works while + signed out, and it is only true where the notch exists: every other + control below is disabled by `busy` (`loading || signedOut`), and + the notch cards are disabled by `!notchSupported`. Off macOS that + leaves nothing on this page that "still applies", so the sentence + is dropped rather than reworded — a Windows user was being pointed + at a section that is inert for them. + */} + {model.notchSupported + ? "Sign in to ADE to sync Activity across your machines. The notch settings below still apply to this computer." + : "Sign in to ADE to sync Activity across your machines."}
) : null} diff --git a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx index 2a0962ea5..bf2e40308 100644 --- a/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx +++ b/apps/desktop/src/renderer/components/settings/ActivitySettingsControls.tsx @@ -17,6 +17,7 @@ import { type AttentionNotchRevealMode, type AttentionPreferences, } from "../../../shared/types"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; import { activityNotchSupported, activityNotchSettingsFromPreferences, @@ -387,12 +388,21 @@ export function ActivitySettingsControls({ <> {notchSupported ? (
-

This Mac

+ {/* + These two are scope labels, not macOS prose: the page variant + renders the very same notch row with ``, + and this component exists so the two surfaces cannot say different + things about one setting. A badge reading "This Mac" beside a chip + reading "This computer" would be two names for one machine. The + surrounding section is macOS-only, but the *scope* is not a + platform claim, so it follows `THIS_MACHINE_NAME`. + */} +

{THIS_MACHINE_NAME}

= { affects: "Everyone who works in this repository, once you push.", }, machine: { - label: "This Mac", + // Sourced, never spelled out. The chip's own `affects` line has always said + // "Only this computer"; a hardcoded "This Mac" here made one object claim + // two different machines, and lied outright on Windows. + label: THIS_MACHINE_NAME, color: COLORS.warning, storedIn: ".ade/local.yaml and ADE's local services — gitignored", affects: "Only this computer. Nothing here is shared or committed.", @@ -44,7 +48,7 @@ export function ScopeChip({ scope, remoteMachineName }: { scope: SettingScope; r const copy = SCOPE_COPY[scope]; // A machine-scoped setting viewed through the remote banner writes to *that* - // machine, not this one. Saying "This Mac" there would be a lie. + // machine, not this one. Naming the local machine there would be a lie. const isRemote = scope !== "team" && !!remoteMachineName?.trim(); const label = isRemote ? remoteMachineName!.trim() : copy.label; From ba9033b47cf508108103f6ca3ef5f5db4c817a49 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 21:13:45 -0400 Subject: [PATCH 10/51] fix(windows): stop failing DPAPI when PowerShell is slow to start The DPAPI helper gave a Windows PowerShell 5.1 cold start a 5s budget and reported anything slower as "Windows DPAPI credential protection is unavailable" - a hard credential failure. DPAPI is a local sub-millisecond call, so that budget bought process startup: CLR load, System.Security from disk, and Defender's on-access scan of powershell.exe and each assembly the first time they are touched. On a contended machine that runs past 5s. CI shows the shape exactly. In one batch the same job on PR1 ran the file in 2685ms and on PR5 in 3228ms, while PR4 - same 24 tests, same code as PR5 - took 9636ms and lost the first protect() spawn to the deadline. Nothing about PR4 differs; it drew a slower runner. Give the helper a bound that reflects what it is waiting for, and report a deadline separately from an unavailable helper so the sync path stops diagnosing a busy machine as a broken one, matching the async path. Based-on: nsxdavid/ADE#999 (cherry picked from commit d8a15f551a33a594988dbc69805cbde2498a8a95) --- .../credentials/windowsDpapiMaterial.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts index 9d3a2e44a..83e760406 100644 --- a/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts +++ b/apps/ade-cli/src/services/credentials/windowsDpapiMaterial.ts @@ -5,7 +5,19 @@ import path from "node:path"; const WINDOWS_DPAPI_KEY_FILE = ".credential-key.dpapi"; const WINDOWS_DPAPI_KEY_MAGIC = "ADE_WINDOWS_DPAPI_KEY_V1"; -const WINDOWS_DPAPI_TIMEOUT_MS = 5_000; +/** + * DPAPI itself is a local, sub-millisecond call; essentially the whole budget + * pays for a Windows PowerShell 5.1 cold start. That start is not bounded by + * anything ADE controls - it loads the CLR and the System.Security assembly + * from disk, and Defender's on-access scanner inspects powershell.exe and each + * assembly the first time they are touched. On a contended machine (a CI + * runner, or a laptop right after login) it routinely runs several seconds, + * which a 5s budget turned into a hard "credentials are unavailable" failure + * for a helper that had done nothing wrong. Bound the helper generously + * instead: waiting longer only costs time in the case that was already broken, + * while a tight bound costs the user their credentials. + */ +const WINDOWS_DPAPI_TIMEOUT_MS = 30_000; const WINDOWS_DPAPI_MAX_OUTPUT_BYTES = 64 * 1024; const WINDOWS_DPAPI_POWERSHELL_KERNEL_PATH = "\\\\?\\GLOBALROOT\\SystemRoot\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"; @@ -127,6 +139,13 @@ function runDpapiSync(operation: "protect" | "unprotect", value: Buffer): Buffer windowsHide: true, }); if (result.error) { + // spawnSync folds "could not start" and "ran past the deadline" into the + // same field. They are different diagnoses - one means the helper is + // missing or blocked, the other means the machine was busy - and the async + // path already reports them apart. + if (isNodeErrorCode(result.error, "ETIMEDOUT")) { + throw new Error("Windows DPAPI credential protection timed out."); + } throw new Error("Windows DPAPI credential protection is unavailable."); } if (result.status !== 0) { From f7575bf23f8f1b8edf1668810a64dbbf6d158741 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Sun, 2 Aug 2026 22:15:42 -0400 Subject: [PATCH 11/51] fix(windows): cache DPAPI key material instead of respawning PowerShell macOS reads its keychain material once per process and caches it. The win32 branch returned above that cache, so every credential read spawned a fresh `powershell.exe` -- and unlike `security`, which is a small native binary, PowerShell 5.1 pays CLR load, System.Security from disk, and Defender's on-access scan every time. Concurrent reads each spawned their own, which is what made a cold start slow enough to hit the timeout that was raised in the previous commit; raising it treated the symptom. The cache is keyed by resolved secrets directory, which is where this differs from macOS: keychain material is one global item, but DPAPI material is protected per directory (`/.credential-key.dpapi`). A single shared slot would hand one store another store's key -- the account-binding test catches exactly that, and caught it here. In-flight dedup is included for the same reason it exists on macOS: without it, concurrent first reads race and each pays the spawn. The negative cache is deliberately not extended to Windows. A locked keychain is a durable state worth backing off from, but a DPAPI failure is usually a transient timeout, and suppressing retries would make one slow cold start look like permanently unavailable credentials. Based-on: nsxdavid/ADE#999 --- .../services/credentials/credentialStore.ts | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/ade-cli/src/services/credentials/credentialStore.ts b/apps/ade-cli/src/services/credentials/credentialStore.ts index c124c55d4..73329399d 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.ts @@ -61,6 +61,10 @@ const CREDENTIAL_CHANGE_POLL_INTERVAL_MS = 250; const MACOS_KEYCHAIN_READ_TIMEOUT_MS = 2_000; const MACOS_KEYCHAIN_NEGATIVE_CACHE_MS = 30_000; let cachedDefaultOsBoundKeyMaterial: Buffer | null = null; +// Keyed by resolved secrets directory: DPAPI material is protected per +// directory, so unlike the single macOS keychain item these cannot share a slot. +const windowsDpapiMaterialCache = new Map(); +const windowsDpapiReadInFlight = new Map>(); let defaultOsBoundKeyMaterialReadInFlight: Promise | null = null; let lastMissingDefaultOsBoundKeyMaterialAt = 0; @@ -609,7 +613,21 @@ function readDefaultOsBoundKeyMaterial(secretsDir: string): Buffer | null { if (process.env.ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING === "1") return null; if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") return null; if (process.platform === "win32") { - return readOrCreateWindowsDpapiMaterial(secretsDir); + // Windows re-spawned `powershell.exe` on every credential read, where macOS + // spawns `security` once and caches. That is a far worse trade than it + // looks: PowerShell 5.1 pays CLR load, System.Security from disk, and + // Defender's on-access scan each time. + // + // The cache must be keyed by directory, unlike macOS. Keychain material is + // one global item, but DPAPI material is protected per secrets directory + // (`/.credential-key.dpapi`), so a single shared slot would + // hand one store another store's key. + const key = path.resolve(secretsDir); + const cached = windowsDpapiMaterialCache.get(key); + if (cached) return cached; + const material = readOrCreateWindowsDpapiMaterial(secretsDir); + if (material) windowsDpapiMaterialCache.set(key, material); + return material; } if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial; const material = readOrCreateMacKeychainMaterial(); @@ -626,7 +644,29 @@ async function readDefaultOsBoundKeyMaterialAsync(secretsDir: string): Promise { + if (material) windowsDpapiMaterialCache.set(key, material); + return material; + }); + windowsDpapiReadInFlight.set(key, inFlight); + try { + return await inFlight; + } finally { + if (windowsDpapiReadInFlight.get(key) === inFlight) { + windowsDpapiReadInFlight.delete(key); + } + } } if (cachedDefaultOsBoundKeyMaterial) return cachedDefaultOsBoundKeyMaterial; if ( From 816f29d3a853351fce015666cc0c50585df7e160 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:22:10 -0400 Subject: [PATCH 12/51] fix(windows): resolve provider CLIs the way Windows actually resolves them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADE reported every agent CLI as installed on Windows. `commandExists` decided "installed" from the exit code of ` --version`, but `spawnAsync` routes an extension-less command through `cmd.exe /d /s /c "…"`, and cmd.exe always starts: a missing binary comes back as exit 1 with "is not recognized as an internal or external command", never as the ENOENT spawn error (`status === null`) that means "missing" on macOS. Probed on this machine with no Claude Code, cursor-agent or droid installed, all three reported exit 1 and were recorded as installed with a bare `claude`/`droid` path — which then flowed into DetectedAuth and on into `pathToClaudeCodeExecutable`, so Settings advertised a runtime the chat lane could not spawn. Detection and execution now share one answer: `resolveCommandLocation` returns the executable file or null, `installed` is exactly "that file exists", and the reported path is exactly what gets launched. Executable resolution itself assumed a Unix layout. `npm i -g` writes three shims side by side — `codex` (a `#!/bin/sh` script for Git Bash), `codex.cmd` and `codex.ps1` — and `resolveFromDirs` probed the bare name first, so it handed callers the sh script. ADE's own spawns survived because the cmd.exe wrapper re-applies PATHEXT, but anything spawning the resolved path directly (Claude Agent SDK, node-pty, provider SDKs) gets ENOENT. Windows now resolves through PATHEXT only, with `.ps1` as a last resort, executed via PowerShell since cmd.exe cannot run it. Known install dirs gained the generic `%LOCALAPPDATA%\Programs\` and `%ProgramFiles%\` layouts alongside `%APPDATA%\npm`, `%USERPROFILE%\.local\bin` and the WinGet Links dir, which are the documented Windows homes for these CLIs. Session discovery resolved `$HOME` before `%USERPROFILE%`; under Git Bash that is `/c/Users/`, a path no `fs` call can open, so every provider's session directory silently came back empty. Its POSIX-only command quoter now defers to the shared platform-aware one. Based-on: nsxdavid/ADE#999 --- .../src/main/services/ai/authDetector.ts | 99 ++++++++++--------- .../services/ai/cliExecutableResolver.test.ts | 33 +++++-- .../main/services/ai/cliExecutableResolver.ts | 88 ++++++++++++++--- .../externalSessions/discoveryUtils.ts | 23 ++++- .../main/services/shared/processExecution.ts | 20 ++++ 5 files changed, 188 insertions(+), 75 deletions(-) diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 631c1f63b..008b1f7e5 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -145,59 +145,65 @@ function findExplicitCommandPath(command: string): string | null { return resolveExecutableFromKnownLocations(command)?.path ?? null; } -async function commandExists(command: string): Promise { +/** + * Resolve where a CLI actually lives, or null when it is not installed. + * + * One function answers both "is it installed" and "what do we launch", so the + * Settings card can never advertise a provider the chat runtime cannot spawn: + * `installed` is exactly "this file exists" and `path` is exactly that file, + * which is what flows into {@link DetectedAuth} and on into + * `resolveClaudeCodeExecutable`/`resolveDroidExecutable`. + * + * Windows: never probe by exit code. `spawnAsync` routes extension-less + * commands through `cmd.exe /d /s /c "…"` (see `resolveCliSpawnInvocation`), + * and cmd.exe itself always starts — a missing binary comes back as exit 1 + * with `'claude' is not recognized as an internal or external command`, not as + * the ENOENT spawn error (`status === null`) that means "missing" on + * macOS/Linux. An exit-code probe therefore reports *every* CLI as installed + * on Windows. `where` + the known-install-dir scan (which honours PATHEXT) + * answer the question honestly. + */ +async function resolveCommandLocation(command: string): Promise { const explicitPath = findExplicitCommandPath(command); - if (explicitPath) return true; + if (explicitPath) return explicitPath; - // Strategy 1: Direct spawn — bypasses shell init (.zshrc errors, slow profiles). - // If the binary exists, --version will produce *some* exit code. - // A spawn error (ENOENT) means the binary isn't on PATH → status is null. - try { - const direct = await spawnAsync(command, ["--version"], { timeout: 5_000 }); - if (direct.status !== null) return true; - } catch { - // fall through to shell-based check + if (process.platform === "win32") { + try { + const result = await spawnAsync("where", [command], { timeout: 5_000 }); + if (result.status === 0) { + const first = (result.stdout ?? "").trim().split(/\r?\n/)[0]?.trim(); + if (first) return first; + } + } catch { + // Treat a failed lookup as "not installed" rather than guessing. + } + return null; } - // Strategy 2: Shell-based lookup (fallback for edge cases) + // POSIX: a direct spawn bypasses shell init (.zshrc errors, slow profiles), + // and here ENOENT really does surface as `status === null`. try { - if (process.platform === "win32") { - const result = await spawnAsync("where", [command], { timeout: 5_000 }); - return result.status === 0; + const direct = await spawnAsync(command, ["--version"], { timeout: 5_000 }); + if (direct.status !== null) { + const which = await spawnAsync("which", [command], { timeout: 3_000 }); + const line = which.status === 0 ? (which.stdout ?? "").trim() : ""; + return line || command; } - const result = await spawnAsync(getLookupShell(), ["-lc", 'command -v "$1" >/dev/null 2>&1', "--", command], { timeout: 5_000 }); - return result.status === 0; } catch { - // fall through to explicit common-path lookup + // fall through to shell-based lookup } - return explicitPath != null; -} - -async function commandPath(command: string): Promise { try { - if (process.platform === "win32") { - const result = await spawnAsync("where", [command], { timeout: 5_000 }); - if (result.status === 0 && result.stdout?.trim()) { - return result.stdout.trim().split(/\r?\n/)[0] ?? command; - } - return findExplicitCommandPath(command) ?? command; - } - // Try which first (simpler, doesn't load full login shell) - const which = await spawnAsync("which", [command], { timeout: 3_000 }); - if (which.status === 0 && which.stdout?.trim()) { - return which.stdout.trim(); - } - const explicitPath = findExplicitCommandPath(command); - if (explicitPath) { - return explicitPath; - } - // Fallback to login shell lookup const result = await spawnAsync(getLookupShell(), ["-lc", 'command -v "$1"', "--", command], { timeout: 5_000 }); - return result.stdout?.trim() || command; + if (result.status === 0) { + const line = (result.stdout ?? "").trim(); + if (line) return line; + } } catch { - return findExplicitCommandPath(command) ?? command; + // Not installed. } + + return null; } async function refreshProcessPathFromShell(): Promise { @@ -1109,15 +1115,16 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut const statuses = await Promise.all( cliChecks.map(async (cli) => { let spawnName = cliSpawnCommand(cli); - let installed = false; + let path: string | null = null; for (const candidate of cliSpawnCommands(cli)) { - if (await commandExists(candidate)) { + const location = await resolveCommandLocation(candidate); + if (location) { spawnName = candidate; - installed = true; + path = location; break; } } - const path = installed ? await commandPath(spawnName) : null; + const installed = path !== null; const cmd = path ?? spawnName; if (!installed) { return { @@ -1150,8 +1157,8 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut }; } if (cli === "droid") { - // Prefer the path we already proved via commandPath() above; only fall - // back to resolveDroidExecutable() when commandPath() failed. + // Prefer the path we already proved via resolveCommandLocation() above; + // only fall back to resolveDroidExecutable() when that failed. let droidPath: string; if (path) { droidPath = path; diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts index d4788d0ea..33d8cc732 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts @@ -14,6 +14,15 @@ import { const originalPlatform = process.platform; const originalPathDelimiter = path.delimiter; +/** + * Windows cannot execute an extension-less file, so a fixture that stands in + * for an installed CLI has to carry a PATHEXT extension there — the same shape + * `npm i -g` produces (`codex.cmd` next to the `#!/bin/sh` `codex`). + */ +function executableFileName(command: string): string { + return process.platform === "win32" ? `${command}.cmd` : command; +} + function makeExecutable(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, "#!/bin/sh\nexit 0\n", "utf8"); @@ -55,7 +64,7 @@ describe("cliExecutableResolver", () => { tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); const homeDir = path.join(tempRoot, "home"); const prefixDir = path.join(homeDir, ".npm-global"); - makeExecutable(path.join(prefixDir, "bin", "codex")); + makeExecutable(path.join(prefixDir, "bin", executableFileName("codex"))); fs.mkdirSync(homeDir, { recursive: true }); fs.writeFileSync(path.join(homeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8"); @@ -79,7 +88,7 @@ describe("cliExecutableResolver", () => { }; expect(resolveExecutableFromKnownLocations("codex", env)).toEqual({ - path: path.join(prefixDir, "bin", "codex"), + path: path.join(prefixDir, "bin", executableFileName("codex")), source: "known-dir", }); }); @@ -99,6 +108,10 @@ describe("cliExecutableResolver", () => { }); it("keeps both Intel and Apple Silicon Homebrew bins on PATH", () => { + // Homebrew is a macOS layout claim, and PATH parsing is delimiter-sensitive, + // so pin the platform instead of inheriting the host's. + setPlatform("darwin"); + setPathDelimiter(":"); const nextPath = augmentPathWithKnownCliDirs("/usr/local/bin:/usr/bin:/bin", { HOME: "/tmp/ade-home", PATH: "/usr/local/bin:/usr/bin:/bin", @@ -115,9 +128,9 @@ describe("cliExecutableResolver", () => { const firstBin = path.join(tempRoot, "first"); const secondBin = path.join(tempRoot, "second"); const knownBin = path.join(homeDir, ".local", "bin"); - makeExecutable(path.join(firstBin, "git")); - makeExecutable(path.join(secondBin, "git")); - makeExecutable(path.join(knownBin, "git")); + makeExecutable(path.join(firstBin, executableFileName("git"))); + makeExecutable(path.join(secondBin, executableFileName("git"))); + makeExecutable(path.join(knownBin, executableFileName("git"))); const realStatSync = fs.statSync; vi.spyOn(fs, "statSync").mockImplementation(((p: fs.PathLike, opts?: any) => { @@ -136,9 +149,9 @@ describe("cliExecutableResolver", () => { }); expect(candidates.slice(0, 3)).toEqual([ - { path: path.join(firstBin, "git"), source: "path" }, - { path: path.join(secondBin, "git"), source: "path" }, - { path: path.join(knownBin, "git"), source: "known-dir" }, + { path: path.join(firstBin, executableFileName("git")), source: "path" }, + { path: path.join(secondBin, executableFileName("git")), source: "path" }, + { path: path.join(knownBin, executableFileName("git")), source: "known-dir" }, ]); }); @@ -234,7 +247,9 @@ describe("cliExecutableResolver", () => { USERPROFILE: userProfile, PATH: "C:\\Windows\\System32", })).toEqual({ - path: path.join(scoopShims, "codex.CMD"), + // statSync is stubbed and the directory does not exist, so the resolver + // cannot read the real on-disk spelling and reports the probed name. + path: path.join(scoopShims, "codex.cmd"), source: "known-dir", }); }); diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index 35188885e..c4ae7809a 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -155,7 +155,25 @@ function getWindowsKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string const asdfDataDir = env.ASDF_DATA_DIR?.trim(); return uniqueNonEmpty([ + // `npm i -g` writes `.cmd` / `.ps1` shims straight into %APPDATA%\npm. appData ? path.join(appData, "npm") : "", + // Standalone/native installers put per-tool binaries under %LOCALAPPDATA%\Programs + // or %ProgramFiles%, either directly in the tool directory or in its `bin`. + // Claude Code's Windows installer instead uses %USERPROFILE%\.local\bin + // (`claude.exe`), and WinGet publishes shims into the WinGet\Links dir — both + // are listed below. + ...(localAppData + ? [ + path.join(localAppData, "Programs", command), + path.join(localAppData, "Programs", command, "bin"), + ] + : []), + ...(programFiles + ? [ + path.join(programFiles, command), + path.join(programFiles, command, "bin"), + ] + : []), localAppData ? path.join(localAppData, "Programs", "cursor", "resources", "app", "bin") : "", localAppData ? path.join(localAppData, "Programs", "Microsoft VS Code", "bin") : "", localAppData ? path.join(localAppData, "Microsoft", "WinGet", "Links") : "", @@ -247,27 +265,69 @@ function isExecutableFile(candidatePath: string): boolean { } } +/** Windows launcher extensions, in the order Windows itself would try them. */ +export function windowsExecutableExtensions(env: NodeJS.ProcessEnv = process.env): string[] { + // PATHEXT is conventionally uppercase while the files on disk are lowercase + // (`claude.exe`, `codex.cmd`). Normalize so resolved paths match the real + // filename; NTFS lookups are case-insensitive either way. + const pathext = uniqueNonEmpty((env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")) + .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`).toLowerCase()); + // PATHEXT never lists .PS1 (PowerShell resolves scripts itself), but a + // PowerShell-only shim is still a real, launchable install. Try it last so a + // .exe/.cmd sibling always wins — those run under cmd.exe, .ps1 does not. + if (!pathext.some((ext) => ext.toLowerCase() === ".ps1")) pathext.push(".ps1"); + return pathext; +} + +/** + * NTFS lookups ignore case, so a probe for `codex.cmd` succeeds against a file + * actually named `codex.CMD` and vice versa. The resolved path is surfaced in + * Settings and handed to other tools, so report the name as it is spelled on + * disk instead of however PATHEXT happened to be cased. + */ +function withOnDiskCasing(candidatePath: string): string { + if (process.platform !== "win32") return candidatePath; + const dir = path.dirname(candidatePath); + const base = path.basename(candidatePath); + try { + const actual = fs.readdirSync(dir).find((entry) => entry.toLowerCase() === base.toLowerCase()); + return actual ? path.join(dir, actual) : candidatePath; + } catch { + return candidatePath; + } +} + function resolveFromDirs( command: string, dirs: Iterable, env: NodeJS.ProcessEnv = process.env, ): string | null { - const pathext = process.platform === "win32" - ? uniqueNonEmpty((env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")) - .flatMap((ext) => [ext, ext.toLowerCase(), ext.toUpperCase()]) - : []; const commandHasExtension = path.extname(command).length > 0; + const extensions = process.platform === "win32" && !commandHasExtension + ? windowsExecutableExtensions(env) + : []; for (const dir of dirs) { - const candidatePaths = [path.join(dir, command)]; - if (process.platform === "win32" && !commandHasExtension) { - for (const ext of pathext) { - candidatePaths.push(path.join(dir, `${command}${ext}`)); - } - } + // Windows cannot execute an extension-less file. `npm i -g` drops three + // shims side by side — `codex` (a `#!/bin/sh` script for Git Bash), + // `codex.cmd` and `codex.ps1` — and only the latter two are launchable + // here. Trying `path.join(dir, command)` first therefore handed callers the + // sh script: ADE's own spawns survived it because `resolveCliSpawnInvocation` + // wraps extension-less commands in `cmd.exe`, which re-applies PATHEXT, but + // every consumer that spawns the resolved path directly (the Claude Agent + // SDK via `pathToClaudeCodeExecutable`, node-pty, provider SDKs) gets ENOENT. + // Resolve the way Windows does: PATHEXT only. On other platforms the bare + // name is the executable. + const candidatePaths = extensions.length > 0 + // Uppercase second, for the rare case-sensitive Windows directory. + ? extensions.flatMap((ext) => [ + path.join(dir, `${command}${ext}`), + path.join(dir, `${command}${ext.toUpperCase()}`), + ]) + : [path.join(dir, command)]; for (const candidatePath of candidatePaths) { - if (isExecutableFile(candidatePath)) return candidatePath; + if (isExecutableFile(candidatePath)) return withOnDiskCasing(candidatePath); } } return null; @@ -288,10 +348,8 @@ export function augmentPathWithKnownCliDirs( ): string { return mergePathEntries( pathValue, - getKnownBinDirs("claude", env).join(pathListDelimiter()), - getKnownBinDirs("codex", env).join(pathListDelimiter()), - getKnownBinDirs("agent", env).join(pathListDelimiter()), - getKnownBinDirs("opencode", env).join(pathListDelimiter()), + ...["claude", "codex", "agent", "cursor-agent", "droid", "opencode"].map((command) => + getKnownBinDirs(command, env).join(pathListDelimiter())), ); } diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts index 9a31d1242..49f16b85c 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts @@ -7,6 +7,7 @@ import type { ExternalSessionSummary, } from "../../../shared/types/externalSessions"; import type { TerminalResumeLaunchConfig } from "../../../shared/types/sessions"; +import { commandArrayToLine as sharedCommandArrayToLine } from "../../../shared/shell"; export type ExternalSessionDiscoveryRecord = Omit< ExternalSessionSummary, @@ -70,6 +71,17 @@ export function resolveHomeDir(args?: Pick`, which no + // `fs` call can resolve — so every session directory built from it silently + // came back empty. `%USERPROFILE%` is the real home, and it is what the + // provider CLIs themselves write under (`%USERPROFILE%\.claude`, + // `%USERPROFILE%\.codex`). Matches getHomeDir() in cliExecutableResolver. + const profile = typeof env.USERPROFILE === "string" ? env.USERPROFILE.trim() : ""; + if (profile) return profile; + return path.win32.isAbsolute(envHome) ? envHome : os.homedir(); + } return envHome || os.homedir(); } @@ -612,12 +624,13 @@ export function isUuidLike(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value); } +/** + * POSIX single-quoting produces a command line cmd.exe and PowerShell both + * misread, so defer to the shared platform-aware quoter rather than keeping a + * second, Unix-only copy here. + */ export function commandArrayToLine(parts: string[]): string { - return parts.map((part) => { - if (!part.length) return "''"; - if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(part)) return part; - return `'${part.replace(/'/gu, "'\\''")}'`; - }).join(" "); + return sharedCommandArrayToLine(parts); } export function directShellLaunchForCommandLine(commandLine: string): { command?: string; args?: string[] } { diff --git a/apps/desktop/src/main/services/shared/processExecution.ts b/apps/desktop/src/main/services/shared/processExecution.ts index dc31eb52f..84d04c189 100644 --- a/apps/desktop/src/main/services/shared/processExecution.ts +++ b/apps/desktop/src/main/services/shared/processExecution.ts @@ -79,12 +79,32 @@ export function resolveWindowsCmdLineInvocation( }; } +export function isWindowsPowerShellScript(command: string, platform: NodeJS.Platform = process.platform): boolean { + return platform === "win32" && path.win32.extname(command).toLowerCase() === ".ps1"; +} + +/** + * `.ps1` shims (npm writes one next to every `.cmd`) cannot be launched by + * cmd.exe or by `spawn` directly — PowerShell has to interpret them. `-File` + * keeps the remaining arguments literal rather than re-parsing them as script. + */ +export function resolveWindowsPowerShellInvocation(command: string, args: string[]): SpawnInvocation { + return { + command: "powershell.exe", + args: ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", command, ...args], + windowsVerbatimArguments: false, + }; +} + export function resolveCliSpawnInvocation( command: string, args: string[], env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): SpawnInvocation { + if (isWindowsPowerShellScript(command, platform)) { + return resolveWindowsPowerShellInvocation(command, args); + } if (shouldUseWindowsCmdWrapper(command, platform)) { return resolveWindowsCmdInvocation(command, args, env); } From e4c6660da0c80f4cd737158837da46fb643a40ef Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:23:31 -0400 Subject: [PATCH 13/51] fix(windows): close the Cursor lane guard for backslash paths The Cursor hard guard only recognised POSIX path shapes, so on Windows a shell tool call written the way Windows shells actually write paths -- `..\..\.ssh\id_rsa`, `.\..\secret.txt`, `%USERPROFILE%\.ssh\id_rsa`, `$env:USERPROFILE\.aws\credentials` -- never produced a path candidate and fell through to "ask" instead of "deny". `.ade\secrets\token` slipped the protected-path check the same way. The POSIX spellings of all five are denied on macOS, so this was a Windows-only hole in the lane containment and secrets guard. Teach `looksLikePathToken` the Windows separator and `%VAR%` / `$env:VAR` home prefixes (win32-gated, since `\` is a legal filename character on POSIX), and expand those prefixes in `resolveCandidatePath` the way `$HOME/` already is. Also fix the pre-existing Windows failure in the policy suite: the transcript-read test hard-coded a POSIX lane root, and `path.resolve` prefixes the current drive on Windows, so the expected slug never matched. Based-on: nsxdavid/ADE#999 --- .../services/chat/cursorSdkPolicy.test.ts | 42 +++++++++++++++++-- .../src/main/services/chat/cursorSdkPolicy.ts | 30 +++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts index 0f9c62ae9..daca89399 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts @@ -158,6 +158,34 @@ describe("Cursor SDK policy", () => { expect(request.reason).toContain("/etc"); }); + it.runIf(process.platform === "win32")("denies Windows-shell lane escapes written with backslashes or %VAR% expansion", () => { + const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); + const laneRoot = path.join(path.parse(path.resolve("/")).root, "Users", "admin", "lane"); + const userHomeDir = path.join(path.parse(path.resolve("/")).root, "Users", "admin"); + const cases: Array<[string, string]> = [ + ["type ..\\..\\..\\.ssh\\id_rsa", "outside the active lane"], + ["type .\\..\\..\\secret.txt", "outside the active lane"], + ["type %USERPROFILE%\\.ssh\\id_rsa", "outside the active lane"], + ["Get-Content $env:USERPROFILE\\.aws\\credentials", "outside the active lane"], + ["type .ade\\secrets\\token", "protected by ADE"], + ]; + for (const [command, reason] of cases) { + const request = summarizeCursorHook({ toolName: "shell", toolInput: { command } }, laneRoot); + expect(evaluateCursorSdkHook({ request, policy, laneRoot, userHomeDir })).toBe("deny"); + expect(request.reason).toContain(reason); + } + }); + + it("keeps POSIX-style backslash filenames out of the Windows path heuristics", () => { + const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); + const laneRoot = path.join(path.parse(path.resolve("/")).root, "tmp", "ade-lane"); + const request = summarizeCursorHook({ + toolName: "shell", + toolInput: { command: "echo hello" }, + }, laneRoot); + expect(evaluateCursorSdkHook({ request, policy, laneRoot })).toBe("allow"); + }); + it("denies shell cwd escapes even when the command text is otherwise safe", () => { const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); const laneRoot = "/tmp/ade-lane"; @@ -170,10 +198,18 @@ describe("Cursor SDK policy", () => { it("allows Cursor SDK transcript and terminal reads for the active lane only", () => { const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); - const laneRoot = "/Users/admin/Projects/Versic/.ade/worktrees/private-sharing-5d14c47a"; - const userHomeDir = "/Users/admin"; + // Build the lane root from the platform's own filesystem root: on Windows + // `path.resolve` prefixes the current drive, so a hard-coded POSIX path + // yields a different (drive-prefixed) slug there. + const fsRoot = path.parse(path.resolve("/")).root; + const userHomeDir = path.join(fsRoot, "Users", "admin"); + const laneRoot = path.join(userHomeDir, "Projects", "Versic", ".ade", "worktrees", "private-sharing-5d14c47a"); const slug = cursorProjectSlugForPath(laneRoot); - expect(slug).toBe("Users-admin-Projects-Versic-ade-worktrees-private-sharing-5d14c47a"); + const expectedSlug = [ + ...fsRoot.split(/[\\/]+/u).filter(Boolean).map((part) => part.replace(/[^A-Za-z0-9_-]+/gu, "")), + "Users-admin-Projects-Versic-ade-worktrees-private-sharing-5d14c47a", + ].filter(Boolean).join("-"); + expect(slug).toBe(expectedSlug); const transcript = summarizeCursorHook({ toolName: "read", diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts index c193bbca6..87ad44672 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts @@ -231,6 +231,27 @@ function trimShellToken(token: string): string { return token.trim().replace(/^[=:,]+|[,:]+$/g, ""); } +/** + * Windows shells use `\` as the path separator and `%VAR%` / `$env:VAR` for + * expansion, so the POSIX-only token shapes below never match a Windows command + * line. Backslash handling stays win32-gated because `\` is a legal filename + * character (and a shell escape) on POSIX. + */ +const WINDOWS_HOME_PREFIX = /^(?:%(?:USERPROFILE|HOME)%|\$env:(?:USERPROFILE|HOME)|\$\{?env:(?:USERPROFILE|HOME)\}?)[\\/]/iu; + +function isWindowsPathLikeToken(cleaned: string): boolean { + if (WINDOWS_HOME_PREFIX.test(cleaned)) return true; + if ( + cleaned.startsWith("..\\") + || cleaned.startsWith(".\\") + || cleaned.startsWith("\\") + ) return true; + if (!cleaned.includes("\\")) return false; + // `FOO=bar\baz` is an assignment, not a path argument. + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(cleaned)) return false; + return true; +} + function looksLikePathToken(token: string): boolean { const cleaned = trimShellToken(token); if (!cleaned || cleaned.startsWith("-") || cleaned.includes("://")) return false; @@ -245,6 +266,7 @@ function looksLikePathToken(token: string): boolean { || cleaned.startsWith("$HOME/") || cleaned.startsWith("${HOME}/") ) return true; + if (process.platform === "win32" && isWindowsPathLikeToken(cleaned)) return true; if (!cleaned.includes("/")) return false; if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(cleaned)) return false; if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(cleaned)) return false; @@ -327,6 +349,14 @@ function isWithinPath(root: string, candidate: string): boolean { } function resolveCandidatePath(candidate: string, cwd: string, userHomeDir?: string | null): string { + // Windows equivalents of `$HOME/…`. Without this the guard resolves + // `%USERPROFILE%\.ssh\id_rsa` literally under the lane root and lets it pass. + if (process.platform === "win32" && userHomeDir?.trim()) { + const windowsHome = WINDOWS_HOME_PREFIX.exec(candidate); + if (windowsHome) { + return path.resolve(userHomeDir, candidate.slice(windowsHome[0].length)); + } + } if (candidate === "~" && userHomeDir?.trim()) return path.resolve(userHomeDir); if (candidate.startsWith("~/") && userHomeDir?.trim()) { return path.resolve(userHomeDir, candidate.slice(2)); From 2ec0719bd4de957b0da4f9d7887bec8fded458fc Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:27:22 -0400 Subject: [PATCH 14/51] fix(windows): drop the drive prefix from Cursor project slugs `cursorProjectSlugForCwd` hyphenated every separator, so on Windows `C:\Users\me\repo` became `C:-Users-me-repo`. That string can never equal a real directory name -- `mkdir` on it fails with EINVAL -- and `resolveCursorCwdFromSlug` returns null for it, while the drive-less `Users-me-repo` round-trips back to the real path. The structural slug comparison in Cursor CLI session discovery was therefore dead on Windows. Visible effect: a Cursor session whose workspace directory no longer exists is dropped entirely on Windows, because the slug-to-cwd resolver cannot help once the directory is gone and the slug comparison is all that is left. macOS has no drive prefix and keeps the session. The two Cursor cases in the external-session suites built their fixture slugs with POSIX separators only and failed on Windows before this change; they now derive the slug the same way production does. Based-on: nsxdavid/ADE#999 --- .../externalSessions/discoverCursor.test.ts | 68 +++++++++++++++++++ .../externalSessions/discoverCursor.ts | 9 ++- .../discoverProviders.test.ts | 4 +- .../externalSessions/discoveryUtils.test.ts | 5 +- 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts diff --git a/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts b/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts new file mode 100644 index 000000000..577c6169a --- /dev/null +++ b/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { discoverCursorSessions } from "./discoverCursor"; + +/** + * Mirrors `cursorProjectSlugForCwd` in discoverCursor.ts. A Windows drive + * prefix has to be dropped rather than hyphenated, because `C:-Users-…` + * contains a colon and can therefore never be a real directory name. + */ +function cursorProjectSlug(cwd: string): string { + return cwd + .replace(/^([A-Za-z]):[\\/]+/u, "") + .replace(/^[/\\]+/u, "") + .replace(/[\\/]/gu, "-"); +} + +function writeTranscript(home: string, slug: string, agentId: string, cwd: string): void { + const dir = path.join(home, ".cursor", "projects", slug, "agent-transcripts", agentId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, `${agentId}.jsonl`), + `${JSON.stringify({ + type: "user", + timestamp: 1_700_000_000_000, + cwd, + message: { role: "user", content: "hello" }, + })}\n`, + ); +} + +describe("discoverCursorSessions", () => { + it("imports a transcript for an existing scoped workspace", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-import-")); + const home = path.join(root, "home"); + const workspace = path.join(root, "repo"); + fs.mkdirSync(workspace, { recursive: true }); + writeTranscript(home, cursorProjectSlug(workspace), "chat-existing", workspace); + + try { + const records = await discoverCursorSessions({ homeDir: home, scopeRoots: [workspace], limit: 10 }); + expect(records.map((record) => record.id)).toContain("chat-existing"); + expect(records[0]?.cwd).toBe(workspace); + expect(records[0]?.preview).toBe("hello"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("keeps a transcript in scope when its workspace directory no longer exists", async () => { + // Without the drive-prefix fix this drops the session on Windows: the + // slug-to-cwd resolver cannot help once the directory is gone, so the + // structural slug comparison is the only thing left, and it can never + // match a slug containing `C:`. macOS has no drive prefix and passes. + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-import-gone-")); + const home = path.join(root, "home"); + const workspace = path.join(root, "deleted-repo"); + writeTranscript(home, cursorProjectSlug(workspace), "chat-deleted", workspace); + + try { + const records = await discoverCursorSessions({ homeDir: home, scopeRoots: [workspace], limit: 10 }); + expect(records.map((record) => record.id)).toContain("chat-deleted"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts index 7d6e85678..174bc99da 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts @@ -39,7 +39,14 @@ const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: DatabaseSyncConstructor }; function cursorProjectSlugForCwd(cwd: string): string { - return cwd.replace(/^[/\\]+/u, "").replace(/[\\/]/gu, "-"); + return cwd + // A Windows drive prefix must be dropped, not hyphenated: `C:-Users-…` + // contains a colon, so it can never equal a real directory name, and + // `resolveCursorCwdFromSlug` (which walks down from the drive root) cannot + // invert it either. The drive-less slug round-trips correctly. + .replace(/^([A-Za-z]):[\\/]+/u, "") + .replace(/^[/\\]+/u, "") + .replace(/[\\/]/gu, "-"); } type CursorStoreCandidate = ExternalSessionFileCandidate<{ diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts index 8a4261977..f81c317c4 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -720,7 +720,9 @@ describe("external session provider discovery", () => { // Use a writable temp dir (CI can't mkdir under /private/tmp) and derive the slug from it. const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "adecursorcwd"))); expect(cwd.includes("-"), "temp cwd must be dash-free for the cursor slug round-trip").toBe(false); - const slug = cwd.replace(/^\/+/u, "").replace(/\//gu, "-"); + // A Windows drive prefix is dropped rather than hyphenated (`C:` is not a + // legal directory-name fragment), matching `cursorProjectSlugForCwd`. + const slug = cwd.replace(/^([A-Za-z]):[\\/]+/u, "").replace(/^[/\\]+/u, "").replace(/[\\/]/gu, "-"); const agentId = "33333333-3333-4333-8333-333333333333"; const sdkAgentId = "agent-44444444-4444-4444-8444-444444444444"; writeJsonl(path.join(homeDir, ".cursor", "projects", slug, "agent-transcripts", agentId, `${agentId}.jsonl`), [ diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts index 7cd10201d..211c83ecf 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts @@ -215,7 +215,10 @@ describe("resolveCursorCwdFromSlug", () => { const cwd = path.join(root, "Projects", "my-cool.app", ".ade", "worktrees", "lane-with-hyphen"); fs.mkdirSync(cwd, { recursive: true }); try { - const slug = cwd.replace(/^\/+/u, "").replace(/[/.]/gu, "-"); + const slug = cwd + .replace(/^([A-Za-z]):[\\/]+/u, "") + .replace(/^[/\\]+/u, "") + .replace(/[\\/.]/gu, "-"); expect(resolveCursorCwdFromSlug(slug)).toBe(fs.realpathSync(cwd)); } finally { fs.rmSync(root, { recursive: true, force: true }); From 3d5b99cd4becf19aadcd018491e9fc345184e475 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:28:59 -0400 Subject: [PATCH 15/51] fix(windows): show Cursor's PowerShell installer in onboarding The AI runtimes band hard-coded Cursor's POSIX install one-liner -- `mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash` -- on every platform. Cursor documents that command for macOS, Linux and WSL only; native Windows has its own installer, and the agent registry already emits the right one. A Windows user copying the onboarding command into PowerShell gets nothing but errors. Derive the command from the renderer platform, matching `cursorInstallCommand()` in the agent registry. Based-on: nsxdavid/ADE#999 --- .../onboarding/AiRuntimesBand.test.tsx | 21 +++++++++++++++++++ .../components/onboarding/AiRuntimesBand.tsx | 15 ++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx diff --git a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx new file mode 100644 index 000000000..629eb7f9f --- /dev/null +++ b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { cursorInstallCommand } from "./AiRuntimesBand"; + +describe("cursorInstallCommand", () => { + it("uses Cursor's PowerShell installer on Windows", () => { + const command = cursorInstallCommand("win32"); + expect(command).toContain("powershell.exe"); + expect(command).toContain("cursor.com/install?win32=true"); + expect(command).not.toContain("curl"); + expect(command).not.toContain("mkdir -p"); + expect(command).not.toContain("$HOME"); + }); + + it("keeps the documented POSIX one-liner elsewhere", () => { + for (const platform of ["darwin", "linux"] as const) { + const command = cursorInstallCommand(platform); + expect(command).toContain("curl https://cursor.com/install -fsS | bash"); + expect(command).not.toContain("powershell"); + } + }); +}); diff --git a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx index fbf0fbda6..2643dc079 100644 --- a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx +++ b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx @@ -12,6 +12,7 @@ import { COLORS, SANS_FONT, MONO_FONT } from "../lanes/laneDesignTokens"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { openExternalUrl } from "../../lib/openExternal"; +import { rendererPlatformAttribute } from "../../lib/platform"; import { docs } from "../../onboarding/docsLinks"; import { InputPopover } from "./InputPopover"; import { RescanButton } from "./RescanButton"; @@ -34,10 +35,22 @@ type RuntimeMeta = { authCommand?: string; }; +/** + * Cursor ships a PowerShell installer for native Windows; the `curl … | bash` + * one-liner is documented for macOS, Linux and WSL only. Keep this in step with + * `cursorInstallCommand()` in apps/ade-cli/src/services/agentRegistry.ts. + */ +export function cursorInstallCommand(platform = rendererPlatformAttribute()): string { + if (platform === "win32") { + return `powershell.exe -NoProfile -Command "irm 'https://cursor.com/install?win32=true' | iex"`; + } + return 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash'; +} + const RUNTIMES: RuntimeMeta[] = [ { id: "claude", label: "Claude Code", brand: BRAND.claude, Logo: ClaudeLogo, docsUrl: docs.multiAgentSetup, installCommand: "npm install -g @anthropic-ai/claude-code", authCommand: "claude /login" }, { id: "codex", label: "Codex", brand: BRAND.codex, Logo: CodexLogo, docsUrl: docs.multiAgentSetup, installCommand: "npm install -g @openai/codex", authCommand: "codex login" }, - { id: "cursor", label: "Cursor", brand: BRAND.cursor, Logo: CursorAgentLogo, docsUrl: docs.multiAgentSetup, installCommand: 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash' }, + { id: "cursor", label: "Cursor", brand: BRAND.cursor, Logo: CursorAgentLogo, docsUrl: docs.multiAgentSetup, installCommand: cursorInstallCommand() }, { id: "droid", label: "Factory Droid", brand: BRAND.droid, Logo: DroidLogo, docsUrl: "https://docs.factory.ai/cli/getting-started/quickstart", authCommand: "droid login" }, { id: "opencode", label: "OpenCode", brand: BRAND.opencode, Logo: OpenCodeLogo, docsUrl: docs.multiAgentSetup }, ]; From 6ac6785da3b90a7e898129317538c48d12b03f39 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:30:03 -0400 Subject: [PATCH 16/51] fix(windows): find standalone Codex installs on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's own installer (https://chatgpt.com/codex/install.ps1) unpacks the release into `$CODEX_HOME\packages\standalone\current` and exposes it through `%CODEX_INSTALL_DIR%`, defaulting to `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin`, which it prepends to the *persisted* user PATH. A persisted PATH edit is invisible to an already-running session, so ADE cannot rely on PATH alone. macOS gets this for free: the installer's Unix default is `$HOME/.local/bin`, which the shared known-bin-dir list already carries. The Windows list carries `%ProgramFiles%\Codex` and `%LOCALAPPDATA%\Programs\Codex` instead — neither of which any Codex installer ever writes to. The result was that a standalone Windows install resolved to the bare command `codex`, indistinguishable from "Codex is not installed" and guaranteed to ENOENT on spawn. Probe the installer's real layout from `resolveCodexExecutable` before falling back to the bare command, on every platform, and thread the caller-supplied platform through `pathExists` so the win32 no-execute-bit branch is honoured when the platform is injected rather than inferred. Based-on: nsxdavid/ADE#999 --- .../src/main/services/ai/codexExecutable.ts | 72 ++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/ai/codexExecutable.ts b/apps/desktop/src/main/services/ai/codexExecutable.ts index 6e102e33c..de7e68990 100644 --- a/apps/desktop/src/main/services/ai/codexExecutable.ts +++ b/apps/desktop/src/main/services/ai/codexExecutable.ts @@ -1,5 +1,6 @@ import type { DetectedAuth } from "./authDetector"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; @@ -37,20 +38,80 @@ function findCodexAuthPath(auth?: DetectedAuth[]): string | null { return null; } -function pathExists(filePath: string): boolean { +function pathExists(filePath: string, platform: NodeJS.Platform = process.platform): boolean { try { fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { try { fs.accessSync(filePath, fs.constants.F_OK); - return process.platform === "win32"; + // Windows has no execute bit; presence is the only signal available. + return platform === "win32"; } catch { return false; } } } +function homeDirFromEnv(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string | null { + const profile = env.USERPROFILE?.trim(); + const home = env.HOME?.trim(); + if (platform === "win32") return profile || home || os.homedir() || null; + return home || os.homedir() || null; +} + +/** + * Directories used by Codex's own standalone installer + * (`https://chatgpt.com/codex/install.ps1` / `install.sh`). + * + * The installer drops the release under `$CODEX_HOME/packages/standalone/current` + * and exposes it through a "visible bin" directory that it prepends to the + * *persisted* user PATH: + * - Windows: `%CODEX_INSTALL_DIR%` else `%LOCALAPPDATA%\Programs\OpenAI\Codex\bin` + * - macOS/Linux: `$CODEX_INSTALL_DIR` else `$HOME/.local/bin` + * + * A persisted PATH edit is not visible to an already-running login session, so + * ADE must be able to find a standalone install without it. macOS already gets + * this for free because `~/.local/bin` is in the shared known-bin-dir list; the + * Windows equivalent is not, which left standalone Windows installs + * indistinguishable from "Codex is not installed". + */ +function standaloneCodexInstallDirs(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string[] { + const dirs: string[] = []; + const installDir = env.CODEX_INSTALL_DIR?.trim(); + if (installDir) dirs.push(installDir); + + if (platform === "win32") { + const localAppData = env.LOCALAPPDATA?.trim(); + if (localAppData) dirs.push(path.join(localAppData, "Programs", "OpenAI", "Codex", "bin")); + } else { + const home = homeDirFromEnv(env, platform); + if (home) dirs.push(path.join(home, ".local", "bin")); + } + + const configuredHome = env.CODEX_HOME?.trim(); + const home = homeDirFromEnv(env, platform); + const codexHome = configuredHome || (home ? path.join(home, ".codex") : ""); + if (codexHome) { + const current = path.join(codexHome, "packages", "standalone", "current"); + dirs.push(path.join(current, "bin"), current); + } + + return [...new Set(dirs)]; +} + +function findStandaloneCodexExecutable( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string | null { + const binaryName = platform === "win32" ? "codex.exe" : "codex"; + for (const dir of standaloneCodexInstallDirs(env, platform)) { + const candidate = path.join(dir, binaryName); + if (pathExists(candidate, platform)) return candidate; + } + return null; +} + function listDirectories(rootPath: string): string[] { try { return fs.readdirSync(rootPath, { withFileTypes: true }) @@ -68,7 +129,7 @@ function findVendorCodexBinary(packageRoot: string, platform: NodeJS.Platform): path.join(vendorRoot, "bin", binaryName), path.join(vendorRoot, "codex", binaryName), ]) { - if (pathExists(candidate)) return candidate; + if (pathExists(candidate, platform)) return candidate; } } return null; @@ -158,5 +219,10 @@ export function resolveCodexExecutable(args?: { }; } + const standalone = findStandaloneCodexExecutable(env, args?.platform ?? process.platform); + if (standalone) { + return { path: standalone, source: "common-dir" }; + } + return { path: "codex", source: "fallback-command" }; } From 48172f88d6141d08e51fc4c3ef21a6538ca0eff2 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:31:24 -0400 Subject: [PATCH 17/51] fix(windows): assert the real Codex task launch shape on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runCommand` routes every provider CLI through `resolveCliSpawnInvocation`. On Windows a `.cmd`/extensionless launcher cannot be handed to CreateProcess, so the invocation becomes `%ComSpec% /d /s /c ""` and all arguments collapse into a single string. The Codex and Claude task tests asserted the POSIX shape — a bare launcher path and a flat argv — so both failed on Windows, and the Codex case failed silently in a way that hid the assertion it existed to make: the fake process never found `--output-last-message`, never wrote the result file, and the run came back empty rather than `DONE`. Assert argument content through the same quoting primitive the launcher uses, so the tests describe Codex's actual Windows invocation instead of only its macOS one. Based-on: nsxdavid/ADE#999 --- .../services/ai/providerTaskRunner.test.ts | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts index c502e00b1..eea1b696e 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts @@ -32,6 +32,37 @@ vi.mock("./codexExecutable", () => ({ })); import { makeCodexCompatibleJsonSchema, runProviderTask } from "./providerTaskRunner"; +import { quoteWindowsCmdArg } from "../shared/processExecution"; + +// `runCommand` launches CLIs through `resolveCliSpawnInvocation`. On Windows an +// extensionless/`.cmd`/`.bat` launcher cannot be handed to CreateProcess, so the +// invocation becomes `%ComSpec% /d /s /c ""` and every +// argument is folded into one string. These helpers assert the same argument +// content on both shapes instead of encoding the POSIX shape only. +const isWindowsLaunch = process.platform === "win32"; + +function expectedLaunchCommand(executablePath: string): string { + return isWindowsLaunch ? (process.env.ComSpec?.trim() || "cmd.exe") : executablePath; +} + +function launchArgvContains(argv: unknown, value: string): boolean { + const args = Array.isArray(argv) ? (argv as string[]) : []; + return isWindowsLaunch + ? args.join(" ").includes(quoteWindowsCmdArg(value)) + : args.includes(value); +} + +function launchArgvValueAfter(argv: unknown, flag: string): string | null { + const args = Array.isArray(argv) ? (argv as string[]) : []; + if (!isWindowsLaunch) { + const index = args.indexOf(flag); + return index >= 0 ? (args[index + 1] ?? null) : null; + } + const match = args + .join(" ") + .match(new RegExp(`${quoteWindowsCmdArg(flag).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} "([^"]+)"`)); + return match?.[1] ?? null; +} type MockSpawnProcess = EventEmitter & { stdout: EventEmitter; @@ -143,9 +174,9 @@ describe("runProviderTask", () => { expect(result.text).toBe("READY"); expect(spawnMock).toHaveBeenCalledTimes(1); const [command, argv, options] = spawnMock.mock.calls[0]!; - expect(command).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd"); - expect(argv).toContain("-p"); - expect(argv).not.toContain("Summarize the worktree state."); + expect(command).toBe(expectedLaunchCommand("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd")); + expect(launchArgvContains(argv, "-p")).toBe(true); + expect(launchArgvContains(argv, "Summarize the worktree state.")).toBe(false); expect(options).toMatchObject({ stdio: ["pipe", "pipe", "pipe"], }); @@ -155,8 +186,7 @@ describe("runProviderTask", () => { it("pipes Codex prompts over stdin instead of argv", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-task-runner-")); spawnMock.mockImplementationOnce((_command: unknown, argv: string[]) => { - const outputIndex = argv.indexOf("--output-last-message"); - const outputPath = outputIndex >= 0 ? argv[outputIndex + 1] : null; + const outputPath = launchArgvValueAfter(argv, "--output-last-message"); return createMockProcess({ onStart: () => { if (outputPath) { @@ -187,12 +217,12 @@ describe("runProviderTask", () => { expect(result.text).toBe("DONE"); expect(spawnMock).toHaveBeenCalledTimes(1); const [command, argv, options] = spawnMock.mock.calls[0]!; - expect(command).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd"); - expect(argv).toContain("exec"); - expect(argv).toContain("-"); - expect(argv).toContain("--image"); - expect(argv).toContain("/tmp/settings.png"); - expect(argv).not.toContain("Fix the Windows launcher."); + expect(command).toBe(expectedLaunchCommand("C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd")); + expect(launchArgvContains(argv, "exec")).toBe(true); + expect(launchArgvContains(argv, "-")).toBe(true); + expect(launchArgvContains(argv, "--image")).toBe(true); + expect(launchArgvContains(argv, "/tmp/settings.png")).toBe(true); + expect(launchArgvContains(argv, "Fix the Windows launcher.")).toBe(false); expect(options).toMatchObject({ stdio: ["pipe", "pipe", "pipe"], }); From dae17de5745de84973c014a2651697f9536a3bd2 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:31:32 -0400 Subject: [PATCH 18/51] fix(windows): match Droid's real per-project session directory name Droid names each directory under ~/.factory/sessions with the CLI's own sanitizePathToDirectoryName(), which is not a plain separator swap: posix /Users/dev/ADE -> "-Users-dev-ADE" win32 C:\Users\dev\ADE -> "-C-Users-dev-ADE" (drive colon dropped) discoverDroid used the generic slashEscapedCwd() helper, which coincides with the posix form -- so macOS worked -- but on Windows produced "C:-Users-dev-ADE", a name NTFS can never contain. Every Droid project directory failed the scope filter and no CLI sessions were importable. Reproduced against a session tree laid out exactly as @factory/droid-sdk writes it: 0 sessions discovered before, 1 after. Based-on: nsxdavid/ADE#999 --- .../externalSessions/discoverDroid.ts | 43 ++++++++++++++++++- .../discoverProviders.test.ts | 25 +++++++++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/services/externalSessions/discoverDroid.ts b/apps/desktop/src/main/services/externalSessions/discoverDroid.ts index b54ca577a..0588f3e80 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverDroid.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverDroid.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { asEpochMs, @@ -13,7 +14,6 @@ import { resolveHomeDir, safeReadDir, sessionFileCandidate, - slashEscapedCwd, slugMatchesScopeRoots, sortFileCandidatesByMtime, sortDiscoveryRecords, @@ -22,6 +22,41 @@ import { type ExternalSessionDiscoveryRecord, } from "./discoveryUtils"; +/** + * Name of the per-project directory Droid creates under `~/.factory/sessions`. + * + * This mirrors `sanitizePathToDirectoryName` in @factory/droid-sdk (and the + * `droid` CLI that writes the files), which is *not* a plain separator swap: + * + * posix /Users/dev/ADE -> "-Users-dev-ADE" + * win32 C:\Users\dev\ADE -> "-C-Users-dev-ADE" (drive colon dropped) + * + * The generic `slashEscapedCwd` helper happens to match the posix form, which + * is why macOS worked, but on Windows it yields "C:-Users-dev-ADE" — a name + * that can never exist on NTFS (`:` is reserved) — so every Droid project + * directory failed the scope filter and no CLI sessions were imported. + */ +export function droidProjectSlugForCwd(cwd: string): string { + const absolutePath = path.resolve(cwd); + let canonicalPath = absolutePath; + try { + canonicalPath = fs.realpathSync(absolutePath); + } catch { + canonicalPath = absolutePath; + } + const normalized = canonicalPath.replace(/[\\/]+$/u, ""); + const slug = process.platform === "win32" + ? `-${normalized.replace(/^([A-Za-z]):/u, "$1").replace(/[\\/]+/gu, "-")}` + : `-${normalized.replace(/^\/+/u, "").replace(/\/+/gu, "-")}`; + // Windows paths are case-insensitive; the on-disk directory can differ in + // case from the scope root ADE hands us (drive letter especially). + return process.platform === "win32" ? slug.toLowerCase() : slug; +} + +function droidSlugForComparison(slug: string): string { + return process.platform === "win32" ? slug.toLowerCase() : slug; +} + export async function discoverDroidSessions( args: ExternalSessionDiscoveryArgs = {}, ): Promise { @@ -34,7 +69,11 @@ export async function discoverDroidSessions( if (!projectEntry.isDirectory()) continue; if ( projectEntry.name.startsWith("-") - && !slugMatchesScopeRoots(projectEntry.name, args.scopeRoots, slashEscapedCwd) + && !slugMatchesScopeRoots( + droidSlugForComparison(projectEntry.name), + args.scopeRoots, + droidProjectSlugForCwd, + ) ) { continue; } diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts index 8a4261977..5cbace547 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -9,9 +9,9 @@ import { clearOpenCodeBinaryCache } from "../opencode/openCodeBinaryManager"; import { discoverClaudeSessions } from "./discoverClaude"; import { discoverCodexSessions } from "./discoverCodex"; import { discoverCursorSessions } from "./discoverCursor"; -import { discoverDroidSessions } from "./discoverDroid"; +import { discoverDroidSessions, droidProjectSlugForCwd } from "./discoverDroid"; import { discoverOpenCodeSessions } from "./discoverOpenCode"; -import { claudeProjectSlugForCwd, slashEscapedCwd } from "./discoveryUtils"; +import { claudeProjectSlugForCwd } from "./discoveryUtils"; import { createExternalSessionsService } from "./externalSessionsService"; type DatabaseSyncConstructor = new (dbPath: string) => DatabaseSyncType; @@ -787,7 +787,7 @@ describe("external session provider discovery", () => { const homeDir = path.join(root, "home"); const cwd = path.join(root, "droid-repo"); const id = "44444444-4444-4444-8444-444444444444"; - writeJsonl(path.join(homeDir, ".factory", "sessions", slashEscapedCwd(cwd), `${id}.jsonl`), [ + writeJsonl(path.join(homeDir, ".factory", "sessions", droidProjectSlugForCwd(cwd), `${id}.jsonl`), [ { type: "session_start", id, title: "New Session", cwd }, { type: "message", message: { role: "assistant", content: "untimestamped setup" } }, { type: "message", timestamp: "2026-07-06T10:00:00.000Z", message: { role: "user", content: [{ type: "text", text: "Factory task title" }] } }, @@ -807,6 +807,25 @@ describe("external session provider discovery", () => { }); }); + it("keeps Droid sessions in scope when the project directory uses the CLI's own slug", async () => { + // droid names the per-project directory with sanitizePathToDirectoryName(), + // which drops the drive colon on Windows ("-C-Users-dev-ADE"). A plain + // separator swap produced "C:-Users-dev-ADE" and filtered every directory + // out before any session was read. + const homeDir = path.join(root, "home-scoped"); + const cwd = path.join(root, "droid-scoped-repo"); + fs.mkdirSync(cwd, { recursive: true }); + const id = "55555555-5555-4555-8555-555555555555"; + writeJsonl(path.join(homeDir, ".factory", "sessions", droidProjectSlugForCwd(cwd), `${id}.jsonl`), [ + { type: "session_start", id, cwd, timestamp: "2026-07-06T10:00:00.000Z" }, + { type: "message", timestamp: "2026-07-06T10:00:01.000Z", message: { role: "user", content: "scoped droid task" } }, + ]); + + const sessions = await discoverDroidSessions({ homeDir, scopeRoots: [cwd], limit: 10 }); + + expect(sessions.map((session) => session.id)).toEqual([id]); + }); + it("uses the OpenCode CLI list command and skips cleanly when unavailable", async () => { const homeDir = path.join(root, "home"); const cwd = path.join(root, "opencode-repo"); From 185a97503e7617db9799f79a36bf75a1d80c481b Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:31:38 -0400 Subject: [PATCH 19/51] fix(windows): reap the whole Droid worker tree on dispose droidSdkPool.dispose() force-killed the SDK worker with a bare child.kill("SIGTERM"), which on Windows is a single-PID TerminateProcess: the droid.exe the worker spawned (and anything droid spawned in turn) is orphaned rather than reaped. Use the repo's terminateChildProcessTree(), which issues taskkill /T on win32 and signals the process group elsewhere. The 800ms escalation was also shorter than the 5s grace period @factory/droid-sdk's ProcessTransport.close() gives droid before it escalates to SIGKILL, so ADE routinely tore the worker down mid-close. Raise it past that window. Based-on: nsxdavid/ADE#999 --- .../desktop/src/main/services/chat/droidSdkPool.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/chat/droidSdkPool.ts b/apps/desktop/src/main/services/chat/droidSdkPool.ts index b77ff2c7b..4245fa108 100644 --- a/apps/desktop/src/main/services/chat/droidSdkPool.ts +++ b/apps/desktop/src/main/services/chat/droidSdkPool.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Logger } from "../logging/logger"; +import { terminateChildProcessTree } from "../shared/utils"; import type { DroidSdkAskUserRequest, DroidSdkAskUserResponse, @@ -48,6 +49,10 @@ let droidSdkGenCounter = 0; const pools = new Map(); const pendingInits = new Map>(); const STALE_INIT_RETRY_LIMIT = 2; +// @factory/droid-sdk's ProcessTransport.close() gives `droid` a 5s grace period +// before escalating to SIGKILL. Force-killing the worker sooner than that tears +// the worker down mid-close and leaves the `droid` process behind. +const WORKER_FORCE_KILL_DELAY_MS = 6_000; const moduleDir = typeof __dirname === "string" ? __dirname @@ -208,8 +213,13 @@ async function createDroidSdkConnection(args: Parameters { - if (child.exitCode == null && !child.killed) child.kill("SIGTERM"); - }, 800).unref(); + if (child.exitCode != null || child.killed) return; + // `child.kill("SIGTERM")` is a single-PID TerminateProcess on Windows, + // so the `droid` process the worker spawned (and anything it spawned in + // turn) is orphaned rather than reaped. terminateChildProcessTree() uses + // `taskkill /T` on win32 and the process group elsewhere. + terminateChildProcessTree(child, null, 1_500).unref(); + }, WORKER_FORCE_KILL_DELAY_MS).unref(); }, }; From 2e7f9e7f3f8a542f4c9413f8fa4fcbd9dcba56c3 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:31:44 -0400 Subject: [PATCH 20/51] fix(windows): give Windows users a runnable Droid install and API-key command Settings told a Windows user to run "export FACTORY_API_KEY=..." and offered a bare docs link instead of an install command; onboarding offered no Droid install command at all. Factory ships a native Windows build with its own PowerShell installer, so show the command that actually works: win32 irm https://app.factory.ai/cli/windows | iex setx FACTORY_API_KEY ... posix curl -fsSL https://app.factory.ai/cli | sh export FACTORY_API_KEY=... Based-on: nsxdavid/ADE#999 --- .../components/onboarding/AiRuntimesBand.tsx | 11 ++++++++++- .../components/settings/ProvidersSection.tsx | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx index fbf0fbda6..3e277420d 100644 --- a/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx +++ b/apps/desktop/src/renderer/components/onboarding/AiRuntimesBand.tsx @@ -12,6 +12,7 @@ import { COLORS, SANS_FONT, MONO_FONT } from "../lanes/laneDesignTokens"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { openExternalUrl } from "../../lib/openExternal"; +import { rendererPlatformAttribute } from "../../lib/platform"; import { docs } from "../../onboarding/docsLinks"; import { InputPopover } from "./InputPopover"; import { RescanButton } from "./RescanButton"; @@ -34,11 +35,19 @@ type RuntimeMeta = { authCommand?: string; }; +// Factory publishes a PowerShell installer for its native Windows build; the +// POSIX shell pipeline is not runnable there. The command shown here is the one +// the user is expected to paste into their own shell. +// https://docs.factory.ai/cli/getting-started/quickstart +const DROID_INSTALL_COMMAND = rendererPlatformAttribute() === "win32" + ? "irm https://app.factory.ai/cli/windows | iex" + : "curl -fsSL https://app.factory.ai/cli | sh"; + const RUNTIMES: RuntimeMeta[] = [ { id: "claude", label: "Claude Code", brand: BRAND.claude, Logo: ClaudeLogo, docsUrl: docs.multiAgentSetup, installCommand: "npm install -g @anthropic-ai/claude-code", authCommand: "claude /login" }, { id: "codex", label: "Codex", brand: BRAND.codex, Logo: CodexLogo, docsUrl: docs.multiAgentSetup, installCommand: "npm install -g @openai/codex", authCommand: "codex login" }, { id: "cursor", label: "Cursor", brand: BRAND.cursor, Logo: CursorAgentLogo, docsUrl: docs.multiAgentSetup, installCommand: 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash' }, - { id: "droid", label: "Factory Droid", brand: BRAND.droid, Logo: DroidLogo, docsUrl: "https://docs.factory.ai/cli/getting-started/quickstart", authCommand: "droid login" }, + { id: "droid", label: "Factory Droid", brand: BRAND.droid, Logo: DroidLogo, docsUrl: "https://docs.factory.ai/cli/getting-started/quickstart", installCommand: DROID_INSTALL_COMMAND, authCommand: "droid login" }, { id: "opencode", label: "OpenCode", brand: BRAND.opencode, Logo: OpenCodeLogo, docsUrl: docs.multiAgentSetup }, ]; diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index d930d5c55..b19e5451e 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -44,6 +44,7 @@ import { outlineButton, primaryButton, } from "../lanes/laneDesignTokens"; +import { rendererPlatformAttribute } from "../../lib/platform"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { shouldRefreshAiStatusForChatEvent } from "../../lib/aiProviderStatus"; @@ -79,6 +80,17 @@ const CUSTOM_PROVIDER_NPM_OPTIONS = [ "@ai-sdk/anthropic", ]; +// Factory ships a native Windows build of `droid` with its own installer and +// its own way of setting an environment variable — a POSIX `export` line and a +// bare docs link leave a Windows user with nothing to run. +// https://docs.factory.ai/cli/getting-started/quickstart +const DROID_INSTALL_HINT = rendererPlatformAttribute() === "win32" + ? "irm https://app.factory.ai/cli/windows | iex — installs droid.exe into %USERPROFILE%\\bin and puts it on PATH" + : "curl -fsSL https://app.factory.ai/cli | sh — ensure `droid` is on PATH"; +const DROID_LOGIN_CMD = rendererPlatformAttribute() === "win32" + ? "setx FACTORY_API_KEY … (or sign in via `droid` interactive login)" + : "export FACTORY_API_KEY=… (or sign in via `droid` interactive login)"; + const CLI_TOOLS: Array<{ cli: CliName; label: string; @@ -111,8 +123,8 @@ const CLI_TOOLS: Array<{ cli: "droid", label: "Droid", authStory: "Uses your Factory login or FACTORY_API_KEY.", - loginCmd: "export FACTORY_API_KEY=… (or sign in via `droid` interactive login)", - installHint: "Install from https://docs.factory.ai/cli/getting-started/quickstart — ensure `droid` is on PATH", + loginCmd: DROID_LOGIN_CMD, + installHint: DROID_INSTALL_HINT, }, ]; From 94f77d454c18380b76fe9aa46eb248524b6b4594 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:33:01 -0400 Subject: [PATCH 21/51] fix(windows): stop leaking OpenCode servers ADE can never reap On Windows every `opencode serve` was launched through a `cmd.exe /d /s /c` wrapper whose only purpose was to make the managed markers (`ADE_OPENCODE_MANAGED=1`, `OPENCODE_DISABLE_PROJECT_CONFIG=1`) visible in WMIC/CIM output. That made the real server a grandchild, and Windows process listings do not expose a child's environment, so the surviving `opencode.exe` carried none of those markers on its command line. Verified on Windows 11: the wrapper classified as managed, the actual server did not, and after the wrapper died `recoverManagedOpenCodeOrphans` returned `{recoveredPids: [], skippedPids: []}` while the server kept holding its port. macOS does not have this failure mode -- there the server is the direct child and `ps -wwE` prints its environment, so the markers are always visible. Launch the resolved executable directly unless it genuinely needs a shim, reusing the `shouldUseWindowsCmdWrapper` policy every other ADE CLI launch path already applies, so `.cmd`/`.bat`/extensionless targets keep their wrapper while the bundled `.exe` does not. Since the command line can no longer carry the markers on Windows, add a small on-disk registry of launched servers under the managed XDG runtime dir. It is written at spawn, removed on close/exit, and read by orphan recovery as a platform-neutral identity that survives an ADE crash. Recovery guards against PID reuse by requiring the live process to still look like an OpenCode server, and prunes stale records. Also stop forcing `windowsVerbatimArguments` on every Windows spawn; it now follows the launch spec, which is only verbatim when the cmd wrapper is used. Based-on: nsxdavid/ADE#999 --- .../opencode/openCodeServerManager.ts | 245 +++++++++++++++--- 1 file changed, 210 insertions(+), 35 deletions(-) diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts index fbe7b54b1..cbd782605 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts @@ -11,6 +11,7 @@ import { processOutputToString, quoteWindowsCmdArg, resolveWindowsCmdLineInvocation, + shouldUseWindowsCmdWrapper, } from "../shared/processExecution"; import { probeOpenCodeBinaryQuarantine, resolveOpenCodeBinaryPath } from "./openCodeBinaryManager"; @@ -68,6 +69,7 @@ type OpenCodeServeLaunchSpec = { args: string[]; env: NodeJS.ProcessEnv; useShell: boolean; + windowsVerbatimArguments: boolean; xdgPaths: OpenCodeIsolationPaths; }; @@ -799,6 +801,99 @@ function isManagedOpenCodeServeCommand(command: string, configMarkers: string[]) return configMarkers.some((marker) => command.includes(marker)); } +/** + * On-disk record of a server process ADE launched. Windows process listings do + * not expose a child's environment, so the `ADE_OPENCODE_MANAGED` marker that + * identifies managed servers in `ps -wwE` output on macOS (and `/proc//environ` + * on Linux) has no Windows equivalent. This registry is the platform-neutral + * identity: it survives an ADE crash, so the next launch can reap the servers the + * dead process left behind on every platform. + */ +type ManagedOpenCodeServerRecord = { + pid: number; + port: number; + ownerPid: number; + startedAt: number; +}; + +function managedServerRegistryDirs(): string[] { + return resolveKnownAdeManagedOpenCodeRoots().map((root) => ( + path.join(root, `xdg-v${ADE_OPENCODE_XDG_LAYOUT_VERSION}`, "runtime", "servers") + )); +} + +function managedServerRecordPath(pid: number): string { + return path.join( + resolveOpenCodeIsolationPaths().runtimeDir, + "servers", + `${pid}.json`, + ); +} + +function writeManagedServerRecord(record: ManagedOpenCodeServerRecord): void { + try { + const filePath = managedServerRecordPath(record.pid); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(record), "utf8"); + } catch { + // Recovery is best-effort; a registry write failure must not fail a launch. + } +} + +function removeManagedServerRecord(pid: number): void { + for (const dir of managedServerRegistryDirs()) { + try { + fs.rmSync(path.join(dir, `${pid}.json`), { force: true }); + } catch { + // ignore + } + } +} + +function readManagedServerRecords(): Array<{ file: string; record: ManagedOpenCodeServerRecord }> { + const out: Array<{ file: string; record: ManagedOpenCodeServerRecord }> = []; + const seenPids = new Set(); + for (const dir of managedServerRegistryDirs()) { + let names: string[]; + try { + names = fs.readdirSync(dir); + } catch { + continue; + } + for (const name of names) { + if (!name.endsWith(".json")) continue; + const file = path.join(dir, name); + try { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as Partial; + const pid = Number(parsed.pid); + const ownerPid = Number(parsed.ownerPid); + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ownerPid) || ownerPid <= 0) { + fs.rmSync(file, { force: true }); + continue; + } + if (seenPids.has(pid)) continue; + seenPids.add(pid); + out.push({ + file, + record: { + pid, + ownerPid, + port: Number.isInteger(Number(parsed.port)) ? Number(parsed.port) : 0, + startedAt: Number.isFinite(Number(parsed.startedAt)) ? Number(parsed.startedAt) : 0, + }, + }); + } catch { + try { + fs.rmSync(file, { force: true }); + } catch { + // ignore + } + } + } + } + return out; +} + function parseManagedOwnerPid(command: string): number | null { const match = command.match(new RegExp(`${ADE_OPENCODE_OWNER_PID_ENV}=(\\d+)`, "i")); if (!match) return null; @@ -806,6 +901,24 @@ function parseManagedOwnerPid(command: string): number | null { return Number.isInteger(pid) && pid > 0 ? pid : null; } +/** + * Terminate an orphaned managed server, escalating once if it survives the + * grace period. Windows has no POSIX signals, so it uses a `taskkill /T /F` + * tree kill on both passes; Unix escalates SIGTERM → SIGKILL. + */ +async function terminateOrphanProcess(pid: number): Promise { + if (process.platform === "win32") { + openCodeProcessController.killProcessTree(pid); + if (await waitForProcessExit(pid, ORPHAN_RECOVERY_TERM_GRACE_MS)) return true; + openCodeProcessController.killProcessTree(pid); + return await waitForProcessExit(pid, ORPHAN_RECOVERY_TERM_GRACE_MS); + } + openCodeProcessController.killProcess(pid, "SIGTERM"); + if (await waitForProcessExit(pid, ORPHAN_RECOVERY_TERM_GRACE_MS)) return true; + openCodeProcessController.killProcess(pid, "SIGKILL"); + return await waitForProcessExit(pid, ORPHAN_RECOVERY_TERM_GRACE_MS); +} + async function waitForProcessExit(pid: number, timeoutMs: number): Promise { const attempts = Math.max(1, Math.ceil(timeoutMs / 50)); for (let attempt = 0; attempt < attempts; attempt += 1) { @@ -848,8 +961,11 @@ export async function recoverManagedOpenCodeOrphans(args: { const activePorts = activeManagedOpenCodePorts(); const recoveredPids: number[] = []; const skippedPids: number[] = []; + const handledPids = new Set(); + const snapshot = openCodeProcessController.listProcesses(); + const commandByPid = new Map(snapshot.map((proc) => [proc.pid, proc.command])); - for (const proc of openCodeProcessController.listProcesses()) { + for (const proc of snapshot) { if (proc.pid === process.pid) continue; if (!isManagedOpenCodeServeCommand(proc.command, configMarkers)) continue; @@ -872,39 +988,16 @@ export async function recoverManagedOpenCodeOrphans(args: { continue; } - if (process.platform === "win32") { - openCodeProcessController.killProcessTree(proc.pid); - const exitedGracefully = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); - if (!exitedGracefully && openCodeProcessController.isProcessAlive(proc.pid)) { - openCodeProcessController.killProcessTree(proc.pid); - const exitedAfterKill = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); - if (!exitedAfterKill && openCodeProcessController.isProcessAlive(proc.pid)) { - skippedPids.push(proc.pid); - args.logger?.warn("opencode.server_orphan_recovery_failed", { - pid: proc.pid, - ownerPid, - ppid: proc.ppid, - }); - continue; - } - } - } else { - openCodeProcessController.killProcess(proc.pid, "SIGTERM"); - const exitedGracefully = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); - if (!exitedGracefully && openCodeProcessController.isProcessAlive(proc.pid)) { - openCodeProcessController.killProcess(proc.pid, "SIGKILL"); - const exitedAfterKill = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); - if (!exitedAfterKill && openCodeProcessController.isProcessAlive(proc.pid)) { - skippedPids.push(proc.pid); - args.logger?.warn("opencode.server_orphan_recovery_failed", { - pid: proc.pid, - ownerPid, - ppid: proc.ppid, - }); - continue; - } - } + if (!await terminateOrphanProcess(proc.pid)) { + skippedPids.push(proc.pid); + args.logger?.warn("opencode.server_orphan_recovery_failed", { + pid: proc.pid, + ownerPid, + ppid: proc.ppid, + }); + continue; } + handledPids.add(proc.pid); recoveredPids.push(proc.pid); args.logger?.warn("opencode.server_orphan_recovered", { pid: proc.pid, @@ -912,6 +1005,66 @@ export async function recoverManagedOpenCodeOrphans(args: { ppid: proc.ppid, port: parseManagedOpenCodePort(proc.command), }); + removeManagedServerRecord(proc.pid); + } + + // Second pass: the on-disk registry. This is the only identity that works on + // Windows, where a process listing cannot show a child's environment and the + // managed markers therefore never appear on the server's command line. + for (const { file, record } of readManagedServerRecords()) { + if (record.pid === process.pid) continue; + if (handledPids.has(record.pid)) continue; + if (!openCodeProcessController.isProcessAlive(record.pid)) { + try { + fs.rmSync(file, { force: true }); + } catch { + // ignore + } + continue; + } + // Guard against PID reuse: the live process must still look like an + // OpenCode server before we are willing to kill it. + const command = commandByPid.get(record.pid); + if (command !== undefined && !commandLooksLikeOpenCodeServe(command)) { + try { + fs.rmSync(file, { force: true }); + } catch { + // ignore + } + continue; + } + const ownerAlive = record.ownerPid !== process.pid + && openCodeProcessController.isProcessAlive(record.ownerPid); + if (ownerAlive) { + skippedPids.push(record.pid); + continue; + } + if (record.ownerPid === process.pid && record.port > 0 && activePorts.has(record.port)) { + skippedPids.push(record.pid); + continue; + } + if (!await terminateOrphanProcess(record.pid)) { + skippedPids.push(record.pid); + args.logger?.warn("opencode.server_orphan_recovery_failed", { + pid: record.pid, + ownerPid: record.ownerPid, + source: "registry", + }); + continue; + } + handledPids.add(record.pid); + recoveredPids.push(record.pid); + args.logger?.warn("opencode.server_orphan_recovered", { + pid: record.pid, + ownerPid: record.ownerPid, + port: record.port, + source: "registry", + }); + try { + fs.rmSync(file, { force: true }); + } catch { + // ignore + } } lastOrphanRecoveryResult = { recoveredPids, skippedPids }; @@ -933,7 +1086,16 @@ function buildOpenCodeServeLaunchSpec(args: OpenCodeServerLaunchArgs): OpenCodeS const xdgPaths = resolveOpenCodeIsolationPaths(); ensureOpenCodeIsolationDirs(xdgPaths); const env = buildIsolatedOpenCodeEnv(args.config, xdgPaths); - if (process.platform === "win32") { + // Only shim through cmd.exe when the resolved target actually needs it (a + // `.cmd`/`.bat` shim, or an extensionless file), matching + // {@link shouldUseWindowsCmdWrapper} — the policy every other ADE CLI launch + // path uses. The bundled runtime is a real `.exe`, so wrapping it added a + // cmd.exe parent that owned the server process. When that parent died without + // taking its tree down, the surviving `opencode.exe` was unreachable: Windows + // process listings do not expose a child's environment, so the managed markers + // that identify ADE's servers on macOS/Linux were absent from its command line + // and orphan recovery could never reap it. + if (process.platform === "win32" && shouldUseWindowsCmdWrapper(executable)) { const serveCmdLine = [ executable, "serve", @@ -951,6 +1113,7 @@ function buildOpenCodeServeLaunchSpec(args: OpenCodeServerLaunchArgs): OpenCodeS args: invocation.args, env, useShell: false, + windowsVerbatimArguments: true, xdgPaths, }; } @@ -964,6 +1127,7 @@ function buildOpenCodeServeLaunchSpec(args: OpenCodeServerLaunchArgs): OpenCodeS ], env, useShell: false, + windowsVerbatimArguments: false, xdgPaths, }; } @@ -976,9 +1140,17 @@ async function defaultOpenCodeServerLauncher( env: launchSpec.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, - windowsVerbatimArguments: process.platform === "win32", + windowsVerbatimArguments: launchSpec.windowsVerbatimArguments, shell: launchSpec.useShell, }); + if (proc.pid) { + writeManagedServerRecord({ + pid: proc.pid, + port: args.port, + ownerPid: process.pid, + startedAt: Date.now(), + }); + } let output = ""; let resolved = false; @@ -995,6 +1167,7 @@ async function defaultOpenCodeServerLauncher( const fail = (error: Error): void => { cleanup(); stopChildProcess(proc); + if (proc.pid) removeManagedServerRecord(proc.pid); reject(error); }; @@ -1017,6 +1190,7 @@ async function defaultOpenCodeServerLauncher( close() { cleanup(); terminateOpenCodeServerProcesses(proc, listenerPid); + if (proc.pid) removeManagedServerRecord(proc.pid); }, }); return; @@ -1028,6 +1202,7 @@ async function defaultOpenCodeServerLauncher( }; const onExit = (code: number | null): void => { + if (proc.pid) removeManagedServerRecord(proc.pid); if (resolved) return; cleanup(); let message = `Server exited with code ${code}`; From d4876bc15005c88dff3ea039a68a2c33fdf5ce24 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:33:07 -0400 Subject: [PATCH 22/51] fix(windows): stop telling Windows users to install OpenCode with brew The "Install OpenCode" card in Settings > Providers listed three fixed commands on every platform: `brew install anomalyco/tap/opencode`, `npm i -g opencode-ai` and `curl -fsSL https://opencode.ai/install | bash`. Two of the three cannot run on Windows -- there is no Homebrew, and no POSIX shell to pipe the install script into -- so a Windows user whose OpenCode binary is missing was handed mostly macOS instructions. Serve OpenCode's own documented Windows install methods there instead (npm, Scoop, Chocolatey), and keep the existing list everywhere else. Based-on: nsxdavid/ADE#999 --- .../components/settings/ProvidersSection.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index d930d5c55..2d7f02cfd 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -36,6 +36,7 @@ import { } from "@phosphor-icons/react"; import { ClaudeLogo, CodexLogo, CursorAgentLogo, OpenCodeLogo } from "../terminals/ToolLogos"; import { ProviderLogo } from "../shared/ProviderLogos"; +import { rendererPlatformAttribute } from "../../lib/platform"; import { COLORS, MONO_FONT, @@ -67,11 +68,28 @@ type ProvidersStatus = AiSettingsStatus & { const KIMI_PROVIDER_ID = "kimi-for-coding"; -const OPENCODE_INSTALL_COMMANDS = [ - "brew install anomalyco/tap/opencode", - "npm i -g opencode-ai", - "curl -fsSL https://opencode.ai/install | bash", -]; +/** + * OpenCode's own documented install methods, per platform. Windows has neither + * Homebrew nor a POSIX shell to pipe the install script into, so it gets the + * package managers OpenCode actually documents for Windows (npm, Scoop, + * Chocolatey) instead of commands that cannot run there. + */ +export function openCodeInstallCommands( + platform: ReturnType = rendererPlatformAttribute(), +): string[] { + if (platform === "win32") { + return [ + "npm i -g opencode-ai", + "scoop install opencode", + "choco install opencode", + ]; + } + return [ + "brew install anomalyco/tap/opencode", + "npm i -g opencode-ai", + "curl -fsSL https://opencode.ai/install | bash", + ]; +} const CUSTOM_PROVIDER_NPM_OPTIONS = [ "@ai-sdk/openai-compatible", @@ -1275,7 +1293,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh OpenCode powers every subscription, API key, and local model below. Install it, then re-check:
- {OPENCODE_INSTALL_COMMANDS.map((cmd) => ( + {openCodeInstallCommands().map((cmd) => ( ))}
From 7c4ad62c159453ecd47e92231279abad954ffe13 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:34:28 -0400 Subject: [PATCH 23/51] fix(windows): launch and reap Claude Code the way Windows requires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Windows-only defects on the Claude runtime path, each reproduced here before and after. The subprocess reaper handed the resolved executable straight to `spawn`. An npm-installed Claude Code is `claude.cmd`, and since the CVE-2024-27980 fix Node refuses a `.cmd`/`.bat` target with EINVAL unless it goes through a command interpreter — measured: `spawn("fakeclaude.cmd")` -> EINVAL. It now goes through `resolveCliSpawnInvocation`, a no-op for a plain `.exe`. Killing used `child.kill("SIGTERM")`, which on Windows is an immediate TerminateProcess of one PID with no signal semantics and no tree. With the cmd.exe shim in front, that PID is not even the agent. Measured on a live 3-process tree (cmd.exe -> cmd.exe -> node.exe): SIGTERM left 2 of 3 alive; taskkill /T /F leaves 0. Live children now go through `terminateProcessTree` and stale registry entries through `killWindowsProcessTree`; the POSIX SIGTERM -> SIGKILL escalation is untouched, and the reaper takes an injectable `platform` so both paths stay testable. The registry outlives the app in the temp dir, and Windows recycles PIDs far faster than macOS, so a stale record can name somebody else's process. Before reaping a cross-restart PID, Windows now checks the image name and skips it if it cannot belong to us; an unreadable answer still reaps, since leaking a Claude subprocess is the worse failure. User-facing strings stopped describing macOS. The "CLI not found" blocker claimed ADE had probed the login-shell and interactive-shell PATH, which it never does on Windows, and told the user to edit a shell PATH; it now names the directories actually searched and points at Environment Variables. The Claude install hint offered only the npm command — Windows now gets Anthropic's documented native installer and WinGet package. Based-on: nsxdavid/ADE#999 --- .../services/ai/providerConnectionStatus.ts | 7 +- .../chat/claudeSubprocessReaper.test.ts | 11 ++- .../services/chat/claudeSubprocessReaper.ts | 92 ++++++++++++++++++- .../components/settings/ProvidersSection.tsx | 19 +++- 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index 81a1a18c1..e73003f19 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -79,7 +79,12 @@ export async function buildProviderConnections( return `${providerLabel} CLI is installed but no login was detected. Run: ${loginHint}`; } if (!flags.runtimeDetected) { - return `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH, login-shell PATH, interactive-shell PATH, and common install directories. If ${providerLabel} is installed elsewhere, add that bin directory to your shell PATH and refresh.`; + // The login-shell/interactive-shell PATH probe is a POSIX-only step — + // `augmentProcessPathWithShellAndKnownCliDirs` skips it on Windows — so + // do not claim it happened, and give the right place to fix PATH. + return process.platform === "win32" + ? `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH (honouring PATHEXT) and the common Windows install directories: %APPDATA%\\npm, %USERPROFILE%\\.local\\bin, %LOCALAPPDATA%\\Programs, %LOCALAPPDATA%\\Microsoft\\WinGet\\Links. If ${providerLabel} is installed elsewhere, add that folder to your PATH in System Properties -> Environment Variables, reopen ADE, and refresh.` + : `Local credentials exist but ADE could not find the ${providerLabel} CLI. ADE checks the app PATH, login-shell PATH, interactive-shell PATH, and common install directories. If ${providerLabel} is installed elsewhere, add that bin directory to your shell PATH and refresh.`; } if (extraBlocker) return extraBlocker; return null; diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts index 1fb242940..62becce1b 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.test.ts @@ -48,10 +48,13 @@ afterEach(() => { vi.useRealTimers(); }); +// These cover the POSIX signal path, which is why each reaper is pinned to +// "darwin": on Windows there are no signals, and the reaper kills the whole +// process tree with taskkill instead. describe("createClaudeSubprocessReaper", () => { it("registers and unregisters Claude subprocesses on exit", () => { const logger = createLogger(); - const reaper = createClaudeSubprocessReaper({ logger }); + const reaper = createClaudeSubprocessReaper({ logger, platform: "darwin" }); const child = createProcess(1234); reaper.register(child, { @@ -87,6 +90,7 @@ describe("createClaudeSubprocessReaper", () => { const spawnProcess = vi.fn(() => child); const reaper = createClaudeSubprocessReaper({ logger, + platform: "darwin", spawnProcess: spawnProcess as any, }); @@ -125,6 +129,7 @@ describe("createClaudeSubprocessReaper", () => { const child = createProcess(2468); const reaper = createClaudeSubprocessReaper({ logger, + platform: "darwin", killGraceMs: 25, }); reaper.register(child, { @@ -151,6 +156,7 @@ describe("createClaudeSubprocessReaper", () => { const otherChild = createProcess(2470); const reaper = createClaudeSubprocessReaper({ logger, + platform: "darwin", killGraceMs: 25, }); reaper.register(matchingChild, { @@ -190,7 +196,7 @@ describe("createClaudeSubprocessReaper", () => { child.killed = true; // Node-faithful: true after the first delivered signal. return true; }); - const reaper = createClaudeSubprocessReaper({ logger, killGraceMs: 25 }); + const reaper = createClaudeSubprocessReaper({ logger, platform: "darwin", killGraceMs: 25 }); reaper.register(child, { sessionId: "chat-hung", laneId: "lane-1", @@ -234,6 +240,7 @@ describe("createClaudeSubprocessReaper", () => { createClaudeSubprocessReaper({ logger, + platform: "darwin", killGraceMs: 25, registryPath, processKill, diff --git a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts index 3e164a710..1312ae063 100644 --- a/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts +++ b/apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts @@ -1,10 +1,11 @@ -import { spawn, type ChildProcessByStdio } from "node:child_process"; +import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { Readable, Writable } from "node:stream"; import type { SpawnOptions, SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; import type { Logger } from "../logging/logger"; +import { killWindowsProcessTree, resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; export type ClaudeSubprocessMetadata = { sessionId: string; @@ -22,6 +23,40 @@ export type ClaudeSubprocessRecord = ClaudeSubprocessMetadata & { }; type ClaudeChildProcess = ChildProcessByStdio; + +/** + * Image names a registered Claude subprocess can legitimately be running under + * on Windows: the resolved executable itself, or the interpreter that a shim + * hands off to (`claude.cmd` runs under cmd.exe, `claude.ps1` under PowerShell, + * an npm-linked entry under node.exe). + */ +function windowsExpectedImageNames(command: string): string[] { + const base = path.win32.basename(command).toLowerCase(); + const stem = base.replace(/\.(cmd|bat|ps1|exe|com)$/u, ""); + return [base, `${stem}.exe`, "cmd.exe", "powershell.exe", "pwsh.exe", "node.exe", "conhost.exe"]; +} + +function windowsPidImageName(pid: number): string | null { + try { + const result = spawnSync("tasklist.exe", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { + encoding: "utf8", + windowsHide: true, + timeout: 5_000, + }); + if (result.error || result.status !== 0) return null; + // CSV row: "image.exe","1234","Console","1","12,345 K" + const name = String(result.stdout ?? "").trim().match(/^"([^"]+)"/u)?.[1]; + return name ? name.toLowerCase() : null; + } catch { + return null; + } +} + +function windowsPidLooksLikeRecord(record: ClaudeSubprocessRecord): boolean { + const image = windowsPidImageName(record.pid); + if (!image) return true; + return windowsExpectedImageNames(record.command).includes(image); +} type LiveClaudeSubprocess = { record: ClaudeSubprocessRecord; process: SpawnedProcess; @@ -38,8 +73,12 @@ export function createClaudeSubprocessReaper(args: { clearTimer?: typeof clearTimeout; registryPath?: string | null; processKill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean; + /** Overridable so the POSIX and Windows kill paths can each be exercised. */ + platform?: NodeJS.Platform; }) { const logger = args.logger; + const platform = args.platform ?? process.platform; + const isWindows = platform === "win32"; const killGraceMs = args.killGraceMs ?? 5_000; const spawnProcess = args.spawnProcess ?? spawn; const setTimer = args.setTimer ?? setTimeout; @@ -121,6 +160,36 @@ export function createClaudeSubprocessReaper(args: { removeRegistryPid(record.pid); return; } + if (isWindows) { + // This registry outlives the app, and Windows recycles PIDs far faster + // than macOS, so a stale record can point at somebody else's process by + // the time we read it. Only refuse when Windows positively names an image + // that cannot be ours — an unreadable answer still gets reaped, because + // leaking a Claude subprocess is the worse failure. + if (!windowsPidLooksLikeRecord(record)) { + logger.warn("agent_chat.claude_subprocess_pid_reused", { + pid: record.pid, + sessionId: record.sessionId, + reason, + }); + removeRegistryPid(record.pid); + return; + } + logger.warn("agent_chat.claude_subprocess_terminate", { + pid: record.pid, + sessionId: record.sessionId, + reason, + }); + // `process.kill(pid, "SIGTERM")` on Windows is an immediate, ungraceful + // TerminateProcess of that one PID — it leaves the Claude binary's own + // children (ripgrep, MCP servers, node) orphaned. There is no graceful + // stage to escalate from, so kill the whole tree in one step. + killWindowsProcessTree(record.pid, (detail) => { + logger.warn("agent_chat.claude_subprocess_taskkill_failed", { ...detail, sessionId: record.sessionId }); + }); + removeRegistryPid(record.pid); + return; + } logger.warn("agent_chat.claude_subprocess_terminate", { pid: record.pid, sessionId: record.sessionId, @@ -220,11 +289,18 @@ export function createClaudeSubprocessReaper(args: { options: SpawnOptions, metadata: ClaudeSubprocessMetadata, ): SpawnedProcess => { - const child = spawnProcess(options.command, options.args, { + // A `.cmd`/`.bat` shim — what `npm i -g @anthropic-ai/claude-code` puts on + // PATH — cannot be handed to `spawn` directly: since the CVE-2024-27980 fix + // Node refuses it with EINVAL unless it goes through a command interpreter. + // `resolveCliSpawnInvocation` produces the correctly quoted cmd.exe (or + // PowerShell, for `.ps1`) invocation and is a no-op for a plain `.exe`. + const invocation = resolveCliSpawnInvocation(options.command, options.args, options.env, platform); + const child = spawnProcess(invocation.command, invocation.args, { cwd: options.cwd, env: options.env, signal: options.signal, stdio: ["pipe", "pipe", "ignore"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, windowsHide: true, }) as ClaudeChildProcess; register(child, metadata, options.command, options.args); @@ -247,7 +323,17 @@ export function createClaudeSubprocessReaper(args: { reason, }); try { - child.kill("SIGTERM"); + if (isWindows) { + // taskkill /T /F is the only way to take the Claude binary's own + // children (ripgrep, MCP servers, and the cmd.exe that fronts a `.cmd` + // shim) down with it — `child.kill("SIGTERM")` on Windows terminates + // this one PID and orphans the rest. + terminateProcessTree(child as unknown as Parameters[0], "SIGTERM", (detail) => { + logger.warn("agent_chat.claude_subprocess_taskkill_failed", { ...detail, sessionId: entry.record.sessionId }); + }); + } else { + child.kill("SIGTERM"); + } } catch { // Best effort; the process may already be gone. } diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index d930d5c55..f8bb255ee 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -50,6 +50,7 @@ import { shouldRefreshAiStatusForChatEvent } from "../../lib/aiProviderStatus"; import { showToast } from "../app/toast/toastStore"; import { ClaudeLoginPromptButton, revealTerminalSessionInWork } from "../work/ClaudeLoginPromptButton"; import { OAuthConnectModal } from "./OAuthConnectModal"; +import { rendererPlatformAttribute } from "../../lib/platform"; type CliName = "claude" | "codex" | "cursor" | "droid"; type ApiKeySource = "config" | "env" | "store"; @@ -85,6 +86,8 @@ const CLI_TOOLS: Array<{ authStory: string; loginCmd: string; installHint: string; + /** Used instead of installHint on Windows, where the vendor ships a different installer. */ + windowsInstallHint?: string; }> = [ { cli: "claude", @@ -92,6 +95,9 @@ const CLI_TOOLS: Array<{ authStory: "Uses your claude login — Claude Pro/Max subscription or ANTHROPIC_API_KEY.", loginCmd: "claude auth login or set ANTHROPIC_API_KEY", installHint: "npm install -g @anthropic-ai/claude-code", + // Anthropic's documented Windows installs: the PowerShell native installer + // (drops claude.exe in %USERPROFILE%\.localin) or WinGet. + windowsInstallHint: "irm https://claude.ai/install.ps1 | iex (PowerShell), or winget install Anthropic.ClaudeCode", }, { cli: "codex", @@ -387,6 +393,12 @@ function describeCredentialSource(connection: AiProviderConnectionStatus | null return null; } +const isWindowsRenderer = rendererPlatformAttribute() === "win32"; + +function installHintFor(tool: (typeof CLI_TOOLS)[number]): string { + return (isWindowsRenderer && tool.windowsInstallHint) || tool.installHint; +} + function buildCliMessage(tool: (typeof CLI_TOOLS)[number], connection: AiProviderConnectionStatus | null | undefined): string { if (connection?.runtimeAvailable) { return "Connection verified."; @@ -398,9 +410,12 @@ function buildCliMessage(tool: (typeof CLI_TOOLS)[number], connection: AiProvide return `CLI detected but not signed in. Run: ${tool.loginCmd}`; } if (connection?.authAvailable && !connection.runtimeDetected) { - return `Local credentials exist but CLI not found in PATH. Install: ${tool.installHint}`; + return `Local credentials exist but CLI not found in PATH. Install: ${installHintFor(tool)}`; } - return `CLI not found in PATH. Install: ${tool.installHint}. If already installed, ensure it is on your shell PATH and use Refresh.`; + const pathAdvice = isWindowsRenderer + ? "If already installed, add its folder to your Windows PATH (System Properties -> Environment Variables), reopen ADE, and use Refresh." + : "If already installed, ensure it is on your shell PATH and use Refresh."; + return `CLI not found in PATH. Install: ${installHintFor(tool)}. ${pathAdvice}`; } function formatLocalModelLabel(modelId: string): string { From 679e1a1179db5783d3fa35a70b34ddcbbd5c5e3c Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:35:58 -0400 Subject: [PATCH 24/51] fix(windows): make OpenCode session import work off a .cmd shim OpenCode CLI session import called `execFile` on the resolved binary directly. On Windows `npm i -g opencode-ai` installs `%APPDATA%\npm\opencode.cmd`, and Node refuses to spawn a `.cmd`/`.bat` without a shell -- verified on Windows 11, it fails with a bare `spawn EINVAL` that surfaces as "OpenCode session discovery failed: spawn EINVAL". The same global install is a directly executable script on macOS, so import worked there and only broke on Windows. Route the spawn through `resolveCliSpawnInvocation`, which shims those targets through cmd.exe and leaves a real `.exe` alone. Separately, `opencode session list --format json` prints nothing at all when the user has no sessions rather than an empty array, so discovery raised "returned no JSON session list" for anyone who had simply never used the OpenCode CLI -- which is every fresh Windows machine. Treat empty output as an empty list and keep the error for output that is non-empty but has no array in it. Based-on: nsxdavid/ADE#999 --- .../externalSessions/discoverOpenCode.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts b/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts index 2ee1180e1..8a60086b3 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverOpenCode.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import path from "node:path"; import { promisify } from "node:util"; import { resolveOpenCodeBinaryPath } from "../opencode/openCodeBinaryManager"; +import { resolveCliSpawnInvocation } from "../shared/processExecution"; import { asEpochMs, asRecord, @@ -42,11 +43,23 @@ export async function discoverOpenCodeSessions( const env: NodeJS.ProcessEnv = { ...process.env, ...(args.env ?? {}), NO_COLOR: "1" }; delete env.FORCE_COLOR; + // `npm i -g opencode-ai` installs `%APPDATA%\npm\opencode.cmd` on Windows, and + // Node refuses to spawn a `.cmd`/`.bat` without a shell (it fails with a bare + // `spawn EINVAL`). The same install is a directly executable script on macOS, + // so this path only breaks on Windows. Route through the shared invocation + // helper, which shims those targets through cmd.exe and leaves a real `.exe` + // untouched. + const invocation = resolveCliSpawnInvocation( + executable, + ["session", "list", "--pure", "--format", "json", "--max-count", String(requestedLimit)], + env, + ); + let stdout: string; try { const result = await execFileAsync( - executable, - ["session", "list", "--pure", "--format", "json", "--max-count", String(requestedLimit)], + invocation.command, + invocation.args, { cwd: path.resolve(cwd), encoding: "utf8", @@ -54,6 +67,8 @@ export async function discoverOpenCodeSessions( killSignal: "SIGTERM", maxBuffer: 2 * 1024 * 1024, env, + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }, ); stdout = String(result.stdout ?? ""); @@ -62,6 +77,11 @@ export async function discoverOpenCodeSessions( } const jsonStart = stdout.indexOf("["); if (jsonStart < 0) { + // `opencode session list --format json` prints nothing at all when there are + // no sessions rather than an empty array, so a user who has simply never run + // the OpenCode CLI is not an error -- it is an empty result. Only treat + // non-empty output with no array in it as a genuine protocol failure. + if (stdout.trim().length === 0) return []; throw new Error("OpenCode session discovery returned no JSON session list."); } From f7c7c879e7bb00774f2f4ead6609cb4e1fc7457b Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:36:49 -0400 Subject: [PATCH 25/51] fix(windows): honour CODEX_HOME when discovering Codex prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's user-level prompt directory lives under CODEX_HOME, not unconditionally under the home directory. Every other Codex home resolution in the tree already reads CODEX_HOME first — session discovery, rollout probing, credential sources, usage ledgers — but slash-command discovery hardcoded `os.homedir()/.codex/prompts`, so relocating CODEX_HOME made every user `/`-prompt disappear. Relocating CODEX_HOME off the roaming profile is a routine Windows move, which is where this surfaces. The ancestor walk also compared `path.resolve(cwd)` to `os.homedir()` with a case-sensitive `===`. Windows paths are case-insensitive and these two routinely disagree on drive-letter case, so the home-directory stop was missed and the walk ran to its 25-level cap, inventing `C:\Users\.codex\prompts` and `C:\.codex\prompts` roots above the user's profile. Based-on: nsxdavid/ADE#999 --- .../chat/codexSlashCommandDiscovery.ts | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts index fc4c25b6f..aada91720 100644 --- a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts @@ -20,8 +20,26 @@ export type ResolvedCodexSlashCommandInvocation = { argumentsText: string; }; +function codexHomeDir(env: NodeJS.ProcessEnv = process.env): string { + const configured = typeof env.CODEX_HOME === "string" ? env.CODEX_HOME.trim() : ""; + return configured ? path.resolve(configured) : path.join(os.homedir(), ".codex"); +} + +function samePath(left: string, right: string): boolean { + // Windows paths are case-insensitive, and `path.resolve(cwd)` and + // `os.homedir()` routinely disagree on drive-letter case. A case-sensitive + // compare would miss the home-directory stop and keep walking to the depth + // cap, inventing `C:\Users\.codex` and `C:\.codex` prompt roots. + return process.platform === "win32" + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + function codexPromptRoots(cwd: string): string[] { - const roots: string[] = [path.join(os.homedir(), ".codex", "prompts")]; + // Codex resolves its user-level prompt directory under CODEX_HOME, which its + // own installers set and which is commonly relocated off the roaming profile + // on Windows. Only the per-project `.codex/prompts` walk is cwd-relative. + const roots: string[] = [path.join(codexHomeDir(), "prompts")]; const seen = new Set(roots); const home = os.homedir(); let current = path.resolve(cwd); @@ -34,7 +52,7 @@ function codexPromptRoots(cwd: string): string[] { } const parent = path.dirname(current); if (parent === current) break; - if (current === home) break; + if (samePath(current, home)) break; current = parent; depth += 1; } From 6fcd0d51457da2767027438573ff18fa1f2565e9 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:38:03 -0400 Subject: [PATCH 26/51] fix(windows): stop rendering an unreadable session as a sign-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop reported `signedIn: false` for two unrelated states: a machine that is genuinely signed out, and a machine whose stored session could not be decrypted on that particular read. `accountBridge.toAccountStatus` dropped the daemon's `sessionReadState`, so the renderer had no way to tell them apart and flipped the whole app to signed-out either way. On Windows the second state is reachable in normal use. The OS-bound credential key is unwrapped by a PowerShell/DPAPI helper, and when that helper is slow or fails the key material read throws, the credential store records `unreadable`, and `getStatus()` answers `signedIn: false` while the session is still intact on disk. macOS never grew the same exposure: the Keychain material is fetched once by `security` and cached, and a failure there returns null rather than throwing. The brain already draws this distinction — the account-directory publisher splits `account_signed_out` from `token_unreadable` on exactly this field. Carry the same field to the renderer and hold the last known identity across an unreadable read; an explicit sign-out reports `missing` and is never retained. Based-on: nsxdavid/ADE#999 --- .../src/main/services/account/accountBridge.ts | 15 ++++++++++++++- apps/desktop/src/renderer/lib/account.ts | 18 ++++++++++++++++++ apps/desktop/src/shared/types/account.ts | 12 ++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index 1c3f75159..1fb81f55f 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -32,6 +32,7 @@ import type { AdeAccountMachinesResult, AdeAccountPairMachineProgress, AdeAccountLoginPoll, + AdeAccountSessionReadState, AdeAccountStatus, } from "../../../shared/types"; import { @@ -115,6 +116,7 @@ function resolveDirectoryBaseUrl(projectRoot: string | null): string | null { function toAccountStatus( status: AccountAuthStatus, configured: boolean, + sessionReadState?: AdeAccountSessionReadState, ): AdeAccountStatus { return { signedIn: status.signedIn, @@ -125,6 +127,7 @@ function toAccountStatus( provider: status.provider ?? null, imageUrl: status.imageUrl ?? null, configured, + ...(sessionReadState ? { sessionReadState } : {}), }; } @@ -229,7 +232,17 @@ export function createAccountBridge(options: AccountBridgeOptions): AccountBridg }; return { - status: () => toAccountStatus(service().getStatus(), configured()), + status: () => { + const accountService = service(); + // Read the state alongside the status: `signedIn: false` with an + // "unreadable" session is a failed decrypt, not a sign-out, and the + // renderer must be able to tell them apart. + const status = accountService.getStatus(); + // Optional call: a runtime that predates the split simply reports no read + // state, and the renderer then falls back to its previous behaviour. + const readState = accountService.getSessionReadState?.(); + return toAccountStatus(status, configured(), readState); + }, startLogin: () => { // Prioritize the active project's CLERK_* secrets for config resolution. diff --git a/apps/desktop/src/renderer/lib/account.ts b/apps/desktop/src/renderer/lib/account.ts index f805f882a..a84ffbcd5 100644 --- a/apps/desktop/src/renderer/lib/account.ts +++ b/apps/desktop/src/renderer/lib/account.ts @@ -61,6 +61,17 @@ function emit(status: AdeAccountStatus): void { } } +/** + * A status that only says "signed out" because the stored session could not be + * READ. Signing out is an explicit user action and a durable state; a failed + * decrypt is neither. On Windows the OS-bound credential key is unwrapped by a + * PowerShell/DPAPI helper that can transiently time out under load, which is + * how a machine that never signed out ends up rendering as signed out. + */ +function isUnreadableSession(status: AdeAccountStatus): boolean { + return !status.signedIn && status.sessionReadState === "unreadable"; +} + export async function fetchAccountStatus(options?: { force?: boolean }): Promise { const api = accountApi(); if (!api?.status) return SIGNED_OUT_ACCOUNT; @@ -77,9 +88,16 @@ export async function fetchAccountStatus(options?: { force?: boolean }): Promise .status() .then((status) => { const normalized = status ?? SIGNED_OUT_ACCOUNT; + // Hold the last known identity across an unreadable read instead of + // flashing the whole app to signed-out. The next successful read wins, + // and a real sign-out reports "missing", which is never retained. + const previous = cachedStatus?.value; + if (isUnreadableSession(normalized) && previous?.signedIn) return previous; if (serial === fetchSerial) emit(normalized); return normalized; }) + // A failed status call is an unknown state, not a signed-out one. Only + // fabricate SIGNED_OUT_ACCOUNT when nothing was ever known. .catch(() => cachedStatus?.value ?? SIGNED_OUT_ACCOUNT) .finally(() => { if (serial === fetchSerial) inFlight = null; diff --git a/apps/desktop/src/shared/types/account.ts b/apps/desktop/src/shared/types/account.ts index dabd685ed..f5ef3c562 100644 --- a/apps/desktop/src/shared/types/account.ts +++ b/apps/desktop/src/shared/types/account.ts @@ -26,8 +26,20 @@ export type AdeAccountStatus = { * unavailable. Lets the UI explain "not configured" instead of failing hard. */ configured?: boolean; + /** + * Why `signedIn` is false. "missing" is a real signed-out machine; + * "unreadable" means the stored session could not be decrypted on this read + * (on Windows the OS-bound key comes from a PowerShell/DPAPI helper that can + * transiently time out) and says NOTHING about whether the user is signed in. + * The brain's directory publisher has always split these two — the desktop + * dropped the distinction and rendered every failed read as a sign-out. + */ + sessionReadState?: AdeAccountSessionReadState; }; +/** Mirrors the daemon's `AccountSessionReadState`. */ +export type AdeAccountSessionReadState = "available" | "missing" | "unreadable"; + export type AdeAccountLoginStart = { sessionId: string; authorizeUrl: string; From fec37801264bfa15afd884194a24d68af8388dd9 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:38:16 -0400 Subject: [PATCH 27/51] fix(windows): keep the This-Machine card alive without the background service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sync.getLocalStatus` was the only source for the This-Machine card, and it rejected outright whenever the machine-scoped runtime call failed. The card then collapsed to "Couldn't load connection details / Restart ADE, then try again" — with no machine name, platform or reason — even though almost everything it shows is machine identity the desktop can answer on its own. Reproduced end to end: with the local runtime unreachable, the renderer's call rejects with `brain_not_installed` ("ADE runtime is unavailable at \.\pipe\ade-runtime-...; refusing to spawn an app-owned brain on a primary channel socket"), and `connectionLoadGuidance` buries that actionable diagnosis under generic restart advice. Every other row in the panel keeps working because saved machines, account machines and remote connections all run in the desktop main process and never touch the local brain — which is why the panel looks half-broken rather than broken. macOS parity: launchd keeps the machine brain alive, so a Mac essentially never observes an unreachable local runtime and the card looks fine. The Windows startup supervisor does leave the brain down — this machine's own `~/.ade/runtime/last-failure.prev.json` records `listen EADDRINUSE \.\pipe\ade-runtime` from `brain_startup` — so Windows is where the missing fallback became a visibly broken panel. Answer with a machine-only snapshot instead, mirroring the degradation the brain already performs for its own no-sync-host case: real device id and site id read from the same secrets files, real hostname and platform, every route honestly reported down, and the true failure carried in the listener reason and blocking text. The "starting up" line now yields to that reason, since a down listener never resolves on its own, and the release-build guidance no longer tells Windows users to install into "Applications". Based-on: nsxdavid/ADE#999 --- .../src/main/services/ipc/registerIpc.ts | 23 ++- .../services/sync/machineOnlySyncSnapshot.ts | 164 ++++++++++++++++++ .../settings/SyncDevicesSection.tsx | 13 +- 3 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/main/services/sync/machineOnlySyncSnapshot.ts diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 8b9cf6d9a..23c652788 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -25,6 +25,7 @@ import type { } from "../../../shared/types/attention"; import { ATTENTION_CONTRACT_VERSION } from "../../../shared/types/attention"; import { isSyncServiceUnavailableError } from "../../../shared/runtimeErrors"; +import { buildMachineOnlySyncSnapshot } from "../sync/machineOnlySyncSnapshot"; import { encodeCodedErrorMessage, parseCodedErrorMessage } from "../../../shared/codedError"; import { areAutomationsEnabledForPackagedState } from "../../../shared/automationAvailability"; import { findRecentProjectForRepo } from "../projects/repoProjectResolver"; @@ -4976,16 +4977,32 @@ export function registerIpc({ if (localRuntimeConnectionPool) { try { // Machine-level call: intentionally bypasses the window's local/remote - // project binding so Connections can always describe this physical Mac. + // project binding so Connections can always describe this computer. return await localRuntimeConnectionPool.callSync( "sync.getStatus", params, ); } catch (error) { - if (!isSyncServiceUnavailableError(error)) throw error; + if (!isSyncServiceUnavailableError(error)) { + // This is the ONLY source for the This-Machine card, and the card is + // mostly about identity — the machine's name, platform and whether it + // is reachable — which the desktop can answer without the background + // service. Rejecting here blanked the whole card behind "Couldn't + // load connection details" whenever the local runtime was down. + // Answer with a machine-only snapshot whose routes are honestly all + // down and whose blocking text carries the real reason instead. + getCtx().logger.warn("sync.local_status_degraded", { + error: error instanceof Error ? error.message : String(error), + }); + return buildMachineOnlySyncSnapshot(error); + } } } - return await (await requireSyncService()).getStatus(params); + try { + return await (await requireSyncService()).getStatus(params); + } catch (error) { + return buildMachineOnlySyncSnapshot(error); + } }); ipcMain.handle(IPC.syncRefreshDiscovery, async (event): Promise => { diff --git a/apps/desktop/src/main/services/sync/machineOnlySyncSnapshot.ts b/apps/desktop/src/main/services/sync/machineOnlySyncSnapshot.ts new file mode 100644 index 000000000..599421568 --- /dev/null +++ b/apps/desktop/src/main/services/sync/machineOnlySyncSnapshot.ts @@ -0,0 +1,164 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import { + createSyncAccountDirectoryHealth, + type SyncDeviceRecord, + type SyncPeerPlatform, + type SyncRoleSnapshot, +} from "../../../shared/types"; + +/** + * The This-Machine card in Connections describes the computer ADE runs on: + * its name, platform, and whether it is currently reachable. None of that is + * knowledge the background service owns — the desktop can answer all of it + * from the machine layout — yet the only source wired up was a machine-scoped + * `sync.getStatus` on the local runtime. When the background service is not + * reachable that call rejects, and the whole card collapses to "Couldn't load + * connection details" with no machine identity at all. + * + * The brain already solves exactly this for its own no-sync-host case + * (`getMachineOnlySyncStatus` in multiProjectRpcServer): it answers with a + * machine-only snapshot whose route health is honestly all-down and whose + * blocking text explains what is missing. This is that same degradation, moved + * one layer out so it also covers "the runtime could not be reached at all". + * + * macOS parity note: launchd keeps the machine brain alive, so a Mac almost + * never observes an unreachable local runtime and the card looks fine. The + * Windows startup supervisor genuinely does leave the brain down (a failed + * bind, a channel collision on the runtime pipe), so Windows is where the + * missing fallback became a visibly broken panel. + */ + +/** Device identity the brain persists per machine; readable without the brain. */ +function readMachineSyncIdentity(fileName: string): string { + try { + return fs.readFileSync( + path.join(resolveMachineAdeLayout().secretsDir, fileName), + "utf8", + ).trim(); + } catch { + return ""; + } +} + +function mapPlatform(platform: NodeJS.Platform): SyncPeerPlatform { + if (platform === "darwin") return "macOS"; + if (platform === "win32") return "windows"; + if (platform === "linux") return "linux"; + return "unknown"; +} + +function errorText(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.trim() || "The ADE background service is not reachable."; +} + +/** + * A snapshot describing THIS machine with every route reported down. + * + * `localDevice.deviceId` is read from the same file the brain uses, so a + * caller comparing this snapshot's device id against a routed snapshot's (how + * the renderer detects remote binding) still gets the right answer. When the + * id has never been written the field is empty, which no routed snapshot can + * match — the renderer treats that as "not remote-bound", which is correct for + * a machine whose brain has never run. + */ +export function buildMachineOnlySyncSnapshot(error: unknown): SyncRoleSnapshot { + const now = new Date().toISOString(); + const reason = errorText(error); + const hostname = os.hostname(); + const localDevice: SyncDeviceRecord = { + deviceId: readMachineSyncIdentity("sync-device-id"), + siteId: readMachineSyncIdentity("sync-site-id"), + name: hostname, + platform: mapPlatform(process.platform), + deviceType: "desktop", + createdAt: now, + updatedAt: now, + lastSeenAt: now, + lastHost: hostname, + lastPort: null, + tailscaleIp: null, + ipAddresses: [], + metadata: { hostname }, + }; + return { + mode: "standalone", + role: "brain", + runtimeMode: "standalone", + runtimeRole: "host", + localDevice, + currentBrain: localDevice, + currentRuntime: localDevice, + clusterState: null, + bootstrapToken: null, + pairingPin: null, + pairingPinConfigured: false, + runtimeName: null, + pairingConnectInfo: null, + connectedPeers: [], + tailnetDiscovery: { + state: "disabled", + serviceName: "svc:ade-sync", + servicePort: 0, + target: null, + updatedAt: null, + error: reason, + stderr: null, + }, + routeHealth: { + listener: { + listenerBound: false, + loopbackAdeValidated: false, + port: null, + lastFailureAt: now, + reason, + lastSuccessAt: null, + }, + tailscale: { + enabled: false, + tailscalePublished: false, + tailscaleReachable: false, + lastFailureAt: now, + reason, + lastSuccessAt: null, + }, + relay: { + enabled: false, + relayControlConnected: false, + relayBridgeValidated: false, + lastFailureAt: now, + skipReason: reason, + lastControlError: null, + lastControlOpenAt: null, + lastBridgeValidationAt: null, + }, + accountDirectory: createSyncAccountDirectoryHealth("no_active_sync_scope", reason), + }, + client: { + state: "disconnected", + host: null, + port: null, + connectedAt: null, + lastSeenAt: null, + latencyMs: null, + syncLag: null, + lastRemoteDbVersion: 0, + brainDeviceId: null, + hostDeviceId: null, + hostName: null, + error: null, + message: reason, + savedDraft: null, + }, + transferReadiness: { + ready: false, + blockers: [], + survivableState: [], + }, + survivableStateText: reason, + blockingStateText: reason, + }; +} diff --git a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx index 31a7cc032..ae1b686e7 100644 --- a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx @@ -99,7 +99,10 @@ function formatLatency(value: number | null | undefined): string { function connectionLoadGuidance(error: string): string { if (isLocalReleaseBuildOutputError(error)) { - return "Install this ADE build in Applications, reopen it, then try again."; + // Was macOS-only copy ("in Applications"), which names a folder Windows + // does not have. The instruction is the same on every platform: run an + // installed build, not the build output directory. + return "Install this ADE build, reopen it from the installed copy, then try again."; } if (isProjectRegistrationRequiredError(error)) { return "Open a project in ADE, then try again."; @@ -380,7 +383,13 @@ function acceptsConnectionsState( return { ready: false, label: "Phone sync is unavailable" }; } if (!status.pairingConnectInfo) { - return { ready: false, label: "Starting up — connection details will appear shortly" }; + // "Starting up" is only true while the listener is on its way up. When the + // snapshot already knows why the listener is down (the background service + // is not reachable, a port could not be bound), say that instead — the + // reason is the whole point of this line and it never resolves on its own. + const listener = status.routeHealth?.listener; + const reason = listener?.listenerBound === false ? listener.reason?.trim() : null; + return { ready: false, label: reason || "Starting up — connection details will appear shortly" }; } if (!status.pairingPinConfigured) { return { ready: false, label: "Set a pairing code below so new devices can connect" }; From cfc76c7fc6fa00fbe19ab57ed219ecfd605926c5 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:39:05 -0400 Subject: [PATCH 28/51] fix(windows): stop sending the Codex planner prompt through cmd.exe argv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runCodexExec` appended the planner prompt as the final `codex exec` argument. On macOS that reaches execve untouched. On Windows the resolved launcher is frequently the npm `codex` / `codex.cmd` shim, which cannot be handed to CreateProcess, so ADE routes the spawn through `cmd.exe /d /s /c "…"` — and cmd corrupts the prompt three different ways. Measured against the real launcher: percent + env ref sent 62 chars, arrived 67: "50%" became "50%%" and "%USERPROFILE%" was expanded to "%C:\Users\arul2%" multi-line newlines collapsed to spaces 9000 chars exit 1, "The command line is too long." Planner prompts carry lane branch lists, suite lists and multi-line user intent, so all three are reachable in normal use — the length limit silently turns every large project's automation planning into a hard failure. Pipe the prompt over stdin with `codex exec -`, which is what the provider task runner already does for exactly this reason. Re-measured through the same cmd wrapper, all three cases now arrive byte-identical. Also settle the run on `close` rather than `exit`: on Windows the stdio pipes drain after the process is reaped, so the stderr embedded in the "Codex exited with code N" message was being truncated. Based-on: nsxdavid/ADE#999 --- .../automations/automationPlannerService.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index 214674fa6..a1b3ed453 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -421,7 +421,15 @@ async function runCodexExec(args: { } } - cliArgs.push(args.prompt); + // `-` makes Codex read the prompt from stdin. Never put it on the command + // line: when the resolved launcher is a `.cmd`/extensionless npm shim, ADE + // has to route the spawn through `cmd.exe /d /s /c "…"`, and cmd mangles the + // prompt in three separate ways that execve on macOS does not — `%` is + // doubled and environment references are expanded, newlines collapse to + // spaces, and anything past ~8191 characters fails outright with "The command + // line is too long." Planner prompts routinely carry lane branch lists and + // multi-line user intent, so all three are reachable. + cliArgs.push("-"); let codexExecutable: string; try { @@ -449,7 +457,7 @@ async function runCodexExec(args: { const child = spawn(invocation.command, invocation.args, { cwd: args.cwd, env, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, windowsHide: true, }); @@ -462,7 +470,15 @@ async function runCodexExec(args: { const exitCode = await new Promise((resolve, reject) => { child.on("error", reject); - child.on("exit", (code) => resolve(code)); + // `close` rather than `exit`: on Windows the stdio pipes drain after the + // process is reaped, so settling on `exit` truncates the stderr that ends + // up in the "Codex exited with code N" message. + child.on("close", (code) => resolve(code)); + child.stdin?.on("error", () => { + // Codex can exit before the prompt is fully written; the exit code and + // stderr are the authoritative failure signal. + }); + child.stdin?.end(args.prompt); }); try { From 89681d20fdab8c4626464dd1767c652c6a695e19 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:40:31 -0400 Subject: [PATCH 29/51] fix(windows): keep the Codex work-tab prompt off the cmd.exe command line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For `gpt-5.3-codex`, ADE passes the work-tab prompt as a positional `codex` argument instead of typing it after launch. The launcher is the bare command `codex`, which has no extension on Windows and therefore always has to be spawned through `cmd.exe /d /s /c "…"`, so cmd rewrites the prompt before Codex ever sees it. Measured on the real launch descriptor: prompt in argv: true promptLen=2374 newlines=20 percent=2 newlines survive quoting: false percent doubled: true So the multi-line ADE preamble arrives as one flattened line, `%` is doubled, `%NAME%` sequences are expanded against the environment, and the whole command line is capped at ~8191 characters once the user's own prompt is appended. On macOS the same args reach execve untouched, which is why this never showed up. Fall back on Windows to the post-launch `initialInput` path that every other Codex model already uses. The resume path already drops `promptAtLaunch` for the same reason; this closes the fresh-launch half. Based-on: nsxdavid/ADE#999 --- apps/desktop/src/shared/cliLaunch.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index e15fcc59f..730e0d223 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -622,7 +622,18 @@ export function buildTrackedCliLaunchCommand(args: { ...codexComputerUseMcpFlags(args.codexComputerUse), ...permissionModeToCodexFlags(permissionMode), ]; - const usePromptArg = codexModel === "gpt-5.3-codex"; + // The launcher is the bare command `codex`, which on Windows has no + // extension and therefore always has to be spawned through + // `cmd.exe /d /s /c "…"`. cmd rewrites the command line before Codex sees + // it: `%` is doubled, `%NAME%` is expanded, newlines collapse to spaces, + // and the whole line is capped at ~8191 characters. The work-tab prompt is + // a ~2.4KB multi-line ADE preamble with the user's text appended, so + // passing it as argv corrupts it on every Windows launch. Fall back to the + // post-launch input path that every other Codex model already uses. + const platform = typeof process !== "undefined" && typeof process.platform === "string" + ? process.platform + : ""; + const usePromptArg = codexModel === "gpt-5.3-codex" && platform !== "win32"; if (usePromptArg) commandArgs.push(initialInput); return { command: "codex", From d63281178005758cfd3251243e9a51ba4e6c87ea Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:41:09 -0400 Subject: [PATCH 30/51] fix(windows): match Cursor's own project-slug rule byte for byte ADE had two different, home-grown slug rules for `~/.cursor/projects/` and both were wrong. `cursorProjectSlugForCwd` hyphenated every separator, producing `C:-Users-me-repo` on Windows -- a name NTFS cannot hold, so the scope filter never matched. `cursorProjectSlugForPath` kept `_` and dropped `.`, so `my.app` and `node_modules` slugged differently from Cursor. The two agreed on macOS by accident, which is why nobody noticed. @cursor/sdk 1.0.23 ships the real rule in `../utils/dist/index.js`: p.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "") Reimplement exactly that once, in shared/cursorProjectSlug, and use it from both the SDK policy layer and CLI session discovery. On Windows the drive letter survives as its own segment, so `C:\repo` is `C-repo`. The shared slug-to-cwd resolver walks down from the current drive root and so cannot invert that leading `C-` segment; retry without it in discoverCursor. Without this, imported Cursor sessions came back with `cwd: null` on Windows and could not be opened in the right lane. Supersedes the drive-dropping rule added in 2ec0719b. Based-on: nsxdavid/ADE#999 --- .../services/chat/cursorSdkPolicy.test.ts | 11 +++--- .../src/main/services/chat/cursorSdkPolicy.ts | 8 +--- .../externalSessions/discoverCursor.test.ts | 26 ++++++------- .../externalSessions/discoverCursor.ts | 39 +++++++++++++------ .../discoverProviders.test.ts | 6 +-- apps/desktop/src/shared/cursorProjectSlug.ts | 28 +++++++++++++ 6 files changed, 78 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/shared/cursorProjectSlug.ts diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts index daca89399..9ee573a9b 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts @@ -10,6 +10,7 @@ import { resolveCursorSdkPolicy, summarizeCursorHook, } from "./cursorSdkPolicy"; +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; describe("Cursor SDK policy", () => { it("maps Cursor modes to ADE permission policies", () => { @@ -205,11 +206,11 @@ describe("Cursor SDK policy", () => { const userHomeDir = path.join(fsRoot, "Users", "admin"); const laneRoot = path.join(userHomeDir, "Projects", "Versic", ".ade", "worktrees", "private-sharing-5d14c47a"); const slug = cursorProjectSlugForPath(laneRoot); - const expectedSlug = [ - ...fsRoot.split(/[\\/]+/u).filter(Boolean).map((part) => part.replace(/[^A-Za-z0-9_-]+/gu, "")), - "Users-admin-Projects-Versic-ade-worktrees-private-sharing-5d14c47a", - ].filter(Boolean).join("-"); - expect(slug).toBe(expectedSlug); + // Cursor's own rule: every non-alphanumeric character becomes a dash, runs + // collapse, leading/trailing dashes are trimmed. The Windows drive letter + // therefore survives as a leading `C-` segment. + expect(slug).toBe(cursorProjectSlug(path.resolve(laneRoot))); + expect(slug).toMatch(/Users-admin-Projects-Versic-ade-worktrees-private-sharing-5d14c47a$/u); const transcript = summarizeCursorHook({ toolName: "read", diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts index 87ad44672..997a3618f 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { AgentChatSession } from "../../../shared/types"; +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; import type { CursorSdkApprovalPolicy, CursorSdkChatMode, @@ -371,12 +372,7 @@ function resolveCandidatePath(candidate: string, cwd: string, userHomeDir?: stri } export function cursorProjectSlugForPath(projectPath: string): string { - return path.resolve(projectPath) - .split(/[\\/]+/) - .filter(Boolean) - .map((component) => component.replace(/^\.+/, "").replace(/[^A-Za-z0-9_-]+/g, "")) - .filter(Boolean) - .join("-"); + return cursorProjectSlug(path.resolve(projectPath)); } function cursorSupportReadRoots(laneRoot: string, userHomeDir?: string | null): string[] { diff --git a/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts b/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts index 577c6169a..9a836422a 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCursor.test.ts @@ -4,17 +4,7 @@ import os from "node:os"; import path from "node:path"; import { discoverCursorSessions } from "./discoverCursor"; -/** - * Mirrors `cursorProjectSlugForCwd` in discoverCursor.ts. A Windows drive - * prefix has to be dropped rather than hyphenated, because `C:-Users-…` - * contains a colon and can therefore never be a real directory name. - */ -function cursorProjectSlug(cwd: string): string { - return cwd - .replace(/^([A-Za-z]):[\\/]+/u, "") - .replace(/^[/\\]+/u, "") - .replace(/[\\/]/gu, "-"); -} +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; function writeTranscript(home: string, slug: string, agentId: string, cwd: string): void { const dir = path.join(home, ".cursor", "projects", slug, "agent-transcripts", agentId); @@ -48,11 +38,17 @@ describe("discoverCursorSessions", () => { } }); + it("matches Cursor's own project slug rule", () => { + // Byte-for-byte from @cursor/sdk's shipped slug function. + expect(cursorProjectSlug("C:\\Users\\me\\repo")).toBe("C-Users-me-repo"); + expect(cursorProjectSlug("/Users/me/repo")).toBe("Users-me-repo"); + expect(cursorProjectSlug("/Users/me/my.app/node_modules")).toBe("Users-me-my-app-node-modules"); + expect(cursorProjectSlug("C:\\repo\\.ade\\worktrees\\lane-1")).toBe("C-repo-ade-worktrees-lane-1"); + }); + it("keeps a transcript in scope when its workspace directory no longer exists", async () => { - // Without the drive-prefix fix this drops the session on Windows: the - // slug-to-cwd resolver cannot help once the directory is gone, so the - // structural slug comparison is the only thing left, and it can never - // match a slug containing `C:`. macOS has no drive prefix and passes. + // The slug-to-cwd resolver cannot help once the directory is gone, so the + // structural slug comparison is the only thing left. const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-import-gone-")); const home = path.join(root, "home"); const workspace = path.join(root, "deleted-repo"); diff --git a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts index 174bc99da..3f82d6821 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { createRequire } from "node:module"; import path from "node:path"; import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; import { asEpochMs, asRecord, @@ -39,14 +40,30 @@ const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: DatabaseSyncConstructor }; function cursorProjectSlugForCwd(cwd: string): string { - return cwd - // A Windows drive prefix must be dropped, not hyphenated: `C:-Users-…` - // contains a colon, so it can never equal a real directory name, and - // `resolveCursorCwdFromSlug` (which walks down from the drive root) cannot - // invert it either. The drive-less slug round-trips correctly. - .replace(/^([A-Za-z]):[\\/]+/u, "") - .replace(/^[/\\]+/u, "") - .replace(/[\\/]/gu, "-"); + return cursorProjectSlug(cwd); +} + +/** + * Cursor's slug keeps the Windows drive letter as its first segment + * (`C:\repo` becomes `C-repo`), but the shared slug resolver walks down from + * the current drive root and so never finds a directory literally named `C`. + * Retry without the drive segment when the slug starts with one. + */ +function resolveCursorProjectCwd(slug: string): string | null { + const direct = resolveCursorCwdFromSlug(slug); + if (direct) return direct; + if (process.platform !== "win32") return null; + const withoutDrive = /^([A-Za-z])-(.+)$/u.exec(slug)?.[2]; + return withoutDrive ? resolveCursorCwdFromSlug(withoutDrive) : null; +} + +function cursorProjectCwdCandidates(slug: string): string[] { + const candidates = [...cursorSlugCwdCandidates(slug)]; + if (process.platform === "win32") { + const withoutDrive = /^([A-Za-z])-(.+)$/u.exec(slug)?.[2]; + if (withoutDrive) candidates.push(...cursorSlugCwdCandidates(withoutDrive)); + } + return candidates; } type CursorStoreCandidate = ExternalSessionFileCandidate<{ @@ -72,7 +89,7 @@ function cursorWorkspaceByHash( for (const projectEntry of safeReadDir(projectsDir)) { if (!projectEntry.isDirectory()) continue; const projectDir = path.join(projectsDir, projectEntry.name); - const cwd = trustedCursorWorkspacePath(projectDir) ?? resolveCursorCwdFromSlug(projectEntry.name); + const cwd = trustedCursorWorkspacePath(projectDir) ?? resolveCursorProjectCwd(projectEntry.name); if (!cwd || !cwdIsInScope(cwd, scopeRoots)) continue; result.set(cursorWorkspaceHash(cwd), cwd); } @@ -176,7 +193,7 @@ export async function discoverCursorSessions( if ( !cwdIsInScope(trustedCwd, args.scopeRoots) && !slugMatchesScopeRoots(projectEntry.name, args.scopeRoots, cursorProjectSlugForCwd) - && !cwdCandidatesIncludeScope(cursorSlugCwdCandidates(projectEntry.name), args.scopeRoots) + && !cwdCandidatesIncludeScope(cursorProjectCwdCandidates(projectEntry.name), args.scopeRoots) ) { continue; } @@ -228,7 +245,7 @@ export async function discoverCursorSessions( if (!jsonl.length) continue; const first = asRecord(jsonl[0]); const transcriptCwd = cursorCwdFromRecords(jsonl); - const cwd = transcriptCwd ?? candidate.trustedCwd ?? resolveCursorCwdFromSlug(candidate.projectSlug); + const cwd = transcriptCwd ?? candidate.trustedCwd ?? resolveCursorProjectCwd(candidate.projectSlug); if (!cwdIsInScope(cwd, args.scopeRoots)) continue; const existing = recordsById.get(candidate.agentId); const firstPrompt = firstUserTextFromRecords(jsonl); diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts index f81c317c4..12173dd75 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; import { clearOpenCodeBinaryCache } from "../opencode/openCodeBinaryManager"; import { discoverClaudeSessions } from "./discoverClaude"; import { discoverCodexSessions } from "./discoverCodex"; @@ -720,9 +721,8 @@ describe("external session provider discovery", () => { // Use a writable temp dir (CI can't mkdir under /private/tmp) and derive the slug from it. const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "adecursorcwd"))); expect(cwd.includes("-"), "temp cwd must be dash-free for the cursor slug round-trip").toBe(false); - // A Windows drive prefix is dropped rather than hyphenated (`C:` is not a - // legal directory-name fragment), matching `cursorProjectSlugForCwd`. - const slug = cwd.replace(/^([A-Za-z]):[\\/]+/u, "").replace(/^[/\\]+/u, "").replace(/[\\/]/gu, "-"); + // Cursor's own slug rule, reimplemented in shared/cursorProjectSlug. + const slug = cursorProjectSlug(cwd); const agentId = "33333333-3333-4333-8333-333333333333"; const sdkAgentId = "agent-44444444-4444-4444-8444-444444444444"; writeJsonl(path.join(homeDir, ".cursor", "projects", slug, "agent-transcripts", agentId, `${agentId}.jsonl`), [ diff --git a/apps/desktop/src/shared/cursorProjectSlug.ts b/apps/desktop/src/shared/cursorProjectSlug.ts new file mode 100644 index 000000000..7d1ec1ce5 --- /dev/null +++ b/apps/desktop/src/shared/cursorProjectSlug.ts @@ -0,0 +1,28 @@ +/** + * Cursor names its per-workspace directory under `~/.cursor/projects/` by + * slugging the absolute workspace path. This is a byte-for-byte reimplementation + * of the slug function Cursor ships in `@cursor/sdk` (1.0.23, + * `../utils/dist/index.js`): + * + * function slug(p) { + * return p.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, ""); + * } + * function projectDir(home, workspacePath) { + * return `${home}/.cursor/projects/${slug(workspacePath)}`; + * } + * + * Every non-alphanumeric character becomes `-`, runs collapse, and leading and + * trailing dashes are trimmed. Do not "improve" this: matching Cursor exactly is + * the whole point. In particular: + * - `_` and `.` become `-`, they are not preserved or dropped; + * - a Windows drive letter survives as its own segment, so + * `C:\Users\me\repo` is `C-Users-me-repo` (the colon and backslashes each + * become a dash and then collapse), while `/Users/me/repo` is + * `Users-me-repo` because the leading dash is trimmed. + */ +export function cursorProjectSlug(workspacePath: string): string { + return workspacePath + .replace(/[^a-zA-Z0-9]/gu, "-") + .replace(/-+/gu, "-") + .replace(/^-+|-+$/gu, ""); +} From 5b9f80d63fcff5eec7082dfe2e53ca53e014f3fa Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:41:14 -0400 Subject: [PATCH 31/51] fix(windows): trust only proven executables in the Droid presence branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The droid branch preferred commandPath()'s answer over resolveDroidExecutable() and fell back to the resolver only when it was empty — sound before only by accident, since commandPath() returned the bare command name when `where` failed. Now that resolveCommandLocation() yields a file that exists or null, the branch is reached only for an installed CLI, so the fallback is dead and the shallow presence check is asking about credentials rather than existence. Drop the fallback and report the proven path. Based-on: nsxdavid/ADE#999 --- .../src/main/services/ai/authDetector.ts | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 008b1f7e5..6d6af30d7 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -1157,29 +1157,15 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut }; } if (cli === "droid") { - // Prefer the path we already proved via resolveCommandLocation() above; - // only fall back to resolveDroidExecutable() when that failed. - let droidPath: string; - if (path) { - droidPath = path; - } else { - const resolved = resolveDroidExecutable({ env: process.env }); - if (resolved.source === "fallback-command") { - return { - cli, - installed: false, - path: null, - authenticated: false, - verified: false, - }; - } - droidPath = resolved.path; - } - const auth = await inspectDroidCliPresence(droidPath, { deep: options?.force === true }); + // `path` is a file resolveCommandLocation() proved exists, so it is + // strictly better than resolveDroidExecutable(), whose last resort is + // the bare command name. Reached only when installed, so the shallow + // presence check below is asking about credentials, not existence. + const auth = await inspectDroidCliPresence(cmd, { deep: options?.force === true }); return { cli, installed: auth.installed, - path: droidPath, + path, authenticated: auth.authenticated, verified: auth.verified, }; From c6608acedce17807b119b13a8b4f91478545320d Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:44:24 -0400 Subject: [PATCH 32/51] fix(windows): keep the title-bar controls clear of the caption buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows the feedback reporter, help menu, and zoom controls were invisible: they render at the trailing edge of the title bar, which is exactly where Chromium paints the caption buttons for a `titleBarOverlay` window. Two separate faults put them there. The first is that the reservation never applied. `--shell-header-padding-end` was overridden to 150px under `:root[data-ade-platform="win32"]`, but that declaration sits inside the `:root, [data-theme="dark"]` token block, and `data-theme` is set on , , and the shell wrapper. matches the block and re-declares the 14px default, so the platform value was shadowed one level below where it was set and the header always resolved 14px. The same shadowing silently disabled the macOS traffic-light inset, including the zoom-tracking value `applyShellHeaderInset()` writes. Both insets move to `--shell-header-inset-start` / `--shell-header-inset-end`, names the theme blocks do not own, consumed by `.ade-shell-header` with the old tokens as fallback. The second is that 150px was a guess. Caption buttons are three 46 DIP buttons, but the reservation is spent in CSS pixels, and ADE's zoom control moves the two apart: at 70% the buttons cover 171 CSS px and 150 was too few, at 150% they cover 85 and it was nearly double what was needed. Electron exposes the real geometry, so `trackWindowsCaptionInset()` reserves `viewport - (titlebarAreaRect.x + width)` plus an 8px gutter and re-reads it on `geometrychange` and `resize`. This mirrors what macOS already does for the traffic lights rather than inventing a second mechanism. Reading that geometry needs care: `innerWidth` and the overlay rect are updated independently and are briefly inconsistent mid-resize, which makes the trailing edge measure zero. Taken at face value that means "no caption buttons" and drops the reservation entirely — observed as the whole cluster jumping back under the buttons after a resize. An inconsistent read now returns no answer at all and the last good value stands until the geometry settles on the following frames. Finally the trailing group was `shrink-0`, so once the header ran out of room it overflowed its own padding and slid back under the buttons regardless of the reservation. The status/update strip now clips first and the utility cluster is what survives. Verified on Windows 11 against the running app: the cluster ends exactly 8px before the caption buttons from 1476px down to a 480px-wide window and across 70%/100%/150% zoom, all three controls hit-test to themselves inside the `no-drag` region, and a real mouse click at each on-screen position opens the feedback reporter, opens the help menu, and steps the zoom. Based-on: nsxdavid/ADE#999 --- .../src/renderer/components/app/TopBar.tsx | 52 ++--- apps/desktop/src/renderer/index.css | 42 +++- .../src/renderer/lib/windowControlsOverlay.ts | 187 ++++++++++++++++++ apps/desktop/src/renderer/lib/zoom.ts | 9 +- apps/desktop/src/renderer/main.tsx | 6 + 5 files changed, 262 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/windowControlsOverlay.ts diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index fa70b14c8..bab03ea2e 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -2747,32 +2747,40 @@ export function TopBar({
) : null} - {/* Trailing controls: activity · status · updates · utility cluster */} -
- {/* Account-wide Activity — the one place every machine's work surfaces, - reachable from every tab and project without a nav detour. */} - - - {/* App-global voice capture — visible from any tab while recording. */} - + {/* Trailing controls: activity · status · updates · utility cluster. + The group must be able to shrink: the header reserves room for the + native window controls (macOS traffic lights at the start, Windows + caption buttons at the end) with padding, and a shrink-0 group would + simply overflow that padding at narrow widths and slide back under + them. The status/update strip therefore clips first so the utility + cluster — feedback, help, zoom — always stays inside the reservation. */} +
+
+ {/* Account-wide Activity — the one place every machine's work surfaces, + reachable from every tab and project without a nav detour. */} + + + {/* App-global voice capture — visible from any tab while recording. */} + + + + + +
+ {renderHeaderStatusControls()} +
- - + + {(closeMenu) => renderHeaderStatusControls({ menuLayout: true, onActivate: closeMenu })} + -
- {renderHeaderStatusControls()} + {!webMode ? : null}
- - {(closeMenu) => renderHeaderStatusControls({ menuLayout: true, onActivate: closeMenu })} - - - {!webMode ? : null} -
, , AND the shell wrapper (see App.tsx), so every one + of those elements re-declares the `--shell-header-padding-*` defaults — which + would shadow a platform value inherited from long before it reached + the header. Only carries `data-ade-platform`, so a dedicated property + name declared here survives all the way down. + + Both values are only the pre-paint fallback. `applyShellHeaderInset()` + (renderer/lib/zoom.ts) and `trackWindowsCaptionInset()` + (renderer/lib/windowControlsOverlay.ts) overwrite them with live geometry, + because the native controls are a fixed physical size that does not follow + the renderer's zoom factor. */ + +:root[data-ade-platform="darwin"] { + --shell-header-inset-start: 80px; +} + +:root[data-ade-platform="win32"] { + /* Three 46 DIP caption buttons plus an 8px gutter. */ + --shell-header-inset-end: 146px; +} + /* ═══════════════════════════════════════════════════════════ Light Theme ═══════════════════════════════════════════════════════════ */ @@ -444,8 +466,8 @@ h6 { .ade-shell-header { height: var(--shell-header-height); - padding-left: var(--shell-header-padding-start); - padding-right: var(--shell-header-padding-end); + padding-left: var(--shell-header-inset-start, var(--shell-header-padding-start)); + padding-right: var(--shell-header-inset-end, var(--shell-header-padding-end)); color: var(--shell-header-fg); background: var(--shell-header-bg); backdrop-filter: blur(20px); diff --git a/apps/desktop/src/renderer/lib/windowControlsOverlay.ts b/apps/desktop/src/renderer/lib/windowControlsOverlay.ts new file mode 100644 index 000000000..4582a625e --- /dev/null +++ b/apps/desktop/src/renderer/lib/windowControlsOverlay.ts @@ -0,0 +1,187 @@ +/** + * Windows title-bar (Window Controls Overlay) geometry. + * + * The Windows window is created with `titleBarStyle: "hidden"` + + * `titleBarOverlay` (see `src/main/windowAppearance.ts`), so Chromium draws the + * native minimize/maximize/close buttons on top of the renderer at the trailing + * edge and exposes their geometry through the Window Controls Overlay API. + * + * This is the Windows twin of `shellHeaderInsetPx()` in `./zoom`: macOS reserves + * space at the *start* of the title bar for the traffic lights, Windows must + * reserve space at the *end* for the caption buttons. Both hazards are the same + * shape — the OS draws chrome at a fixed physical size that does not follow the + * renderer's zoom factor — so a static CSS-pixel reservation drifts: it wastes + * space when zoomed in and lets the caption buttons swallow ADE's own controls + * when zoomed out. + * + * Rather than guess, read the real overlay rect. `getTitlebarAreaRect()` returns + * the part of the title bar still owned by the page, in CSS pixels, so the + * caption-button width is simply what is left over at the trailing edge. + */ + +/** + * Deliberately not `--shell-header-padding-end`: `data-theme` is set on , + * , and the shell wrapper, so each of those re-declares the padding + * tokens and would shadow a value inherited from . See the platform-inset + * block in index.css. + */ +export const SHELL_HEADER_INSET_END_PROPERTY = "--shell-header-inset-end"; + +/** + * Caption-button clearance used before the overlay reports real geometry (and + * on any Chromium that does not expose the API). Windows draws three 46 DIP + * caption buttons, so 138 DIP is the true default width. + */ +export const WINDOWS_CAPTION_FALLBACK_PX = 138; + +/** Breathing room between the last ADE control and the first caption button. */ +export const WINDOWS_CAPTION_GUTTER_PX = 8; + +/** + * Title-bar padding when no caption buttons are overlaid (non-Windows, or + * Windows in fullscreen where the overlay hides). Matches the CSS default. + */ +export const SHELL_HEADER_INSET_END_BASE_PX = 14; + +type TitlebarAreaRect = { + x: number; + y: number; + width: number; + height: number; +}; + +export type WindowControlsOverlayLike = { + visible: boolean; + getTitlebarAreaRect: () => TitlebarAreaRect; + addEventListener?: (type: "geometrychange", listener: () => void) => void; + removeEventListener?: (type: "geometrychange", listener: () => void) => void; +}; + +export type CaptionInsetArgs = { + overlay: WindowControlsOverlayLike | null | undefined; + viewportWidth: number; +}; + +/** + * CSS px to reserve at the end of the title bar so the Windows caption buttons + * never cover ADE's trailing controls (feedback reporter, help menu, zoom). + * + * The overlay rect is the page-owned slice of the title bar, so the reserved + * width is `viewport - (rect.x + rect.width)` — whatever the caption buttons + * occupy at the trailing edge, at the current zoom and DPI — plus a small + * gutter. When the overlay is hidden (fullscreen) nothing needs reserving. + * + * Returns `null` for "ask again in a frame". `innerWidth` and the overlay rect + * are updated by different parts of the browser and are briefly inconsistent + * mid-resize: the viewport narrows while the rect still describes the old, + * wider window, which makes the trailing edge look like zero. Taking that at + * face value would mean "no caption buttons here" and drop the reservation to + * nothing — the observed failure is the whole control cluster jumping back + * under the caption buttons after a resize. An unusable answer is better left + * unanswered; the caller keeps the last good value until the geometry settles. + */ +export function windowsCaptionInsetPx(args: CaptionInsetArgs): number | null { + const { overlay, viewportWidth } = args; + const fallback = WINDOWS_CAPTION_FALLBACK_PX + WINDOWS_CAPTION_GUTTER_PX; + if (!overlay || typeof overlay.getTitlebarAreaRect !== "function") { + return fallback; + } + // Fullscreen genuinely hides the caption buttons, and that is a settled + // state rather than a transient one, so it is safe to reclaim the space. + if (!overlay.visible) return SHELL_HEADER_INSET_END_BASE_PX; + + let rect: TitlebarAreaRect; + try { + rect = overlay.getTitlebarAreaRect(); + } catch { + return fallback; + } + if (!rect || !Number.isFinite(rect.width) || !Number.isFinite(rect.x)) { + return fallback; + } + // A zero-width rect means the overlay is present but not laid out yet; the + // fallback is closer to the truth than "reserve the whole window". + if (rect.width <= 0) return fallback; + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) return fallback; + + const trailing = viewportWidth - (rect.x + rect.width); + if (!Number.isFinite(trailing) || trailing <= 0) return null; + return Math.round(trailing) + WINDOWS_CAPTION_GUTTER_PX; +} + +function readOverlay(): WindowControlsOverlayLike | null { + if (typeof navigator === "undefined") return null; + const overlay = (navigator as Navigator & { + windowControlsOverlay?: WindowControlsOverlayLike; + }).windowControlsOverlay; + return overlay ?? null; +} + +/** + * Push the live caption-button clearance into `--shell-header-inset-end`. + * No-op where there is no overlay to dodge, or while the geometry is mid-flight + * (see `windowsCaptionInsetPx`). Returns whether a value was written. + */ +export function applyWindowsCaptionInset(): boolean { + if (typeof document === "undefined" || typeof window === "undefined") { + return false; + } + const overlay = readOverlay(); + if (!overlay) return false; + const inset = windowsCaptionInsetPx({ + overlay, + viewportWidth: window.innerWidth, + }); + if (inset == null) return false; + document.documentElement.style.setProperty( + SHELL_HEADER_INSET_END_PROPERTY, + `${inset}px`, + ); + return true; +} + +/** + * Apply the clearance now and keep it in sync. `geometrychange` covers + * maximize/restore and fullscreen; `resize` covers renderer zoom changes, which + * alter how many CSS pixels the fixed-DIP caption buttons occupy. + * + * Each trigger retries on the next few frames rather than reading once: the + * viewport and the overlay rect settle independently, and the first read after + * a resize is often the inconsistent one. + */ +export function trackWindowsCaptionInset(): () => void { + if (typeof window === "undefined") return () => {}; + const overlay = readOverlay(); + if (!overlay) return () => {}; + + const RETRY_FRAMES = 6; + let frame: number | null = null; + const cancelFrame = () => { + if (frame != null) window.cancelAnimationFrame(frame); + frame = null; + }; + const settle = (remaining: number) => { + cancelFrame(); + if (remaining <= 0) return; + frame = window.requestAnimationFrame(() => { + frame = null; + // Keep re-reading even after a successful write: a resize produces a run + // of intermediate geometries and only the last one is the real answer. + applyWindowsCaptionInset(); + settle(remaining - 1); + }); + }; + const update = () => { + applyWindowsCaptionInset(); + settle(RETRY_FRAMES); + }; + update(); + + overlay.addEventListener?.("geometrychange", update); + window.addEventListener("resize", update); + return () => { + cancelFrame(); + overlay.removeEventListener?.("geometrychange", update); + window.removeEventListener("resize", update); + }; +} diff --git a/apps/desktop/src/renderer/lib/zoom.ts b/apps/desktop/src/renderer/lib/zoom.ts index b2dcee3b1..2b64f364d 100644 --- a/apps/desktop/src/renderer/lib/zoom.ts +++ b/apps/desktop/src/renderer/lib/zoom.ts @@ -80,13 +80,18 @@ export function shellHeaderInsetPx(displayZoom: number): number { /** * Sync the title bar's start padding to the current zoom so the macOS traffic * lights never overlap the logo. No-op off macOS (no native traffic lights) and - * outside a DOM; there the static `--shell-header-padding-start` default stands. + * outside a DOM; there the static `--shell-header-inset-start` default stands. + * + * `--shell-header-inset-start` (not `--shell-header-padding-start`) because + * `data-theme` is set on , , and the shell wrapper, so each of them + * re-declares the padding tokens and would shadow this value before it reached + * the header. See the platform-inset block in index.css. */ export function applyShellHeaderInset(displayZoom: number): void { if (!isMac) return; if (typeof document === "undefined") return; document.documentElement.style.setProperty( - "--shell-header-padding-start", + "--shell-header-inset-start", `${shellHeaderInsetPx(displayZoom)}px`, ); } diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index 5786bbdd0..252c5f99d 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -11,9 +11,15 @@ import { useAppStore } from "./state/appStore"; import { logRendererDebugEvent } from "./lib/debugLog"; import { initPerfRuntime } from "./perf/harness"; import { rendererPlatformAttribute } from "./lib/platform"; +import { trackWindowsCaptionInset } from "./lib/windowControlsOverlay"; document.documentElement.dataset.adePlatform = rendererPlatformAttribute(); +// Windows draws its caption buttons over the trailing edge of the title bar. +// Keep the header's end padding pinned to their real width so the feedback, +// help, and zoom controls always land just to their left. +trackWindowsCaptionInset(); + (function injectFontFaces() { const style = document.createElement("style"); style.dataset.adeFonts = "true"; From ef9cb71b1e680ce539d5c67af9d06df463f03aeb Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:46:58 -0400 Subject: [PATCH 33/51] fix(windows): stop leaking Cursor SDK worker state and process trees Two Windows-only defects in the SDK worker pool, both proven against a real forked worker with @cursor/sdk 1.0.23 loaded: State leak. `releaseCursorSdkConnection` removes the one-shot cache root the instant it asks the worker to dispose, but the worker is still alive and the SDK's local platform still holds `state/index.db` (and its -wal/-shm) open. Windows refuses to remove a directory with open handles, the failure was swallowed, and the directory stayed forever; POSIX unlinks open files and always succeeded on the first try. Every catalog and cloud one-shot request uses a fresh pool key, so this accumulated a new `state/` directory inside the project's `.ade/cache/cursor-sdk` on each call. Observed before: `cacheRoot left: true` with `state/index.db`, `-shm`, `-wal` present, and a manual retry after the worker exited succeeding. Now retried in the background until the handles are released; after: `cacheRoot=false`. Process tree. The kill fallback was a bare `child.kill("SIGTERM")`. On Windows that is `TerminateProcess` against a single pid: the worker's own SIGTERM handler never runs -- confirmed directly, the handler's marker file is never written -- so `dispose()` never cancels the run or closes the SDK agent, and anything the SDK spawned is left behind. Use the repo's `terminateChildProcessTree` (`taskkill /T`, escalating to `/T /F`) and clear the escalation on exit so it can never fire against a recycled pid. The blind 800ms escalation window also had to grow: on macOS a worker caught mid-dispose still gets a graceful SIGTERM, on Windows it is simply killed, so the IPC dispose is the only orderly path and needs a realistic budget. Checked @cursor/sdk for a vendor close grace period like Droid's 5s ProcessTransport one -- it has none, because the SDK runs the agent loop in-process rather than shelling out to cursor-agent. Verified end to end on Windows with spaces in both the project root and the lane path: worker forks, `\.\pipe\ade-cursor-sdk-` binds and is gone after release, worker exits, cache root removed, no stray pipes or processes. Based-on: nsxdavid/ADE#999 --- .../services/chat/cursorSdkPolicy.test.ts | 3 + .../main/services/chat/cursorSdkPool.test.ts | 43 +++++++++++++ .../src/main/services/chat/cursorSdkPool.ts | 64 +++++++++++++++---- 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts index 9ee573a9b..f9776c110 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts @@ -159,6 +159,9 @@ describe("Cursor SDK policy", () => { expect(request.reason).toContain("/etc"); }); + // The guard deliberately leaves backslash tokens alone on POSIX, where `\` is + // a legal filename character, so these escape shapes have no POSIX analogue. + // WINDOWS-GATE: Windows-only shell path syntax; verified green on a native Windows host. it.runIf(process.platform === "win32")("denies Windows-shell lane escapes written with backslashes or %VAR% expansion", () => { const policy = resolveCursorSdkPolicy({ cursorModeId: "full-auto" }); const laneRoot = path.join(path.parse(path.resolve("/")).root, "Users", "admin", "lane"); diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts index c212da719..90b74abef 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts @@ -7,6 +7,7 @@ import { acquireCursorSdkConnection, buildCursorSdkPaths, buildCursorSdkWorkerEnv, + cleanupCursorSdkRuntimePaths, isCursorSdkPooledAlive, releaseCursorSdkConnection, resolveCursorSdkUserHome, @@ -175,6 +176,48 @@ describe("Cursor SDK pool paths", () => { } }); + it("retries one-shot SDK state removal until the worker releases its handles", async () => { + // Cleanup runs while the worker is still shutting down. On Windows the + // SDK's open `state/index.db` makes the first `rmSync` fail with EBUSY and + // the state directory is leaked; POSIX unlinks it on the first try. + const cacheRoot = makeTempDir("ade-cursor-cleanup-"); + const stateRoot = path.join(cacheRoot, "state"); + fs.mkdirSync(stateRoot, { recursive: true }); + fs.writeFileSync(path.join(stateRoot, "index.db"), "held"); + + const realRm = fs.rmSync; + let busyAttempts = 2; + const rmSpy = vi.spyOn(fs, "rmSync").mockImplementation(((target: fs.PathLike, options?: fs.RmOptions) => { + if (busyAttempts > 0) { + busyAttempts -= 1; + const error = new Error(`EBUSY: resource busy or locked, rmdir '${String(target)}'`) as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return realRm(target, options); + }) as typeof fs.rmSync); + + try { + cleanupCursorSdkRuntimePaths({ cacheRoot, stateRoot, cleanupStateRoot: true }); + const deadline = Date.now() + 5_000; + while (fs.existsSync(cacheRoot) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + expect(busyAttempts).toBe(0); + expect(fs.existsSync(cacheRoot)).toBe(false); + } finally { + rmSpy.mockRestore(); + } + }); + + it("leaves SDK state alone when cleanup was not requested", () => { + const cacheRoot = makeTempDir("ade-cursor-keep-"); + const stateRoot = path.join(cacheRoot, "state"); + fs.mkdirSync(stateRoot, { recursive: true }); + cleanupCursorSdkRuntimePaths({ cacheRoot, stateRoot, cleanupStateRoot: false }); + expect(fs.existsSync(stateRoot)).toBe(true); + }); + it("keeps durable SDK state stable while pool-specific socket paths change", () => { const projectRoot = path.join(os.tmpdir(), "ade-project"); const first = buildCursorSdkPaths({ diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.ts index 78cad8047..be43a9b62 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Logger } from "../logging/logger"; import { buildPackagedRuntimeNodeModulePaths } from "../runtime/packagedNodePath"; +import { terminateChildProcessTree } from "../shared/utils"; import type { CursorSdkCloudArtifactDescriptor, CursorSdkErrorDetail, @@ -90,6 +91,12 @@ const pools = new Map(); const pendingInits = new Map>(); const STALE_INIT_RETRY_LIMIT = 2; +/** + * How long the worker gets to answer the IPC `dispose` request before the pool + * kills its process tree. It has to cover cancelling an in-flight run and + * closing the SDK agent, and on Windows it is the only orderly path there is. + */ +const CURSOR_SDK_DISPOSE_GRACE_MS = 3_000; const CURSOR_SDK_WORKER_ENV_DENYLIST = [ "CURSOR_API_KEY", "CURSOR_AUTH_TOKEN", @@ -502,6 +509,8 @@ async function createCursorSdkConnection(args: Parameters(); + let disposeTimer: NodeJS.Timeout | null = null; + let killTimer: NodeJS.Timeout | null = null; const bridge: CursorSdkBridge = { onEvent: null, onRunStarted: null, @@ -615,14 +624,22 @@ async function createCursorSdkConnection(args: Parameters { for (const [, waiter] of pending) waiter.reject(new Error("Cursor SDK worker disposed.")); pending.clear(); + // Windows has no graceful SIGTERM: `child.kill()` is TerminateProcess, so + // the worker's own signal handler never runs and the tools the SDK + // spawned (shell commands, the bundled ripgrep) are left behind. The IPC + // `dispose` request is therefore the only orderly shutdown path here, and + // the escalation must kill the whole tree rather than a single pid. + const escalate = (): void => { + if (child.exitCode != null || child.killed) return; + killTimer = terminateChildProcessTree(child, killTimer); + }; const sent = sendWorkerMessage({ type: "dispose", requestId: randomUUID() } as CursorSdkWorkerRequest); - if (!sent && child.exitCode == null && !child.killed) { - child.kill("SIGTERM"); + if (!sent) { + escalate(); return; } - setTimeout(() => { - if (child.exitCode == null && !child.killed) child.kill("SIGTERM"); - }, 800).unref(); + disposeTimer = setTimeout(escalate, CURSOR_SDK_DISPOSE_GRACE_MS); + disposeTimer.unref(); }, }; @@ -771,6 +788,12 @@ async function createCursorSdkConnection(args: Parameters { + // Never let an escalation fire after the worker is gone: on Windows that + // would run `taskkill /T /F` against a recycled pid. + if (disposeTimer) clearTimeout(disposeTimer); + if (killTimer) clearTimeout(killTimer); + disposeTimer = null; + killTimer = null; rejectPending(workerExitedError(code, signal)); cleanupPoolEntry(pooled); }); @@ -820,7 +843,30 @@ async function createCursorSdkConnection(args: Parameters= CURSOR_SDK_CLEANUP_RETRY_LIMIT) return; + setTimeout( + () => removeCursorSdkRuntimePath(target, attempt + 1), + CURSOR_SDK_CLEANUP_RETRY_DELAY_MS, + ).unref(); + } +} + +export function cleanupCursorSdkRuntimePaths(entry: { cacheRoot?: string; stateRoot: string; socketPath?: string; @@ -833,11 +879,7 @@ function cleanupCursorSdkRuntimePaths(entry: { targets.add(path.dirname(entry.socketPath)); } for (const target of targets) { - try { - fs.rmSync(target, { recursive: true, force: true }); - } catch { - // Best effort: stale one-shot SDK state should never break request cleanup. - } + removeCursorSdkRuntimePath(target); } } From 44ceb56cdb1143553c700ead78f35dd78a304a61 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:50:49 -0400 Subject: [PATCH 34/51] fix(windows): forward hook arguments through the Cursor gate wrapper The POSIX hook wrapper passes `"$@"` on to the bridge script; the Windows `.cmd` wrapper dropped them, so anything Cursor puts on the hook command line -- `--socket ` in particular -- never reached `ade-tool-gate.cjs`, which then had to fall back to the environment. Forward `%*`, and add `setlocal` so ELECTRON_RUN_AS_NODE cannot escape into a reused cmd instance. Checked separately that the surrounding `cmd /d /c ""` form is already safe for paths with spaces: cmd only strips the outer quotes when the quoted path is the entire remainder, and the hook command always has arguments after it. Based-on: nsxdavid/ADE#999 --- .../src/main/services/chat/cursorSdkHooks.test.ts | 9 +++++++-- apps/desktop/src/main/services/chat/cursorSdkHooks.ts | 6 +++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts b/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts index 76148cf4e..8ec6faf0e 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkHooks.test.ts @@ -318,9 +318,14 @@ describe("Cursor SDK hook installation", () => { electronPath: String.raw`C:\Users\Ada%20\AppData\Local\ADE.exe`, scriptPath: String.raw`C:\Users\Ada%20\.cursor\hooks\ade-tool-gate.cjs`, }); - expect(fs.readFileSync(commandPath, "utf8")).toContain( - String.raw`"C:\Users\Ada%%20\AppData\Local\ADE.exe" "C:\Users\Ada%%20\.cursor\hooks\ade-tool-gate.cjs"`, + const script = fs.readFileSync(commandPath, "utf8"); + expect(script).toContain( + String.raw`"C:\Users\Ada%%20\AppData\Local\ADE.exe" "C:\Users\Ada%%20\.cursor\hooks\ade-tool-gate.cjs" %*`, ); + // Cursor's own hook arguments must reach the bridge script, the way the + // POSIX wrapper forwards "$@". + expect(script).toContain("%*"); + expect(script).toContain("setlocal"); } finally { fs.rmSync(home, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/services/chat/cursorSdkHooks.ts b/apps/desktop/src/main/services/chat/cursorSdkHooks.ts index 294334f3c..529a530fc 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkHooks.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkHooks.ts @@ -333,10 +333,14 @@ export function writeCursorSdkHookWindowsCommandScript(args: { scriptPath: string; }): void { ensureDir(path.dirname(args.commandPath)); + // `%*` forwards whatever Cursor passed (notably `--socket `), matching + // the POSIX wrapper's `"$@"`. `setlocal` keeps ELECTRON_RUN_AS_NODE out of + // any parent environment that reuses this cmd instance. const source = [ "@echo off", + "setlocal", "set ELECTRON_RUN_AS_NODE=1", - `${windowsBatchQuote(args.electronPath)} ${windowsBatchQuote(args.scriptPath)}`, + `${windowsBatchQuote(args.electronPath)} ${windowsBatchQuote(args.scriptPath)} %*`, "exit /b %ERRORLEVEL%", "", ].join("\r\n"); From 20caee5bcc521c863a190ab19372c4cb935abcfd Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:53:23 -0400 Subject: [PATCH 35/51] fix(windows): find standalone Codex installs and invert Cursor slugs by drive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups in the shared resolution layer, both from verified vendor sources rather than inference. Codex's known install directories were invented: no installer writes %ProgramFiles%\Codex or %LOCALAPPDATA%\Programs\Codex. The real standalone installer (chatgpt.com/codex/install.ps1) exposes the binary through %CODEX_INSTALL_DIR%, defaulting to %LOCALAPPDATA%\Programs\OpenAI\Codex\bin, and prepends that only to the *persisted* user PATH — invisible to an ADE process that was already running, so a PATH lookup alone reports a real install as absent. Both entries now point at the documented locations. macOS needs no equivalent: that installer's Unix default is $HOME/.local/bin, already listed. Cursor project slugs could not be inverted on Windows at all. `greedyCursorSlugCwdCandidate` anchored at the process's current drive root and then searched for a child directory literally named `C`, because Cursor's rule (verbatim from @cursor/sdk 1.0.23: every non-alphanumeric character becomes `-`, runs collapse, ends trim, no platform branch) turns `C:\Users\me\repo` into `C-Users-me-repo` — the drive letter is a segment, not a directory. Resolution now recognises a leading single-letter segment as a drive, anchors at `:\`, and falls back to every drive that exists for a slug produced on another OS. The `Users-` special case and the `/${slug}` fallback were equally POSIX-shaped; they now derive from the real home directory and rebuild a drive- qualified path. Segment guessing also covers `_` and spaces, which Cursor flattens to `-` just like `.`. `slugMatchesScopeRoots` compared slugs exactly, so a workspace recorded as `c:\users\me\repo` never matched ADE's `C:\Users\me\repo` scope root and the session was dropped. The fold belongs here, since the `slugForCwd` callback only ever sees one side. Verified by round-tripping real Windows directories: `…\Projects\my-cool.app\.ade\worktrees\lane-with-hyphen` and `…\repo_with_underscore\sub dir` both slug and resolve back exactly, and a lowercased slug now matches its scope root. The discoveryUtils fixture built its input with ADE's old rule, which left `C:\…` intact on Windows — a slug Cursor could never write — so it was asserting the inversion of the wrong string. Based-on: nsxdavid/ADE#999 --- .../main/services/ai/cliExecutableResolver.ts | 14 +- .../externalSessions/discoveryUtils.test.ts | 7 +- .../externalSessions/discoveryUtils.ts | 142 +++++++++++++++--- 3 files changed, 138 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index c4ae7809a..c50642835 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -153,6 +153,7 @@ function getWindowsKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string const voltaHome = env.VOLTA_HOME?.trim(); const pnpmHome = env.PNPM_HOME?.trim(); const asdfDataDir = env.ASDF_DATA_DIR?.trim(); + const codexInstallDir = env.CODEX_INSTALL_DIR?.trim(); return uniqueNonEmpty([ // `npm i -g` writes `.cmd` / `.ps1` shims straight into %APPDATA%\npm. @@ -204,8 +205,17 @@ function getWindowsKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string pnpmHome || "", asdfDataDir ? path.join(asdfDataDir, "shims") : "", ...readNpmPrefixBinDirs(env), - command === "codex" && programFiles ? path.join(programFiles, "Codex") : "", - command === "codex" && localAppData ? path.join(localAppData, "Programs", "Codex") : "", + // Codex's standalone Windows installer (chatgpt.com/codex/install.ps1) + // unpacks to $CODEX_HOME\packages\standalone\current and exposes the binary + // through %CODEX_INSTALL_DIR%, defaulting to + // %LOCALAPPDATA%\Programs\OpenAI\Codex\bin. It prepends that to the + // *persisted* user PATH, which an already-running ADE never sees — so a PATH + // lookup alone reports a real install as absent. macOS needs no equivalent + // entry: the Unix default is $HOME/.local/bin, already listed above. + command === "codex" ? (codexInstallDir || "") : "", + command === "codex" && localAppData + ? path.join(localAppData, "Programs", "OpenAI", "Codex", "bin") + : "", ]); } diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts index 7cd10201d..f432aabb4 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts @@ -215,7 +215,12 @@ describe("resolveCursorCwdFromSlug", () => { const cwd = path.join(root, "Projects", "my-cool.app", ".ade", "worktrees", "lane-with-hyphen"); fs.mkdirSync(cwd, { recursive: true }); try { - const slug = cwd.replace(/^\/+/u, "").replace(/[/.]/gu, "-"); + // Cursor's rule, verbatim from @cursor/sdk 1.0.23: every non-alphanumeric + // character becomes `-`, runs collapse, ends are trimmed. The previous + // fixture only replaced `/` and `.`, so on Windows it built a slug + // containing `C:\…` — a string Cursor could never write, which made this + // assert an inversion of the wrong input. + const slug = cwd.replace(/[^a-zA-Z0-9]/gu, "-").replace(/-+/gu, "-").replace(/^-+|-+$/gu, ""); expect(resolveCursorCwdFromSlug(slug)).toBe(fs.realpathSync(cwd)); } finally { fs.rmSync(root, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts index 49f16b85c..5e1805523 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts @@ -693,6 +693,21 @@ export function resolveExistingPath(candidate: string): string | null { } } +/** + * Cursor's project-directory slug, from the vendor's own shipped code + * (`@cursor/sdk` 1.0.23, `../utils/dist/index.js`): every non-alphanumeric + * character becomes `-`, runs of `-` collapse, leading/trailing `-` are + * trimmed. No case folding, no length cap, no platform branch. + * + * TODO: replace with an import of `shared/cursorProjectSlug` (Cursor lane, + * d6328117) once that module lands on a shared base — this copy exists only + * because the inverse below has to run before the branches merge, and it must + * not be allowed to drift from it. + */ +function slugLikeCursor(value: string): string { + return value.replace(/[^a-zA-Z0-9]/gu, "-").replace(/-+/gu, "-").replace(/^-+|-+$/gu, ""); +} + function uniqueNames(names: string[]): string[] { return names.filter((name, index) => name.length > 0 && names.indexOf(name) === index); } @@ -714,22 +729,76 @@ function cursorSlugMixedNames(parts: string[]): string[] { function cursorSlugSegmentNames(parts: string[]): string[] { const hyphen = parts.join("-"); const dotted = parts.join("."); + const underscored = parts.join("_"); + const spaced = parts.join(" "); const mixed = cursorSlugMixedNames(parts); + // Cursor slugs every non-alphanumeric character to `-`, so `_`, `.` and a + // space are all indistinguishable from a real hyphen by the time we read one + // back. Uniform joins for each cover the common cases; the `.`-prefixed forms + // recover a leading dot, which the vendor's trailing `^-+` trim erases + // entirely (`.ade` -> `ade`). return uniqueNames([ hyphen, dotted, + underscored, + spaced, ...mixed, `.${hyphen}`, `.${dotted}`, + `.${underscored}`, ...mixed.map((name) => `.${name}`), ]); } -function greedyCursorSlugCwdCandidate(slug: string): string | null { - const parts = slug.split("-").filter((part) => part.length > 0); - if (!parts.length) return null; - let current = path.parse(path.resolve("/")).root; - let index = 0; +/** + * Windows drive roots that exist right now (`C:\`, `D:\`, …). + * + * A Cursor slug is anchored at a drive letter, and the process's own drive is + * not necessarily that drive, so the inversion cannot assume `path.resolve("/")`. + */ +function windowsDriveRoots(): string[] { + const roots: string[] = []; + for (let code = "A".charCodeAt(0); code <= "Z".charCodeAt(0); code += 1) { + const root = `${String.fromCharCode(code)}:${path.win32.sep}`; + if (safeStat(root)?.isDirectory()) roots.push(root); + } + return roots; +} + +/** + * Roots to start walking from, paired with the slug parts still to consume. + * + * On Windows the first part is the drive letter itself — Cursor's rule turns + * `C:\Users\me\repo` into `C-Users-me-repo`, so the leading `C` is a segment, + * not a directory named `C`. Anchoring at the process's current drive root and + * then looking for a child literally named `C` is why this never resolved. + */ +function cursorSlugAnchors(parts: string[]): Array<{ root: string; index: number }> { + if (!parts.length) return []; + if (process.platform !== "win32") { + return [{ root: path.parse(path.resolve("/")).root, index: 0 }]; + } + const anchors: Array<{ root: string; index: number }> = []; + const first = parts[0] ?? ""; + if (/^[A-Za-z]$/u.test(first)) { + const driveRoot = `${first.toUpperCase()}:${path.win32.sep}`; + if (safeStat(driveRoot)?.isDirectory()) anchors.push({ root: driveRoot, index: 1 }); + } + // A slug produced on another OS (an imported transcript, a synced profile) + // has no drive segment, so fall back to trying every drive that exists. + for (const root of windowsDriveRoots()) { + anchors.push({ root, index: 0 }); + } + return anchors; +} + +function greedyCursorSlugCwdCandidateFrom( + parts: string[], + root: string, + startIndex: number, +): string | null { + let current = root; + let index = startIndex; while (index < parts.length) { let matched: { dir: string; nextIndex: number } | null = null; @@ -753,6 +822,16 @@ function greedyCursorSlugCwdCandidate(slug: string): string | null { return current; } +function greedyCursorSlugCwdCandidate(slug: string): string | null { + const parts = slug.split("-").filter((part) => part.length > 0); + if (!parts.length) return null; + for (const anchor of cursorSlugAnchors(parts)) { + const resolved = greedyCursorSlugCwdCandidateFrom(parts, anchor.root, anchor.index); + if (resolved) return resolved; + } + return null; +} + export function cursorSlugCwdCandidates(slug: string): string[] { const candidates: string[] = []; const add = (candidate: string) => { @@ -762,25 +841,38 @@ export function cursorSlugCwdCandidates(slug: string): string[] { const greedy = greedyCursorSlugCwdCandidate(slug); if (greedy) add(greedy); - if (slug.startsWith("Users-")) { - const parts = slug.split("-"); - if (parts.length >= 2) { - const username = parts[1]; - const rest = parts.slice(2).join("-"); - if (username && rest) { - const worktreeMarker = "-ade-worktrees-"; - const markerIdx = rest.indexOf(worktreeMarker); - if (markerIdx >= 0) { - const projectPart = rest.slice(0, markerIdx); - const lanePart = rest.slice(markerIdx + worktreeMarker.length); - add(path.join("/Users", username, ...projectPart.split("-"), ".ade", "worktrees", lanePart)); - } - add(path.join("/Users", username, ...rest.split("-"))); + // Home-relative guess. On macOS a home-anchored slug starts `Users--`; + // on Windows the same path starts `C-Users--`, because the drive letter + // survives as its own segment and Cursor's rule has no platform branch. Slug + // the real home directory and strip that prefix instead of hardcoding + // `/Users`, so `C:\Users\me`, `/home/me` and a relocated profile all work. + const homeSlug = slugLikeCursor(resolveHomeDir()); + if (homeSlug && (slug === homeSlug || slug.startsWith(`${homeSlug}-`))) { + const rest = slug.slice(homeSlug.length).replace(/^-+/u, ""); + if (rest) { + const worktreeMarker = "-ade-worktrees-"; + const markerIdx = rest.indexOf(worktreeMarker); + if (markerIdx >= 0) { + const projectPart = rest.slice(0, markerIdx); + const lanePart = rest.slice(markerIdx + worktreeMarker.length); + add(path.join(resolveHomeDir(), ...projectPart.split("-"), ".ade", "worktrees", lanePart)); } + add(path.join(resolveHomeDir(), ...rest.split("-"))); } } - add(`/${slug.replace(/-/gu, "/")}`); + // Last resort: treat every `-` as a path separator. On Windows the leading + // segment is a drive letter, so this has to become `C:\a\b` — never `\C\a\b`. + const slugParts = slug.split("-").filter((part) => part.length > 0); + if (slugParts.length) { + if (process.platform === "win32") { + if (/^[A-Za-z]$/u.test(slugParts[0] ?? "")) { + add(path.join(`${(slugParts[0] ?? "").toUpperCase()}:${path.win32.sep}`, ...slugParts.slice(1))); + } + } else { + add(`/${slugParts.join("/")}`); + } + } return candidates; } @@ -850,8 +942,14 @@ export function slugMatchesScopeRoots( ): boolean { const roots = scopeRootPathVariants(scopeRoots); if (!roots.length) return true; + // Windows paths are case-insensitive, so a workspace the provider recorded as + // `c:\users\me\repo` slugs differently from ADE's `C:\Users\me\repo` scope + // root and an exact compare silently drops the session. The fold has to + // happen here: `slugForCwd` only ever sees one of the two sides. + const fold = (value: string): string => (process.platform === "win32" ? value.toLowerCase() : value); + const foldedSlug = fold(slug); return roots.some((root) => { - const rootSlug = slugForCwd(root); - return slug === rootSlug || slug.startsWith(`${rootSlug}-`); + const rootSlug = fold(slugForCwd(root)); + return foldedSlug === rootSlug || foldedSlug.startsWith(`${rootSlug}-`); }); } From 29adbe1142a084487c15315c897056477dcbc6d3 Mon Sep 17 00:00:00 2001 From: Arul Sharma Date: Mon, 3 Aug 2026 02:54:47 -0400 Subject: [PATCH 36/51] perf(windows): stop the sidebar hover from resizing the pane groups The collapsible tab rail animated its own `width` (52px -> 140px) as a flex item in the shell's main row. Every frame of that 200ms transition relaid out the whole shell, which resized the react-resizable-panels groups inside
. Their ResizeObserver responds by reading offsetWidth for every panel (forced layout) and re-rendering every pane, so a plain hover over the rail ran a full layout + React render pass on every animation frame. Measured on Windows with a 240 Hz display (CDP CPU profile + Event Timing, 6s hover sweep, dev build): renderer main thread busy 99.3% / 99.4% -> 33.4% / 42.8% / 43.1% react-resizable-panels self 2291ms/6.4s -> not in profile frame interval p50 8.2ms -> 4.2ms frames slower than 8ms 397/785 (51%) -> 34/1443 (2.4%) The per-frame cost is the same on macOS, but the number of frames in a 200ms transition scales with display refresh rate: ~48 at 240 Hz versus ~12 at 60 Hz. That is why this saturates the main thread on Windows and not on a Mac. Fix: the rail keeps a fixed width in flow and the expansion happens in an absolutely positioned flyout, so the panes to its right never see a resize. Hovering now expands over the content instead of pushing it. Ruled out by measurement, not changed: backdrop-filter (A/B'd on and off, no difference), GPU fallback (hardware accelerated, ANGLE/D3D11), and idle cost (main thread 96% idle at rest). Based-on: nsxdavid/ADE#999 --- .../src/renderer/components/app/AppShell.tsx | 8 +++--- apps/desktop/src/renderer/index.css | 25 ++++++++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 382f71c85..ff5227167 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -1338,9 +1338,11 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{hideSidebar ? null : ( // Graph page uses `fixed` viewport layers up to z-[96]; keep the tab rail above them. -