From 1af1bac2ef894f39d81f5a8bf148c4e1e1510017 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:35:52 -0400 Subject: [PATCH 1/3] Harden ADE runtime connectivity after dual-brain and auth races. Stop daily Clerk invalid_grant sign-outs with a live-peer refresh journal mutex, tell the truth when this computer is missing from the account directory, stop blaming sshd for refused ADE ports, reap same-channel wedged sync holders, prefer and hot-migrate back to 8787, and cap the chat event-history ring that was wedging the brain under a 1GB heap. Co-authored-by: Cursor --- apps/ade-cli/src/commands/doctor.ts | 9 +- .../account/accountAuthService.test.ts | 122 +- .../services/account/accountAuthService.ts | 287 ++-- .../account/accountMachinePublisherService.ts | 3 + .../account/accountSessionRotationJournal.ts | 164 ++- .../services/sync/sharedSyncListener.test.ts | 150 +++ .../src/services/sync/sharedSyncListener.ts | 207 ++- .../services/sync/syncListenerPortInspect.ts | 108 ++ .../sync/syncLoopbackCollision.test.ts | 10 +- .../src/services/sync/syncProtocol.test.ts | 22 + .../ade-cli/src/services/sync/syncProtocol.ts | 36 + apps/ade-cli/src/services/sync/syncService.ts | 96 +- .../services/chat/agentChatService.test.ts | 48 + .../main/services/chat/agentChatService.ts | 28 +- .../components/account/AccountPage.test.tsx | 53 +- .../components/account/AccountPage.tsx | 1085 +-------------- .../components/account/YourMacsCard.tsx | 1163 +++++++++++++++++ .../remoteTargets/remoteMachineModel.test.ts | 19 + .../remoteTargets/remoteMachineModel.ts | 15 +- .../components/settings/BrainRepairButton.tsx | 8 +- docs/features/chat/README.md | 11 +- .../onboarding-and-settings/README.md | 83 +- docs/features/sync-and-multi-device/README.md | 143 +- 23 files changed, 2380 insertions(+), 1490 deletions(-) create mode 100644 apps/ade-cli/src/services/sync/syncListenerPortInspect.ts create mode 100644 apps/desktop/src/renderer/components/account/YourMacsCard.tsx diff --git a/apps/ade-cli/src/commands/doctor.ts b/apps/ade-cli/src/commands/doctor.ts index 5c78213d9..9a9e11af9 100644 --- a/apps/ade-cli/src/commands/doctor.ts +++ b/apps/ade-cli/src/commands/doctor.ts @@ -643,11 +643,10 @@ function syncPortRow(input: DoctorInput): DoctorRow { + " tailscaled is invisible here — check `tailscale serve status`" + " and `netstat -an -p tcp`)" }${ - // The usual cause is ADE's own stranded `tailscale serve` entries from - // earlier runs. The host now reclaims those on its next publish, so the - // fix is a brain restart, not 60-odd manual `serve --tcp=N off` calls. - holders.length ? "" : " · ADE reclaims its own stale serve entries on the" - + " next publish; `ade brain restart` should return it to 8787" + // Sticky lastPort used to keep a replacement brain on 8788 forever. + // Bind order now retries 8787 first, and a live listener migrates back + // when 8787 frees. `ade brain restart` is still the explicit hammer. + holders.length ? "" : " · ADE retries 8787 first and migrates back when it is free; `ade brain restart` also returns it to 8787" }`, }; } diff --git a/apps/ade-cli/src/services/account/accountAuthService.test.ts b/apps/ade-cli/src/services/account/accountAuthService.test.ts index 0acf54164..be2e841a1 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.test.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.test.ts @@ -2069,6 +2069,7 @@ describe("AccountAuthService refresh and sign-out", () => { fetchImpl, refreshRotationWaitMs: 0, now: () => nowMs, + pidAlive: () => false, }); activeServices.push(service); @@ -2094,6 +2095,101 @@ describe("AccountAuthService refresh and sign-out", () => { }); }); + it("does not call Clerk or mark the session dead when a live peer already journals this generation", async () => { + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const store = new MemoryCredentialStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ + accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), + }))); + store.setSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY, JSON.stringify({ + version: 1, + oldRefreshTokenHash: accountTokenGeneration("refresh-old"), + startedAt: "2026-07-14T11:59:59.000Z", + pid: 4242, + source: "desktop", + userId: "user_old", + })); + const fetchImpl = vi.fn(async () => { + throw new Error("Clerk must not be called while a live peer owns the refresh"); + }); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl, + refreshRotationWaitMs: 0, + now: () => nowMs, + pid: 778, + sessionMutationSource: "brain", + pidAlive: (pid) => pid === 4242, + }); + activeServices.push(service); + + await expect(service.getAccessToken()).rejects.toThrow(/another process/i); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(JSON.parse(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)!)).toMatchObject({ + refreshToken: "refresh-old", + }); + expect(JSON.parse(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)!).needsReauth).toBeUndefined(); + expect(service.getStatus()).toMatchObject({ + signedIn: true, + userId: "user_old", + sessionState: "active", + }); + expect(JSON.parse(store.getSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY)!)).toMatchObject({ + pid: 4242, + source: "desktop", + }); + }); + + it("uses a live peer's replacement without starting a second Clerk refresh", async () => { + const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); + const peerAccessToken = jwt({ + sub: "user_old", + exp: Math.floor((nowMs + 3_600_000) / 1000), + }); + const store = new MemoryCredentialStore(); + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ + accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), + }))); + store.setSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY, JSON.stringify({ + version: 1, + oldRefreshTokenHash: accountTokenGeneration("refresh-old"), + startedAt: "2026-07-14T11:59:59.000Z", + pid: 4242, + source: "desktop", + userId: "user_old", + })); + const fetchImpl = vi.fn(async () => { + throw new Error("Clerk must not be called while a live peer owns the refresh"); + }); + setTimeout(() => { + store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ + accessToken: peerAccessToken, + refreshToken: "refresh-peer", + }))); + }, 10); + const service = createAccountAuthService({ + credentialStore: store, + getOAuthConfig: () => ({ issuer: "https://clerk.example.test", clientId: "client-public" }), + fetchImpl, + refreshRotationWaitMs: 100, + refreshRotationPollMs: 5, + now: () => nowMs, + pid: 778, + sessionMutationSource: "brain", + pidAlive: (pid) => pid === 4242, + }); + activeServices.push(service); + + await expect(service.getAccessToken()).resolves.toBe(peerAccessToken); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(service.getStatus()).toMatchObject({ + signedIn: true, + userId: "user_old", + sessionState: "active", + }); + }); + it("journals the rotation before the exchange and clears it once the new pair is durable", async () => { const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); const store = new MemoryCredentialStore(); @@ -2687,16 +2783,16 @@ describe("AccountAuthService refresh and sign-out", () => { }); }); - it("re-reads a refresh token rotated by another process and retries once", async () => { + it("serves a peer's still-fresh rotated pair instead of burning it at Clerk", async () => { const nowMs = Date.parse("2026-07-14T12:00:00.000Z"); const store = new MemoryCredentialStore(); store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs - 60_000) / 1000) }), expiresAt: "2026-07-15T12:00:00.000Z", }))); - const refreshedAccessToken = jwt({ + const peerAccessToken = jwt({ sub: "user_old", - exp: Math.floor((nowMs + 3_600_000) / 1000), + exp: Math.floor((nowMs + 1_800_000) / 1000), }); const refreshTokens: string[] = []; const fetchImpl = vi.fn(async (input: string, init?: RequestInit): Promise => { @@ -2705,17 +2801,13 @@ describe("AccountAuthService refresh and sign-out", () => { refreshTokens.push(refreshToken); if (refreshToken === "refresh-old") { store.setSync(ACCOUNT_SESSION_CREDENTIAL_KEY, JSON.stringify(storedSession({ - accessToken: jwt({ sub: "user_old", exp: Math.floor((nowMs + 1_800_000) / 1000) }), + accessToken: peerAccessToken, refreshToken: "refresh-rotated-by-desktop", expiresAt: "2026-07-15T12:00:00.000Z", }))); return jsonResponse({ error: "invalid_grant" }, 400); } - return jsonResponse({ - access_token: refreshedAccessToken, - refresh_token: "refresh-final", - expires_in: 86_400, - }); + throw new Error("Clerk must not consume a peer's still-fresh rotating grant"); }); const service = createAccountAuthService({ credentialStore: store, @@ -2725,12 +2817,11 @@ describe("AccountAuthService refresh and sign-out", () => { }); activeServices.push(service); - await expect(service.getAccessToken()).resolves.toBe(refreshedAccessToken); - expect(refreshTokens).toEqual(["refresh-old", "refresh-rotated-by-desktop"]); + await expect(service.getAccessToken()).resolves.toBe(peerAccessToken); + expect(refreshTokens).toEqual(["refresh-old"]); expect(JSON.parse(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)!)).toMatchObject({ - accessToken: refreshedAccessToken, - refreshToken: "refresh-final", - expiresAt: "2026-07-14T13:00:00.000Z", + accessToken: peerAccessToken, + refreshToken: "refresh-rotated-by-desktop", }); }); @@ -2766,6 +2857,9 @@ describe("AccountAuthService refresh and sign-out", () => { expect(JSON.parse(store.getSync(ACCOUNT_SESSION_CREDENTIAL_KEY)!)).toMatchObject({ refreshToken: "refresh-rotated-by-desktop", }); + // A live-pid journal left behind after a non-invalid_grant failure would + // make every peer wait on a refresh nobody is running. + expect(store.getSync(ACCOUNT_SESSION_ROTATION_JOURNAL_KEY)).toBeNull(); }); it("preserves a newer session written by another process while refresh succeeds", async () => { diff --git a/apps/ade-cli/src/services/account/accountAuthService.ts b/apps/ade-cli/src/services/account/accountAuthService.ts index 5bdd82701..62294d40d 100644 --- a/apps/ade-cli/src/services/account/accountAuthService.ts +++ b/apps/ade-cli/src/services/account/accountAuthService.ts @@ -923,6 +923,11 @@ export function createAccountAuthService(args: { sessionMutationSource?: AccountSessionMutationSource | null; /** Overrides `process.pid` on audit log lines and journal entries. */ pid?: number; + /** + * Whether a journaled pid is still running. Tests inject this so a fixture + * pid cannot be mistaken for a live peer on the host. + */ + pidAlive?: (pid: number) => boolean; }): AccountAuthService { const fetchImpl = args.fetchImpl ?? ((input, init) => fetch(input, init)); const now = args.now ?? Date.now; @@ -1106,6 +1111,7 @@ export function createAccountAuthService(args: { pid: mutationPid, source: mutationSource, log: logSessionMutation, + ...(args.pidAlive ? { pidAlive: args.pidAlive } : {}), }); const persistSession = ( @@ -2275,149 +2281,176 @@ export function createAccountAuthService(args: { }; let token: TokenResponse | null = null; let config: AccountOAuthConfig | null = null; - for (let attempt = 0; attempt < 2; attempt += 1) { - const refreshRecord = refreshSnapshot.session; - config = refreshRecord.oauthConfig - ? normalizeOAuthConfig(refreshRecord.oauthConfig) - : await resolveOAuthConfig(); - const tokenGeneration = accountTokenGeneration(refreshRecord.refreshToken) ?? ""; - // Read the journal BEFORE writing ours: an entry still naming this - // exact token generation means some process already started an - // exchange against it and never finished. The `invalid_grant` that - // follows is then explainable by that interruption, not proof that - // the grant is dead, so it must not condemn the session. - const priorJournal = rotationJournal.read(); - const interruptedRotation = priorJournal != null - && priorJournal.oldRefreshTokenHash === tokenGeneration; - if (interruptedRotation) { - logSessionMutation({ - action: "rotation_journal_interrupted", - reason: "unfinished_rotation_observed", - level: "warn", - tokenGeneration, - outcome: `started_at:${priorJournal.startedAt} pid:${priorJournal.pid} source:${priorJournal.source ?? "unknown"}`, - }); - } - rotationJournal.write({ - oldRefreshTokenHash: tokenGeneration, - userId: refreshRecord.userId, - }); - try { - token = await postTokenForm({ - fetchImpl, - tokenUrl: `${config.issuer}/oauth/token`, - signal: sharedSignal, - body: { - grant_type: "refresh_token", - refresh_token: refreshRecord.refreshToken!, - client_id: config.clientId, - }, + // Generation we journaled for this exchange. Cleared in `finally` so a + // network/timeout/early-return path cannot leave a live-pid journal that + // peers wait on forever (the sticky mutex that looks like daily logout). + let journaledGeneration: string | null = null; + const accessTokenStillFresh = (session: AccountSessionRecord): boolean => { + const expiresAtMs = Date.parse( + accessTokenExpiresAt(session.accessToken) ?? session.expiresAt, + ); + return Number.isFinite(expiresAtMs) && expiresAtMs > now() + ACCESS_TOKEN_REFRESH_SKEW_MS; + }; + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + const refreshRecord = refreshSnapshot.session; + config = refreshRecord.oauthConfig + ? normalizeOAuthConfig(refreshRecord.oauthConfig) + : await resolveOAuthConfig(); + const tokenGeneration = accountTokenGeneration(refreshRecord.refreshToken) ?? ""; + // Compare-and-swap the journal before talking to Clerk. A live peer + // already exchanging this grant must be waited out: Clerk refresh + // tokens are single-use, and a second POST is what produced the + // daily `invalid_grant` / mark_dead sign-outs. A dead peer's journal + // is taken over and treated as an interrupted rotation, so the + // `invalid_grant` that follows is not definitive. + const begin = rotationJournal.tryBegin({ + oldRefreshTokenHash: tokenGeneration, + userId: refreshRecord.userId, }); - break; - } catch (error) { - if ( - !(error instanceof AccountTokenRequestError) - || error.oauthErrorCode !== "invalid_grant" - ) { - throw error; - } - // The desktop and brain share this credential. A peer that won a - // rotating refresh exchange may not have persisted its replacement - // by the time Clerk rejects our old token, so poll before declaring - // the grant dead. The window out-waits the credential store's lock - // timeout, so a winner still queued for the lock cannot lose. - let rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); - if (rotation.kind === "rotated" && attempt === 0) { - refreshSnapshot = rotation.snapshot; - continue; + if (begin.kind === "peer_in_flight") { + const rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); + if (rotation.kind === "rotated") return rotation.snapshot.session; + if (rotation.kind !== "unchanged") return null; + throw new Error( + "ADE is refreshing your account sign-in in another process. Retry in a moment.", + ); } - if (rotation.kind !== "unchanged") return null; - if (interruptedRotation) { - // An interrupted journal makes this rejection ambiguous: the - // stored token may already have been spent by the process that - // died. Spend one more rotation-wait cycle, then give up for this - // attempt WITHOUT condemning the session. Clearing the journal - // makes the next refresh definitive, so an actually-dead grant - // still reaches the needs-re-auth state one attempt later. - rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); + journaledGeneration = tokenGeneration; + const interruptedRotation = begin.takeover; + try { + token = await postTokenForm({ + fetchImpl, + tokenUrl: `${config.issuer}/oauth/token`, + signal: sharedSignal, + body: { + grant_type: "refresh_token", + refresh_token: refreshRecord.refreshToken!, + client_id: config.clientId, + }, + }); + break; + } catch (error) { + if ( + !(error instanceof AccountTokenRequestError) + || error.oauthErrorCode !== "invalid_grant" + ) { + throw error; + } + // The desktop and brain share this credential. A peer that won a + // rotating refresh exchange may not have persisted its replacement + // by the time Clerk rejects our old token, so poll before declaring + // the grant dead. The window out-waits the credential store's lock + // timeout, so a winner still queued for the lock cannot lose. + let rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); if (rotation.kind === "rotated" && attempt === 0) { + // A peer already persisted a usable pair. Serving it avoids a + // second Clerk POST that would burn their new rotating grant. + if (accessTokenStillFresh(rotation.snapshot.session)) { + return rotation.snapshot.session; + } refreshSnapshot = rotation.snapshot; continue; } if (rotation.kind !== "unchanged") return null; - rotationJournal.clear("interrupted_rotation_inconclusive", tokenGeneration); - logSessionMutation({ - action: "rotation_journal_interrupted", - reason: "invalid_grant_not_definitive", - level: "warn", - oauthErrorCode: error.oauthErrorCode, - tokenGeneration, - outcome: "session_preserved", - }); + if (interruptedRotation) { + // An interrupted journal makes this rejection ambiguous: the + // stored token may already have been spent by the process that + // died. Spend one more rotation-wait cycle, then give up for this + // attempt WITHOUT condemning the session. Clearing the journal + // makes the next refresh definitive, so an actually-dead grant + // still reaches the needs-re-auth state one attempt later. + rotation = await waitForRefreshRotation(refreshSnapshot, sharedSignal); + if (rotation.kind === "rotated" && attempt === 0) { + if (accessTokenStillFresh(rotation.snapshot.session)) { + return rotation.snapshot.session; + } + refreshSnapshot = rotation.snapshot; + continue; + } + if (rotation.kind !== "unchanged") return null; + rotationJournal.clear("interrupted_rotation_inconclusive", tokenGeneration); + journaledGeneration = null; + logSessionMutation({ + action: "rotation_journal_interrupted", + reason: "invalid_grant_not_definitive", + level: "warn", + oauthErrorCode: error.oauthErrorCode, + tokenGeneration, + outcome: "session_preserved", + }); + throw error; + } + const marked = markStoredSessionRejectedIfExact( + refreshSnapshot.raw, + refreshSnapshot.session, + error.oauthErrorCode, + ); + if (!marked && readSessionSnapshot().raw !== refreshSnapshot.raw) { + return null; + } throw error; } - const marked = markStoredSessionRejectedIfExact( - refreshSnapshot.raw, - refreshSnapshot.session, - error.oauthErrorCode, - ); - if (!marked && readSessionSnapshot().raw !== refreshSnapshot.raw) { - return null; - } - throw error; } - } - if (!token || !config) { - throw new Error("ADE account session expired. Run `ade login` again."); - } - if (authEpoch !== epochAtJoin) return null; - const obtainedAtMs = now(); - const refreshed = await buildSessionRecord( - token, - refreshSnapshot.session, - undefined, - config, - { fetchUserinfo: false, obtainedAtMs, signal: sharedSignal }, - ); - if (authEpoch !== epochAtJoin) return null; - if (!persistRefreshedSessionIfCurrent( - refreshed, - refreshSnapshot.raw, - "refresh_token_rotated", - accountTokenGeneration(refreshSnapshot.session.refreshToken), - )) { - return null; - } - - // The rotated access/refresh pair is durable before optional profile - // enrichment. Identity is carried from the previously verified subject, - // so avoidable userinfo latency cannot expose a stale refresh token to a - // second process. - let enriched: AccountSessionRecord; - try { - enriched = await buildSessionRecord( + if (!token || !config) { + throw new Error("ADE account session expired. Run `ade login` again."); + } + if (authEpoch !== epochAtJoin) return null; + const obtainedAtMs = now(); + const refreshed = await buildSessionRecord( token, refreshSnapshot.session, undefined, config, - { obtainedAtMs, signal: sharedSignal }, + { fetchUserinfo: false, obtainedAtMs, signal: sharedSignal }, ); - } catch (error) { - if (sharedSignal.aborted) throw error; - // The rotated credential and verified prior subject are already - // durable. Optional profile enrichment must not make that successful - // refresh unusable. - return readSession() ?? refreshed; + if (authEpoch !== epochAtJoin) return null; + if (!persistRefreshedSessionIfCurrent( + refreshed, + refreshSnapshot.raw, + "refresh_token_rotated", + accountTokenGeneration(refreshSnapshot.session.refreshToken), + )) { + // Persist path clears our journaled generation; skip the finally clear. + journaledGeneration = null; + return null; + } + journaledGeneration = null; + + // The rotated access/refresh pair is durable before optional profile + // enrichment. Identity is carried from the previously verified subject, + // so avoidable userinfo latency cannot expose a stale refresh token to a + // second process. + let enriched: AccountSessionRecord; + try { + enriched = await buildSessionRecord( + token, + refreshSnapshot.session, + undefined, + config, + { obtainedAtMs, signal: sharedSignal }, + ); + } catch (error) { + if (sharedSignal.aborted) throw error; + // The rotated credential and verified prior subject are already + // durable. Optional profile enrichment must not make that successful + // refresh unusable. + return readSession() ?? refreshed; + } + if (authEpoch !== epochAtJoin) return null; + const refreshedRaw = JSON.stringify(refreshed); + return persistRefreshedSessionIfCurrent( + enriched, + refreshedRaw, + "refresh_profile_enriched", + ) + ? enriched + : readSession(); + } finally { + if (journaledGeneration) { + rotationJournal.clear("refresh_finished", journaledGeneration); + } } - if (authEpoch !== epochAtJoin) return null; - const refreshedRaw = JSON.stringify(refreshed); - return persistRefreshedSessionIfCurrent( - enriched, - refreshedRaw, - "refresh_profile_enriched", - ) - ? enriched - : readSession(); })().finally(() => { clearTimeout(sharedRefreshTimer); refreshInFlight = null; diff --git a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts index 3da3def28..5b434c4b8 100644 --- a/apps/ade-cli/src/services/account/accountMachinePublisherService.ts +++ b/apps/ade-cli/src/services/account/accountMachinePublisherService.ts @@ -29,6 +29,7 @@ import { createMachineIdentitySigningStore, MACHINE_IDENTITY_SIGNING_FILE_NAME, } from "../sync/machineIdentitySigningStore"; +import { trackBrainLoopWatchdogCommand } from "../runtime/brainLoopWatchdog"; import { createEpisodeAnalytics } from "./episodeAnalytics"; export const ACCOUNT_MACHINE_HEARTBEAT_MS = 30_000; @@ -1218,6 +1219,7 @@ export function createAccountMachinePublisherService(options: { const publishNow = (): Promise => { if (disposed) return Promise.resolve(); if (inFlight) return inFlight; + const stopTracking = trackBrainLoopWatchdogCommand("account.machine_publish"); const current = publish() .catch((error) => { const attemptAt = now(); @@ -1232,6 +1234,7 @@ export function createAccountMachinePublisherService(options: { }); }) .finally(() => { + stopTracking(); if (inFlight === current) inFlight = null; }); inFlight = current; diff --git a/apps/ade-cli/src/services/account/accountSessionRotationJournal.ts b/apps/ade-cli/src/services/account/accountSessionRotationJournal.ts index a33854597..229ba7a5c 100644 --- a/apps/ade-cli/src/services/account/accountSessionRotationJournal.ts +++ b/apps/ade-cli/src/services/account/accountSessionRotationJournal.ts @@ -10,6 +10,11 @@ * specific token generation, and is cleared once the replacement is durable. An * entry that survives means "the stored token may already have been consumed" — * the one `invalid_grant` that follows is not definitive. + * + * The same entry is also a mutex. Clerk refresh tokens are single-use: two + * live processes that both POST `/oauth/token` against one generation will + * make the loser look signed out. `tryBegin` compare-and-swaps the journal so + * a live peer's in-flight exchange is waited out, not raced. */ import type { SyncCredentialStore } from "../credentials/credentialStore"; @@ -35,6 +40,10 @@ export type RotationJournalEntry = { userId: string | null; }; +export type RotationJournalBeginResult = + | { kind: "acquired"; takeover: boolean } + | { kind: "peer_in_flight"; entry: RotationJournalEntry }; + function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record @@ -67,6 +76,22 @@ export function parseRotationJournal( } } +function describeJournalPeer(entry: RotationJournalEntry): string { + return `started_at:${entry.startedAt} pid:${entry.pid} source:${entry.source ?? "unknown"}`; +} + +function defaultPidAlive(pid: number): boolean { + if (!Number.isFinite(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: the process exists; this caller just cannot signal it. Treat it + // as live so we do not steal a refresh that is still in flight. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + export type RotationJournalArgs = { credentialStore: SyncCredentialStore; /** Clock, so tests can pin `startedAt`. */ @@ -79,12 +104,28 @@ export type RotationJournalArgs = { action: AccountSessionMutationAction; reason: string; tokenGeneration?: string | null; + level?: "info" | "warn"; + outcome?: string; }) => void; + /** + * Whether a journaled pid is still running. Tests inject this so a fixture + * pid cannot be mistaken for a live peer on the host. + */ + pidAlive?: (pid: number) => boolean; }; export type RotationJournal = { read: () => RotationJournalEntry | null; - write: (entry: { oldRefreshTokenHash: string; userId: string | null }) => void; + /** + * Compare-and-swap begin. A live peer's journal for this (or any) generation + * is left untouched and reported as `peer_in_flight` so the caller waits + * instead of burning the rotating grant at Clerk. A dead peer's journal is + * taken over and treated as an interrupted rotation. + */ + tryBegin: (entry: { + oldRefreshTokenHash: string; + userId: string | null; + }) => RotationJournalBeginResult; /** * `expectedTokenGeneration` scopes the clear to OUR entry. A peer may have * started its own rotation against a newer generation while ours was in @@ -95,7 +136,14 @@ export type RotationJournal = { clear: (reason: string, expectedTokenGeneration?: string | null) => void; }; +type BeginDecision = + | { kind: "write"; takeover: boolean } + | { kind: "already_ours" } + | { kind: "peer_in_flight"; entry: RotationJournalEntry }; + export function createRotationJournal(args: RotationJournalArgs): RotationJournal { + const pidAlive = args.pidAlive ?? defaultPidAlive; + const read = (): RotationJournalEntry | null => { try { return parseRotationJournal( @@ -108,27 +156,113 @@ export function createRotationJournal(args: RotationJournalArgs): RotationJourna } }; - const write = (entry: { oldRefreshTokenHash: string; userId: string | null }): void => { + const serialize = (entry: { oldRefreshTokenHash: string; userId: string | null }): string => + JSON.stringify({ + version: 1, + oldRefreshTokenHash: entry.oldRefreshTokenHash, + startedAt: new Date(args.now()).toISOString(), + pid: args.pid, + source: args.source, + userId: entry.userId, + }); + + const decide = ( + existing: RotationJournalEntry | null, + entry: { oldRefreshTokenHash: string; userId: string | null }, + ): BeginDecision => { + if (!existing) return { kind: "write", takeover: false }; + if (existing.pid === args.pid && existing.oldRefreshTokenHash === entry.oldRefreshTokenHash) { + return { kind: "already_ours" }; + } + if (existing.pid !== args.pid && pidAlive(existing.pid)) { + return { kind: "peer_in_flight", entry: existing }; + } + return { + kind: "write", + takeover: existing.oldRefreshTokenHash === entry.oldRefreshTokenHash + && existing.pid !== args.pid, + }; + }; + + const persistBegin = (entry: { oldRefreshTokenHash: string; userId: string | null }): void => { try { args.credentialStore.setSync( ACCOUNT_SESSION_ROTATION_JOURNAL_KEY, - JSON.stringify({ - version: 1, - oldRefreshTokenHash: entry.oldRefreshTokenHash, - startedAt: new Date(args.now()).toISOString(), - pid: args.pid, - source: args.source, - userId: entry.userId, - }), + serialize(entry), ); + } catch { + // Best effort: without a journal this refresh simply behaves the way it + // did before, so a store write failure must not block the exchange. + } + }; + + const finalizeBegin = ( + decision: BeginDecision, + entry: { oldRefreshTokenHash: string; userId: string | null }, + options: { alreadyPersisted?: boolean } = {}, + ): RotationJournalBeginResult => { + if (decision.kind === "peer_in_flight") { args.log({ - action: "rotation_journal_begin", - reason: "refresh_exchange_started", + action: "rotation_journal_interrupted", + reason: "peer_refresh_in_flight", tokenGeneration: entry.oldRefreshTokenHash, + level: "warn", + outcome: describeJournalPeer(decision.entry), + }); + return decision; + } + if (decision.kind === "already_ours") { + return { kind: "acquired", takeover: false }; + } + if (decision.takeover) { + args.log({ + action: "rotation_journal_interrupted", + reason: "dead_peer_journal_taken_over", + tokenGeneration: entry.oldRefreshTokenHash, + level: "warn", + }); + } + if (!options.alreadyPersisted) { + persistBegin(entry); + } + args.log({ + action: "rotation_journal_begin", + reason: "refresh_exchange_started", + tokenGeneration: entry.oldRefreshTokenHash, + }); + return { kind: "acquired", takeover: decision.takeover }; + }; + + const tryBegin = (entry: { + oldRefreshTokenHash: string; + userId: string | null; + }): RotationJournalBeginResult => { + try { + const updateSync = args.credentialStore.updateSync; + if (!updateSync) { + return finalizeBegin(decide(read(), entry), entry); + } + + let decision: BeginDecision | undefined; + updateSync.call(args.credentialStore, (values) => { + const existing = parseRotationJournal(values[ACCOUNT_SESSION_ROTATION_JOURNAL_KEY]); + decision = decide(existing, entry); + if (decision.kind === "peer_in_flight" || decision.kind === "already_ours") { + return false; + } + values[ACCOUNT_SESSION_ROTATION_JOURNAL_KEY] = serialize(entry); + return true; + }); + if (!decision) { + // The store declined to run the updater. Proceed without a journal + // rather than blocking the exchange. + return { kind: "acquired", takeover: false }; + } + return finalizeBegin(decision, entry, { + alreadyPersisted: decision.kind === "write", }); } catch { - // Best effort: without a journal this refresh simply behaves the way it - // did before, so a store write failure must not block the exchange. + return { kind: "acquired", takeover: false }; } }; @@ -155,5 +289,5 @@ export function createRotationJournal(args: RotationJournalArgs): RotationJourna } }; - return { read, write, clear }; + return { read, tryBegin, clear }; } diff --git a/apps/ade-cli/src/services/sync/sharedSyncListener.test.ts b/apps/ade-cli/src/services/sync/sharedSyncListener.test.ts index 8f0a696f6..d2ba933f3 100644 --- a/apps/ade-cli/src/services/sync/sharedSyncListener.test.ts +++ b/apps/ade-cli/src/services/sync/sharedSyncListener.test.ts @@ -9,6 +9,10 @@ import { parseWindowsPortHolders, SYNC_RELAY_BRIDGE_PROOF_HEADER, } from "./sharedSyncListener"; +import { + resolveAdeServeCliScriptPath, + resolveAdeServeCommand, +} from "../../serviceManager/common"; async function connect( port: number, @@ -76,6 +80,152 @@ describe("shared sync listener upgrade policy", () => { } }); + it("reaps a same-channel serve holder even when launchd still tracks that pid", async () => { + const holderPid = 888_777; + const startTime = "Fri Aug 1 04:00:00 2026"; + const serve = resolveAdeServeCommand(); + const cliScriptPath = resolveAdeServeCliScriptPath(serve); + const command = `${serve.command} ${cliScriptPath} serve`; + const probePorts = [8998, 8997, 8996, 8995]; + let holder: http.Server | null = null; + let probePort: number | null = null; + for (const candidate of probePorts) { + const candidateServer = http.createServer(); + try { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + candidateServer.off("listening", onListening); + reject(error); + }; + const onListening = () => { + candidateServer.off("error", onError); + resolve(); + }; + candidateServer.once("error", onError); + candidateServer.once("listening", onListening); + candidateServer.listen(candidate, "127.0.0.1"); + }); + holder = candidateServer; + probePort = candidate; + break; + } catch { + candidateServer.close(); + } + } + if (holder == null || probePort == null) { + throw new Error("Could not bind a port in the ADE sync range for the stale-holder test."); + } + const boundHolder = holder; + const logger = { warn: vi.fn(), info: vi.fn() }; + const inspectPort = vi.fn(async (port: number) => ({ + port, + holders: [{ pid: holderPid, command, startTime }], + })); + const terminatePid = vi.fn(async () => { + await new Promise((resolve) => boundHolder.close(() => resolve())); + }); + const listener = createSharedSyncListener({ + bindHost: "127.0.0.1", + logger, + inspectPort, + activeServicePid: () => holderPid, + terminatePid, + }); + try { + const port = await listener.ensureListening([probePort, 0]); + expect(port).toBe(probePort); + expect(terminatePid).toHaveBeenCalledWith(holderPid); + expect(logger.info).toHaveBeenCalledWith("sync_listener.zombie_reaped", expect.objectContaining({ + port: probePort, + pid: holderPid, + servicePid: holderPid, + })); + } finally { + await listener.close(); + if (boundHolder.listening) { + await new Promise((resolve) => boundHolder.close(() => resolve())); + } + } + }); + + it("migrates onto a free port and closes the previous bind", async () => { + const listener = createSharedSyncListener({ + bindHost: "127.0.0.1", + logger: { info: vi.fn(), debug: vi.fn(), warn: vi.fn() }, + }); + try { + const originalPort = await listener.ensureListening([0]); + const probePorts = [8994, 8993, 8992, 8991].filter((port) => port !== originalPort); + let target: http.Server | null = null; + let targetPort: number | null = null; + for (const candidate of probePorts) { + const candidateServer = http.createServer(); + try { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + candidateServer.off("listening", onListening); + reject(error); + }; + const onListening = () => { + candidateServer.off("error", onError); + resolve(); + }; + candidateServer.once("error", onError); + candidateServer.once("listening", onListening); + candidateServer.listen(candidate, "127.0.0.1"); + }); + target = candidateServer; + targetPort = candidate; + break; + } catch { + candidateServer.close(); + } + } + if (target == null || targetPort == null) { + throw new Error("Could not reserve a port in the ADE sync range for the migrate test."); + } + await new Promise((resolve) => target!.close(() => resolve())); + const migrated = await listener.tryMigrateToPort(targetPort); + expect(migrated).toBe(targetPort); + expect(listener.getPort()).toBe(targetPort); + const accepted = await connect(targetPort, "/"); + accepted.close(); + const stale = new WebSocket(`ws://127.0.0.1:${originalPort}/`); + stale.on("error", () => {}); + await once(stale, "error"); + } finally { + await listener.close(); + } + }); + + it("keeps the current bind when the migrate target is still occupied", async () => { + const holder = http.createServer(); + holder.listen(0, "127.0.0.1"); + await once(holder, "listening"); + const address = holder.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP holder."); + const listener = createSharedSyncListener({ + bindHost: "127.0.0.1", + inspectPort: async (port) => ({ + port, + holders: [{ + pid: 999_998, + command: "/Applications/ADE.app/Contents/MacOS/ADE", + startTime: "Fri Aug 1 04:00:00 2026", + }], + }), + }); + try { + const originalPort = await listener.ensureListening([0]); + expect(await listener.tryMigrateToPort(address.port)).toBeNull(); + expect(listener.getPort()).toBe(originalPort); + expect(await listener.tryMigrateToPort(originalPort)).toBe(originalPort); + } finally { + await listener.close(); + await new Promise((resolve) => holder.close(() => resolve())); + } + }); + it("accepts only the sync root path", async () => { const listener = createSharedSyncListener({ bindHost: "127.0.0.1" }); const port = await listener.ensureListening([0]); diff --git a/apps/ade-cli/src/services/sync/sharedSyncListener.ts b/apps/ade-cli/src/services/sync/sharedSyncListener.ts index 10f3e81ec..627ceee23 100644 --- a/apps/ade-cli/src/services/sync/sharedSyncListener.ts +++ b/apps/ade-cli/src/services/sync/sharedSyncListener.ts @@ -1,5 +1,4 @@ import http from "node:http"; -import { execFile } from "node:child_process"; import { randomBytes, timingSafeEqual } from "node:crypto"; import { WebSocketServer, WebSocket, type RawData } from "ws"; import type { @@ -25,13 +24,11 @@ import { terminatePidGracefullyAsync, } from "../../serviceManager/common"; import { getRuntimeServiceMainPid } from "../../serviceManager"; -import { resolveTrustedWindowsTool } from "../../lib/trustedWindowsTools"; import { resolveMachineAdeLayout } from "../projects/machineLayout"; import { - buildWindowsPortHolderQueryArgs, - parseWindowsPortHolders, - type WindowsPortHolder, -} from "./windowsPortHolders"; + inspectSyncListenerPort, + type SyncListenerPortDiagnosis, +} from "./syncListenerPortInspect"; // Re-exported so existing importers (and `ade doctor`) keep their entry point. export { @@ -40,6 +37,10 @@ export { parseWindowsPortHolders, type WindowsPortHolder, } from "./windowsPortHolders"; +export { + inspectSyncListenerPort, + type SyncListenerPortDiagnosis, +} from "./syncListenerPortInspect"; // Bind the sync host on all interfaces by default so phones on the same // wifi/LAN can reach it without Tailscale. 0.0.0.0 is a superset of loopback, @@ -90,11 +91,6 @@ type SharedSyncListenerLogger = { warn?: (message: string, fields?: Record) => void; }; -export type SyncListenerPortDiagnosis = { - port: number; - holders: WindowsPortHolder[]; -}; - export type SharedSyncListenerConnection = { ws: WebSocket; remoteAddress: string | null; @@ -180,6 +176,12 @@ export type SharedSyncListener = { * port — project switches never rebind, so connected peers survive them. */ ensureListening(portCandidates: number[]): Promise; + /** + * Bind `port` alongside the current listener, then close the old one. + * Used to steal 8787 back after a dual-brain split left us on 8788. + * Returns null when the target is still busy; the existing bind is kept. + */ + tryMigrateToPort(port: number): Promise; getPort(): number | null; isListening(): boolean; getExpectedLoopbackNonce(): string; @@ -219,29 +221,6 @@ type ParkedEntry = { expireTimer: ReturnType; }; -// `lsof`/`ps` answer in a few milliseconds. PowerShell needs to start a -// runtime and load a CIM module first, so the POSIX budget would kill every -// Windows query before it produced a holder. -const PORT_INSPECT_TIMEOUT_MS = 200; -const WINDOWS_PORT_INSPECT_TIMEOUT_MS = 5_000; - -function execFileText( - command: string, - args: string[], - timeoutMs: number = PORT_INSPECT_TIMEOUT_MS, -): Promise { - return new Promise((resolve) => { - execFile( - command, - args, - { encoding: "utf8", timeout: timeoutMs, maxBuffer: 1024 * 1024, windowsHide: true }, - (error, stdout) => { - resolve(error ? null : String(stdout ?? "")); - }, - ); - }); -} - function isRetryableListenerBindError(error: unknown): boolean { if (isLoopbackShadowedError(error)) return true; const code = (error as NodeJS.ErrnoException | null | undefined)?.code ?? ""; @@ -337,10 +316,10 @@ export function createSharedSyncListener(options: { const findStaleHolder = ( diagnosis: SyncListenerPortDiagnosis, ): SyncListenerPortDiagnosis["holders"][number] | null => { - const excludedPids = new Set([ - process.pid, - activeServicePid() ?? -1, - ]); + // Only exclude ourselves. Launchd's tracked main pid is often the wedged + // predecessor still bound to 8787; excluding it is how a replacement brain + // ended up stuck on 8788 with two listeners on the same machine. + const excludedPids = new Set([process.pid]); return diagnosis.holders.find((holder) => !excludedPids.has(holder.pid) && holder.command != null @@ -361,6 +340,7 @@ export function createSharedSyncListener(options: { // externally-supplied server, so we track it to free the port on close. let httpServer: http.Server | null = null; let listeningPromise: Promise | null = null; + let migratePromise: Promise | null = null; let handler: SharedSyncListenerConnectionHandler | null = null; let fallbackHandler: SharedSyncListenerConnectionHandler | null = null; let fallbackSuppressedUntilMs = 0; @@ -516,18 +496,25 @@ export function createSharedSyncListener(options: { parked.set(ws, entry); }; - const bindOnce = async (portCandidates: number[]): Promise => { + const bindOnce = async ( + portCandidates: number[], + bindOptions: { migrate?: boolean } = {}, + ): Promise => { const candidates = portCandidates.length > 0 ? portCandidates : [DEFAULT_SYNC_HOST_PORT]; // A fixed preferred port is re-attempted so a dying listener can free it. // An ephemeral port (0) is ALSO re-attempted, but for a different reason: // each bind(0) yields a fresh OS-assigned port, so a loopback shadow on the // first resolved port is escaped simply by re-binding. Both are bounded by // PREFERRED_PORT_BIND_ATTEMPTS so a persistent shadow still terminates. - const attemptPlan = candidates.flatMap((candidatePort, candidateIndex) => - (candidateIndex === 0 && candidatePort !== 0) || candidatePort === 0 - ? Array.from({ length: PREFERRED_PORT_BIND_ATTEMPTS }, () => candidatePort) - : [candidatePort], - ); + // Runtime migrate probes one candidate; the 3.2s retry storm is for the + // initial bind, not a 15s heal loop against a live ADE Beta on 8787. + const attemptPlan = bindOptions.migrate + ? [...candidates] + : candidates.flatMap((candidatePort, candidateIndex) => + (candidateIndex === 0 && candidatePort !== 0) || candidatePort === 0 + ? Array.from({ length: PREFERRED_PORT_BIND_ATTEMPTS }, () => candidatePort) + : [candidatePort], + ); let lastError: unknown = null; let previousAttemptedPort: number | null = null; // Tracks RESOLVED shadowed ports. For port 0 the literal 0 is never added @@ -681,6 +668,7 @@ export function createSharedSyncListener(options: { logger.info?.("sync_listener.zombie_reaped", { port: attemptedPort, pid: staleHolder.pid, + servicePid: activeServicePid(), }); // Non-preferred candidates occur only once in the normal plan. // Insert exactly one immediate retry for the newly-freed port. @@ -699,7 +687,10 @@ export function createSharedSyncListener(options: { : retryable ? "sync_listener.bind_port_conflict" : "sync_listener.bind_failed"; if (event !== "sync_listener.bind_port_conflict" || !loggedConflictPorts.has(attemptedPort)) { if (event === "sync_listener.bind_port_conflict") loggedConflictPorts.add(attemptedPort); - logger.warn?.(event, { + const log = bindOptions.migrate && event === "sync_listener.bind_port_conflict" + ? logger.debug + : logger.warn; + log?.(event, { attemptedPort, error: error instanceof Error ? error.message : String(error), code: (error as NodeJS.ErrnoException | null | undefined)?.code ?? null, @@ -718,10 +709,12 @@ export function createSharedSyncListener(options: { .slice(0, 5) .map((port) => diagnosePort(port)), ); - logger.warn?.("sync_listener.bind_exhausted", { - candidates: candidateDiagnosis, - error: lastError instanceof Error ? lastError.message : String(lastError), - }); + if (!bindOptions.migrate) { + logger.warn?.("sync_listener.bind_exhausted", { + candidates: candidateDiagnosis, + error: lastError instanceof Error ? lastError.message : String(lastError), + }); + } throw lastError instanceof Error ? lastError : new Error("Unable to bind the shared sync listener."); @@ -742,6 +735,49 @@ export function createSharedSyncListener(options: { return port; }, + async tryMigrateToPort(port: number): Promise { + if (closed) return null; + if (migratePromise) return migratePromise; + const currentMigrate = (async (): Promise => { + if (listeningPromise) { + await listeningPromise.catch(() => null); + } + if (closed) return null; + const currentAddress = server?.address(); + const currentPort = typeof currentAddress === "object" && currentAddress + ? currentAddress.port + : null; + if (currentPort == null || server == null || httpServer == null) return null; + const target = Math.max(1, Math.min(65_535, Math.floor(port))); + if (currentPort === target) return currentPort; + const previousServer = server; + const previousHttp = httpServer; + let nextPort: number; + try { + nextPort = await bindOnce([target], { migrate: true }); + } catch (error) { + logger.debug?.("sync_listener.canonical_port_still_busy", { + port: target, + from: currentPort, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + listeningPromise = Promise.resolve(nextPort); + await closeCandidateServer(previousServer, previousHttp).catch(() => {}); + logger.info?.("sync_listener.migrated_to_canonical_port", { + from: currentPort, + to: nextPort, + }); + notifyLoopbackValidated(); + return nextPort; + })(); + migratePromise = currentMigrate.finally(() => { + migratePromise = null; + }); + return migratePromise; + }, + getPort(): number | null { const address = server?.address(); return typeof address === "object" && address ? address.port : null; @@ -879,76 +915,3 @@ export function createSharedSyncListener(options: { }, }; } - -async function inspectWindowsSyncListenerPort( - port: number, - exec: typeof execFileText, -): Promise { - let powershell: string; - let args: string[]; - try { - powershell = resolveTrustedWindowsTool("powershell"); - args = buildWindowsPortHolderQueryArgs(port); - } catch { - return { port, holders: [] }; - } - const raw = await exec(powershell, args, WINDOWS_PORT_INSPECT_TIMEOUT_MS); - return { port, holders: parseWindowsPortHolders(raw) }; -} - -async function inspectPosixSyncListenerPort( - port: number, - exec: typeof execFileText, -): Promise { - const lsof = await exec( - "lsof", - ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp"], - ); - if (lsof == null) return { port, holders: [] }; - const pids = [...new Set( - lsof - .split(/\r?\n/) - .filter((line) => /^p\d+$/.test(line)) - .map((line) => Number(line.slice(1))) - .filter((pid) => Number.isFinite(pid) && pid > 0), - )]; - return { - port, - holders: await Promise.all(pids.map(async (pid) => { - const [commandResult, startResult] = await Promise.all([ - exec("ps", ["-p", String(pid), "-o", "command="]), - exec("ps", ["-p", String(pid), "-o", "lstart="]), - ]); - const command = commandResult?.trim() ?? ""; - const startTime = startResult?.trim() ?? ""; - return { - pid, - command: command || null, - startTime: startTime || null, - }; - })), - }; -} - -/** - * Processes listening on `port`, dispatched per platform. - * - * Both consumers degrade badly when this silently answers "nothing": the - * stale-port reclaim in `createSharedSyncListener` cannot recognise a wedged - * same-channel sibling and permanently drifts mobile sync onto a fallback port, - * and `ade doctor` reports "no holders visible to this user" with advice that - * only makes sense on macOS. - */ -export async function inspectSyncListenerPort( - port: number, - deps: { - platform?: NodeJS.Platform; - exec?: typeof execFileText; - } = {}, -): Promise { - const platform = deps.platform ?? process.platform; - const exec = deps.exec ?? execFileText; - return platform === "win32" - ? inspectWindowsSyncListenerPort(port, exec) - : inspectPosixSyncListenerPort(port, exec); -} diff --git a/apps/ade-cli/src/services/sync/syncListenerPortInspect.ts b/apps/ade-cli/src/services/sync/syncListenerPortInspect.ts new file mode 100644 index 000000000..fc9f9b3a8 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncListenerPortInspect.ts @@ -0,0 +1,108 @@ +import { execFile } from "node:child_process"; +import { resolveTrustedWindowsTool } from "../../lib/trustedWindowsTools"; +import { + buildWindowsPortHolderQueryArgs, + parseWindowsPortHolders, + type WindowsPortHolder, +} from "./windowsPortHolders"; + +export type SyncListenerPortDiagnosis = { + port: number; + holders: WindowsPortHolder[]; +}; + +// Bounded so a hung lsof/ps cannot stall the preferred-port reclaim loop. +const PORT_INSPECT_TIMEOUT_MS = 200; +// PowerShell cold-starts on Windows. Keep this longer than the POSIX probe so +// doctor / reclaim still see holders instead of timing out into "nothing". +const WINDOWS_PORT_INSPECT_TIMEOUT_MS = 5_000; + +export function execFileText( + command: string, + args: string[], + timeoutMs: number = PORT_INSPECT_TIMEOUT_MS, +): Promise { + return new Promise((resolve) => { + execFile( + command, + args, + { encoding: "utf8", timeout: timeoutMs, maxBuffer: 1024 * 1024, windowsHide: true }, + (error, stdout) => { + resolve(error ? null : String(stdout ?? "")); + }, + ); + }); +} + +async function inspectWindowsSyncListenerPort( + port: number, + exec: typeof execFileText, +): Promise { + let powershell: string; + let args: string[]; + try { + powershell = resolveTrustedWindowsTool("powershell"); + args = buildWindowsPortHolderQueryArgs(port); + } catch { + return { port, holders: [] }; + } + const raw = await exec(powershell, args, WINDOWS_PORT_INSPECT_TIMEOUT_MS); + return { port, holders: parseWindowsPortHolders(raw) }; +} + +async function inspectPosixSyncListenerPort( + port: number, + exec: typeof execFileText, +): Promise { + const lsof = await exec( + "lsof", + ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp"], + ); + if (lsof == null) return { port, holders: [] }; + const pids = [...new Set( + lsof + .split(/\r?\n/) + .filter((line) => /^p\d+$/.test(line)) + .map((line) => Number(line.slice(1))) + .filter((pid) => Number.isFinite(pid) && pid > 0), + )]; + return { + port, + holders: await Promise.all(pids.map(async (pid) => { + const [commandResult, startResult] = await Promise.all([ + exec("ps", ["-p", String(pid), "-o", "command="]), + exec("ps", ["-p", String(pid), "-o", "lstart="]), + ]); + const command = commandResult?.trim() ?? ""; + const startTime = startResult?.trim() ?? ""; + return { + pid, + command: command || null, + startTime: startTime || null, + }; + })), + }; +} + +/** + * Processes listening on `port`, dispatched per platform. + * + * Both consumers degrade badly when this silently answers "nothing": the + * stale-port reclaim in `createSharedSyncListener` cannot recognise a wedged + * same-channel sibling and permanently drifts mobile sync onto a fallback port, + * and `ade doctor` reports "no holders visible to this user" with advice that + * only makes sense on macOS. + */ +export async function inspectSyncListenerPort( + port: number, + deps: { + platform?: NodeJS.Platform; + exec?: typeof execFileText; + } = {}, +): Promise { + const platform = deps.platform ?? process.platform; + const exec = deps.exec ?? execFileText; + return platform === "win32" + ? inspectWindowsSyncListenerPort(port, exec) + : inspectPosixSyncListenerPort(port, exec); +} diff --git a/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts b/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts index b8365c03a..704d8b5fd 100644 --- a/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts +++ b/apps/ade-cli/src/services/sync/syncLoopbackCollision.test.ts @@ -213,10 +213,10 @@ describe("sync loopback collision recovery", () => { try { await expect(listener.ensureListening([port])).resolves.toBe(port); expect(holder.pid).toBeGreaterThan(0); - expect(logger.info).toHaveBeenCalledWith("sync_listener.zombie_reaped", { + expect(logger.info).toHaveBeenCalledWith("sync_listener.zombie_reaped", expect.objectContaining({ port, pid: holder.pid, - }); + })); if (holder.exitCode == null) await once(holder, "exit"); } finally { await listener.close(); @@ -257,9 +257,9 @@ describe("sync loopback collision recovery", () => { listenerBound: true, loopbackAdeValidated: true, }); - // The occupied preferred port is now skipped before ADE binds or probes - // it, so loopback validation has no failure to record. - expect(status.routeHealth.listener.lastFailureAt).toBeNull(); + // 8787 is always probed first now. A live or shadowed 8787 on this + // machine can record a loopback failure even when lastPort was the + // foreign listener; the bound port still has to be ADE-validated. expect(status.localDevice.lastPort).toBe(resolvedPort); expect(status.pairingConnectInfo?.port).toBe(resolvedPort); expect(status.tailnetDiscovery).toMatchObject({ diff --git a/apps/ade-cli/src/services/sync/syncProtocol.test.ts b/apps/ade-cli/src/services/sync/syncProtocol.test.ts index 1449df03e..cc784c4e5 100644 --- a/apps/ade-cli/src/services/sync/syncProtocol.test.ts +++ b/apps/ade-cli/src/services/sync/syncProtocol.test.ts @@ -24,6 +24,9 @@ import { SyncProtocolVersionMismatchError, SYNC_PROTOCOL_MIN_SUPPORTED, SYNC_PROTOCOL_VERSION, + DEFAULT_SYNC_HOST_PORT, + SYNC_HOST_MAX_PORT, + buildSyncHostPortCandidates, } from "./syncProtocol"; // Deterministic xorshift PRNG — gzip cannot compress its output, so payloads @@ -384,3 +387,22 @@ describe("parseSyncEnvelope", () => { expect(() => parseSyncEnvelope(encoded)).toThrow(/Failed to decode gzip sync envelope oversized-inflate/); }); }); + +describe("buildSyncHostPortCandidates", () => { + it("always probes 8787 first, even when lastPort is 8788", () => { + const candidates = buildSyncHostPortCandidates(8788); + expect(candidates[0]).toBe(DEFAULT_SYNC_HOST_PORT); + expect(candidates[1]).toBe(8788); + expect(candidates[2]).toBe(8789); + expect(candidates.at(-1)).toBe(SYNC_HOST_MAX_PORT); + expect(new Set(candidates).size).toBe(candidates.length); + expect(candidates).toHaveLength(SYNC_HOST_MAX_PORT - DEFAULT_SYNC_HOST_PORT + 1); + }); + + it("does not prefer a lastPort outside the sync range", () => { + expect(buildSyncHostPortCandidates(443)[0]).toBe(DEFAULT_SYNC_HOST_PORT); + expect(buildSyncHostPortCandidates(443)).not.toContain(443); + expect(buildSyncHostPortCandidates(null)[0]).toBe(DEFAULT_SYNC_HOST_PORT); + expect(buildSyncHostPortCandidates(undefined)[0]).toBe(DEFAULT_SYNC_HOST_PORT); + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncProtocol.ts b/apps/ade-cli/src/services/sync/syncProtocol.ts index daa1d992f..4ac1f5c66 100644 --- a/apps/ade-cli/src/services/sync/syncProtocol.ts +++ b/apps/ade-cli/src/services/sync/syncProtocol.ts @@ -26,6 +26,42 @@ export const SYNC_PROTOCOL_MIN_SUPPORTED = 1; export const SYNC_PROTOCOL_VERSION_MISMATCH_CLOSE_CODE = 4406; export const DEFAULT_SYNC_HOST_PORT = 8787; export const SYNC_HOST_MAX_PORT = 8999; + +/** + * Bind order for the shared sync listener. + * + * 8787 is always first. A sticky `lastPort` of 8788 used to win, so a + * replacement brain never even attempted 8787 — and never reaped the wedged + * predecessor still holding it. Phones and other computers keep the saved 8787 + * draft, so staying on 8788 is a silent split. + */ +export function buildSyncHostPortCandidates(preferredPort?: number | null): number[] { + const parsedPreferred = Number.isFinite(preferredPort) + ? Math.max(1, Math.min(65_535, Math.floor(Number(preferredPort)))) + : DEFAULT_SYNC_HOST_PORT; + const preferred = parsedPreferred || DEFAULT_SYNC_HOST_PORT; + const candidates: number[] = []; + const seen = new Set(); + const add = (port: number) => { + const normalized = Math.max(0, Math.min(65_535, Math.floor(port))); + if (seen.has(normalized)) return; + seen.add(normalized); + candidates.push(normalized); + }; + add(DEFAULT_SYNC_HOST_PORT); + if ( + preferred !== DEFAULT_SYNC_HOST_PORT + && preferred >= DEFAULT_SYNC_HOST_PORT + && preferred <= SYNC_HOST_MAX_PORT + ) { + add(preferred); + } + for (let port = DEFAULT_SYNC_HOST_PORT; port <= SYNC_HOST_MAX_PORT; port += 1) { + add(port); + } + return candidates; +} + export const DEFAULT_SYNC_COMPRESSION_THRESHOLD_BYTES = 4 * 1024; export const MAX_UNCOMPRESSED_SYNC_ENVELOPE_BYTES = 25 * 1024 * 1024; export const RPC_DATA_CHUNK_BYTES = 256 * 1024; diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index aec6a9996..dc2cb2bc8 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -72,7 +72,7 @@ import { import { createSyncPeerService } from "./syncPeerService"; import { createSyncPinStore } from "./syncPinStore"; import { createSyncRuntimeNameStore } from "./syncRuntimeNameStore"; -import { DEFAULT_SYNC_HOST_PORT, SYNC_HOST_MAX_PORT } from "./syncProtocol"; +import { DEFAULT_SYNC_HOST_PORT, buildSyncHostPortCandidates } from "./syncProtocol"; import { createSyncRemoteCommandService, type ExternalSessionsRemoteService, type SyncRemoteCommandService } from "./syncRemoteCommandService"; import { buildAddressCandidates, @@ -370,9 +370,9 @@ function isChatToolType(toolType: string | null | undefined): boolean { if (!normalized) return false; return normalized === "cursor" || normalized.endsWith("-chat"); } -const LEGACY_SYNC_HOST_PORT_RETRY_WINDOW = 13; -const LEGACY_SYNC_HOST_MAX_PORT = DEFAULT_SYNC_HOST_PORT + LEGACY_SYNC_HOST_PORT_RETRY_WINDOW; const LOCAL_LANE_PRESENCE_HEARTBEAT_MS = 30_000; +const CANONICAL_SYNC_PORT_MIGRATE_FIRST_MS = 2_000; +const CANONICAL_SYNC_PORT_MIGRATE_MS = 15_000; const TRANSFER_READINESS_CACHE_MS = 15_000; const STALE_BRAIN_LAST_SEEN_MS = 5 * 60_000; const VIEWER_DRAFT_TRANSPORT_ERROR_CODES = [ @@ -496,32 +496,6 @@ function createInactiveTailnetDiscoveryStatus( }; } -function buildHostPortCandidates(preferredPort: number | null | undefined): number[] { - const parsedPreferred = Number.isFinite(preferredPort) - ? Math.max(1, Math.min(65_535, Math.floor(Number(preferredPort)))) - : DEFAULT_SYNC_HOST_PORT; - const preferred = parsedPreferred || DEFAULT_SYNC_HOST_PORT; - const preferredIsLegacyReachable = preferred >= DEFAULT_SYNC_HOST_PORT - && preferred <= LEGACY_SYNC_HOST_MAX_PORT; - const candidates: number[] = []; - const seen = new Set(); - const add = (port: number) => { - const normalized = Math.max(0, Math.min(65_535, Math.floor(port))); - if (seen.has(normalized)) return; - seen.add(normalized); - candidates.push(normalized); - }; - if (preferredIsLegacyReachable) { - add(preferred); - } else { - add(DEFAULT_SYNC_HOST_PORT); - } - for (let port = DEFAULT_SYNC_HOST_PORT; port <= SYNC_HOST_MAX_PORT; port += 1) { - add(port); - } - return candidates; -} - export function createSyncService(args: SyncServiceArgs) { const layout = resolveAdeLayout(args.projectRoot); const pairingStateDir = args.phonePairingStateDir ?? layout.secretsDir; @@ -785,6 +759,54 @@ export function createSyncService(args: SyncServiceArgs) { lease?.dispose(); }; + let canonicalPortMigrateTimer: ReturnType | null = null; + + const stopCanonicalPortMigrateTimer = (): void => { + if (!canonicalPortMigrateTimer) return; + clearTimeout(canonicalPortMigrateTimer); + canonicalPortMigrateTimer = null; + }; + + const scheduleCanonicalPortMigrate = (delayMs: number): void => { + if (!args.sharedSyncListener || disposed) return; + stopCanonicalPortMigrateTimer(); + canonicalPortMigrateTimer = setTimeout(() => { + canonicalPortMigrateTimer = null; + void attemptCanonicalPortMigrate(); + }, delayMs); + canonicalPortMigrateTimer.unref?.(); + }; + + const attemptCanonicalPortMigrate = async (): Promise => { + const listener = args.sharedSyncListener; + if (disposed || !listener) return; + const currentPort = listener.getPort(); + if (currentPort == null || currentPort === DEFAULT_SYNC_HOST_PORT) return; + try { + const migrated = await listener.tryMigrateToPort(DEFAULT_SYNC_HOST_PORT); + if (migrated !== DEFAULT_SYNC_HOST_PORT) { + scheduleCanonicalPortMigrate(CANONICAL_SYNC_PORT_MIGRATE_MS); + return; + } + hostSingletonLease?.updatePort(migrated); + deviceRegistryService.touchLocalDevice({ + lastSeenAt: nowIso(), + lastPort: migrated, + }); + args.logger.warn("sync_listener.port_drifted", { + from: currentPort, + to: migrated, + }); + hostService?.refreshLanDiscovery({ forceLan: true, forceTailnet: true }); + void args.requestAccountMachinePublish?.(); + } catch (error) { + args.logger.debug("sync_listener.canonical_port_migrate_failed", { + error: error instanceof Error ? error.message : String(error), + }); + if (!disposed) scheduleCanonicalPortMigrate(CANONICAL_SYNC_PORT_MIGRATE_MS); + } + }; + const startHostIfNeeded = async (): Promise => { if (!hostStartupEnabled || !isCrdtSyncAvailable()) { if (hostService) { @@ -801,12 +823,18 @@ export function createSyncService(args: SyncServiceArgs) { } if (hostService) { const currentLocalDevice = deviceRegistryService.ensureLocalDevice(); + const activePort = hostService.getPort(); deviceRegistryService.touchLocalDevice({ lastSeenAt: nowIso(), lastHost: currentLocalDevice.ipAddresses[0] ?? currentLocalDevice.tailscaleIp ?? currentLocalDevice.lastHost, - lastPort: hostService.getPort(), + lastPort: activePort, }); hostService.refreshLanDiscovery?.(); + if (activePort != null && activePort !== DEFAULT_SYNC_HOST_PORT) { + scheduleCanonicalPortMigrate(CANONICAL_SYNC_PORT_MIGRATE_FIRST_MS); + } else { + stopCanonicalPortMigrateTimer(); + } return; } const localDevice = deviceRegistryService.ensureLocalDevice(); @@ -897,9 +925,14 @@ export function createSyncService(args: SyncServiceArgs) { }); void args.requestAccountMachinePublish?.(); } + if (resolvedPort === DEFAULT_SYNC_HOST_PORT) { + stopCanonicalPortMigrateTimer(); + } else { + scheduleCanonicalPortMigrate(CANONICAL_SYNC_PORT_MIGRATE_FIRST_MS); + } }; try { - const portCandidates = buildHostPortCandidates(preferredPort); + const portCandidates = buildSyncHostPortCandidates(preferredPort); if (args.sharedSyncListener) { // The brain-level shared listener binds once and is handed between // host services on project switches, so connected phones never see a @@ -1752,6 +1785,7 @@ export function createSyncService(args: SyncServiceArgs) { disposed = true; syncPeerService.disconnect(); clearInterval(localLanePresenceHeartbeatTimer); + stopCanonicalPortMigrateTimer(); await stopHostIfRunning(); await syncPeerService.dispose(); }, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 97600ff43..dbf7eabc1 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -23834,6 +23834,53 @@ describe("createAgentChatService", () => { )).toEqual(["persisted-1", "persisted-2"]); }); + it("does not copy transcript hydration into the live event ring", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + const afterCreate = service.residentChatEventHistorySessionCount(); + const envelope: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-08-12T18:00:00.000Z", + event: { type: "text", text: "hydrated-from-disk" }, + sequence: 1, + }; + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.writeFileSync(transcriptFile, `${JSON.stringify(envelope)}\n`, "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope]); + + const history = await service.getChatEventHistory(session.id); + expect(history.events.map((entry) => + entry.event.type === "text" ? entry.event.text : "", + )).toContain("hydrated-from-disk"); + expect(service.residentChatEventHistorySessionCount()).toBe(afterCreate); + }); + + it("does not retain a live ring for every hydrated chat", async () => { + const { service } = createService(); + for (let index = 0; index < 12; index += 1) { + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + const envelope: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-08-12T18:00:00.000Z", + event: { type: "text", text: `hydrated-${index}` }, + sequence: 1, + }; + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.writeFileSync(transcriptFile, `${JSON.stringify(envelope)}\n`, "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope]); + await service.getChatEventHistory(session.id); + } + expect(service.residentChatEventHistorySessionCount()).toBe(0); + }); + it("hydrates through the async path without synchronous realpath or transcript flushing", async () => { const { service } = createService(); const session = await service.createSession({ @@ -24520,6 +24567,7 @@ describe("createAgentChatService", () => { fs.writeFileSync(transcriptFile, `${seeded.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8"); vi.mocked(parseAgentChatTranscript).mockImplementation((raw) => raw.includes("ring-seed") ? seeded : []); + service.seedLiveChatEventHistory(seeded); expect((await service.getChatEventHistory(sessionId)).events).toHaveLength(seeded.length); }; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index e5a2ae149..aaab0aec5 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6976,6 +6976,11 @@ type AgentChatAutomationService = { cancelRunForDeletedChat: (args: { sessionId: string; runId?: string | null }) => void; }; +/** Live in-memory chat-event rings kept by the brain. Snapshot hydration must + * not grow this set — that is how a long-lived brain reached ~1 GB and wedged + * the event loop on GC. */ +export const CHAT_EVENT_HISTORY_BUFFER_MAX_SESSIONS = 64; + export function createAgentChatService(args: { projectRoot: string; adeDir?: string; @@ -7280,7 +7285,9 @@ export function createAgentChatService(args: { // emitted event (see emitChatEvent → commitChatEvent) and merged with the // persisted transcript when a snapshot is requested. The transcript recovers // older project/tab-switch history; the ring contributes events that may not - // have reached fs.appendFile yet. + // have reached fs.appendFile yet. Session count is LRU-capped: a history + // snapshot must not copy the transcript into this map, or every hydrated + // chat parks up to 4 MB in the brain until process death. const CHAT_EVENT_HISTORY_BUFFER_MAX_PER_SESSION = 4_000; const CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION = 20_000; const CHAT_EVENT_HISTORY_TRANSCRIPT_MAX_BYTES = 2_000_000; @@ -7379,6 +7386,16 @@ export function createAgentChatService(args: { : envelopes, CHAT_EVENT_HISTORY_BUFFER_MAX_CHARS, ); + + const touchEventHistoryRing = (sessionId: string, envelopes: AgentChatEventEnvelope[]): void => { + eventHistoryBySession.delete(sessionId); + eventHistoryBySession.set(sessionId, envelopes); + while (eventHistoryBySession.size > CHAT_EVENT_HISTORY_BUFFER_MAX_SESSIONS) { + const oldestSessionId = eventHistoryBySession.keys().next().value; + if (typeof oldestSessionId !== "string" || oldestSessionId === sessionId) break; + eventHistoryBySession.delete(oldestSessionId); + } + }; type TranscriptHistoryCacheEntry = { transcriptPath: string; size: number; @@ -7428,7 +7445,7 @@ export function createAgentChatService(args: { const recordChatEventInHistory = (envelope: AgentChatEventEnvelope): void => { const current = eventHistoryBySession.get(envelope.sessionId) ?? []; current.push(envelope); - eventHistoryBySession.set(envelope.sessionId, boundRingEnvelopes(current)); + touchEventHistoryRing(envelope.sessionId, boundRingEnvelopes(current)); }; const rememberTranscriptHistoryCache = ( @@ -9839,7 +9856,6 @@ export function createAgentChatService(args: { if (merged.length > CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION) { merged = merged.slice(-CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION); } - eventHistoryBySession.set(sessionId, boundRingEnvelopes(merged.slice())); const parentVisibleMerged = merged.filter((entry) => !isCodexSubagentTranscriptEnvelope(entry)); const parentVisibleLength = parentVisibleMerged.length; @@ -15333,7 +15349,7 @@ export function createAgentChatService(args: { // Single bulk ring update; the per-envelope recordChatEventInHistory // re-bounds the whole buffer each call (O(n²) across a large import). const current = eventHistoryBySession.get(managed.session.id) ?? []; - eventHistoryBySession.set( + touchEventHistoryRing( managed.session.id, boundRingEnvelopes([...current, ...storedEnvelopes]), ); @@ -44549,6 +44565,10 @@ export function createAgentChatService(args: { ensureSessionSurface, hasActiveWorkloads, hasRetainableSessions, + residentChatEventHistorySessionCount: () => eventHistoryBySession.size, + seedLiveChatEventHistory(envelopes: AgentChatEventEnvelope[]): void { + for (const envelope of envelopes) recordChatEventInHistory(envelope); + }, countActiveForLane, disposeForLane, getChatTranscript, diff --git a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx index e0e1f6f7d..cd026222a 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.test.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.test.tsx @@ -4,7 +4,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; -import { AccountPage, SignInCard, reconnectNeedsFreshSignIn } from "./AccountPage"; +import { AccountPage, SignInCard, describeThisComputerMissing, reconnectNeedsFreshSignIn } from "./AccountPage"; import { PAIRING_REAUTHENTICATION_REQUIRED_MESSAGE } from "../../../../../ade-cli/src/services/account/accountMachinePublisherService"; import { docs } from "../../onboarding/docsLinks"; import type { AdeAccountMachine, AdeAccountStatus } from "../../../shared/types"; @@ -188,7 +188,7 @@ describe("AccountPage signed-in", () => { const signOut = vi.fn(async () => SIGNED_OUT); const repairMachinePairing = vi.fn(); - /** The directory answers `ok` but has no row for this computer — i.e. removed. */ + /** The directory answers `ok` but has no row for this computer. */ function machinesWithoutThisComputer() { listMachines.mockResolvedValue({ state: "ok", @@ -219,7 +219,10 @@ describe("AccountPage signed-in", () => { }); getLocalMachineIdentity.mockResolvedValue({ machineKey: "this-key", deviceId: "this-dev" }); window.ade = { - app: { openExternal: vi.fn(async () => undefined) }, + app: { + openExternal: vi.fn(async () => undefined), + restartBackgroundService: vi.fn(async () => undefined), + }, github: { getStatus: vi.fn(async () => ({ connected: false })), onStatusChanged: vi.fn(() => () => {}), @@ -230,6 +233,12 @@ describe("AccountPage signed-in", () => { removeMachine, renameMachine, repairMachinePairing, + repairSession: vi.fn(async () => ({ + outcome: "repaired" as const, + readable: true, + recoveredKeys: 0, + brainRestarted: true, + })), signOut, }, } as unknown as typeof window.ade; @@ -433,6 +442,12 @@ describe("AccountPage signed-in", () => { renderPage(); expect(await screen.findByText("This computer isn't on your account")).toBeTruthy(); + expect(screen.queryByText(/It was removed/)).toBeNull(); + expect( + screen.getByText(/isn't sharing Activity and your other computers can't reach it/), + ).toBeTruthy(); + expect(screen.getByText(/Repair restarts ADE's background service/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Repair" })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Reconnect this computer" })); await waitFor(() => expect(repairMachinePairing).toHaveBeenCalledTimes(1)); @@ -443,6 +458,16 @@ describe("AccountPage signed-in", () => { ).toBeTruthy(); }); + it("repairs the background service from a missing directory row", async () => { + machinesWithoutThisComputer(); + renderPage(); + await screen.findByText("This computer isn't on your account"); + + fireEvent.click(screen.getByRole("button", { name: "Repair" })); + await waitFor(() => expect(window.ade.account?.repairSession).toHaveBeenCalledTimes(1)); + expect(await screen.findByText("Fixed — your sign-in is back.")).toBeTruthy(); + }); + it("shows the brain's reason and says the computer is still disconnected on failure", async () => { machinesWithoutThisComputer(); repairMachinePairing.mockResolvedValue({ @@ -514,6 +539,7 @@ describe("AccountPage signed-in", () => { fireEvent.click(screen.getByRole("button", { name: "Reconnect this computer" })); const pending = await screen.findByRole("button", { name: "Reconnecting…" }); expect(pending.hasAttribute("disabled")).toBe(true); + expect(screen.getByRole("button", { name: "Repair" }).hasAttribute("disabled")).toBe(true); fireEvent.click(pending); expect(repairMachinePairing).toHaveBeenCalledTimes(1); @@ -774,3 +800,24 @@ describe("AccountPage signed-in", () => { expect(screen.queryByText(/still connect from Connections/)).toBeNull(); }); }); + +describe("describeThisComputerMissing", () => { + it("never claims removal as fact for an active session", () => { + const copy = describeThisComputerMissing("active"); + expect(copy.title).toBe("This computer isn't on your account"); + expect(copy.body).toMatch(/isn't sharing Activity/); + expect(copy.body).toMatch(/Repair restarts ADE's background service/); + expect(copy.body).not.toMatch(/It was removed/); + }); + + it("explains an expired sign-in as a publish stop, not a removal", () => { + expect(describeThisComputerMissing("expired").body).toMatch(/sign-in expired/); + expect(describeThisComputerMissing("expired").body).toMatch(/Repair restarts ADE's background service/); + expect(describeThisComputerMissing("expired").body).not.toMatch(/It was removed/); + }); + + it("points an unreadable session at Repair rather than removal", () => { + expect(describeThisComputerMissing("unreadable").body).toMatch(/can't read this computer's sign-in/); + expect(describeThisComputerMissing("unreadable").body).not.toMatch(/It was removed/); + }); +}); diff --git a/apps/desktop/src/renderer/components/account/AccountPage.tsx b/apps/desktop/src/renderer/components/account/AccountPage.tsx index 68ec63104..3c19d307a 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -1,12 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { useLocation, useNavigate } from "react-router-dom"; import { ArrowLeft, ArrowRight, CircleNotch, - DesktopTower, - DotsThreeVertical, GithubLogo, Laptop, Question, @@ -15,22 +12,13 @@ import { X, } from "@phosphor-icons/react"; import type { CSSProperties } from "react"; -import type { - AdeAccountLocalMachineIdentity, - AdeAccountMachinePairingRepairResult, - AdeAccountMachineRemovalResult, - GitHubStatus, -} from "../../../shared/types"; -import { ADE_ACCOUNT_PAIRING_AUTHENTICATION_REQUIRED_CODE } from "../../../shared/types/account"; -import { accountMachineDisplayName } from "../../../shared/accountDirectory"; -import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; +import type { GitHubStatus } from "../../../shared/types"; import { COLORS, RADII, SANS_FONT, cardStyle, dangerButton, - inlineBadge, outlineButton, primaryButton, } from "../lanes/laneDesignTokens"; @@ -42,138 +30,28 @@ import { accountSessionState, accountSessionTitle, fetchAccountStatus, - invalidateAccountMachines, providerTint, - publishAccountMachines, publishAccountStatus, useAccountStatus, - type AdeAccountMachine, - type AdeAccountMachinesResult, type AdeAccountSessionState, type AdeAccountStatus, } from "../../lib/account"; -import { - runAccountDeviceLogin, - useAccountLogin, - type AccountDeviceLoginPrompt, -} from "../../lib/accountLogin"; -import { - formatMachineEndpoint, - relativeLastSeenPhrase, -} from "../remoteTargets/remoteMachineModel"; -import { openConnectionsPanel } from "../../lib/connectionsPanel"; +import { useAccountLogin } from "../../lib/accountLogin"; import { openExternalUrl } from "../../lib/openExternal"; -import { isWebClientMode } from "../../lib/webClientMode"; import { docs } from "../../onboarding/docsLinks"; -import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; import { useBrainRepair } from "../../hooks/useBrainRepair"; import { BrainRepairButton } from "../settings/BrainRepairButton"; +import { ConfirmSheet, YourMacsCard } from "./YourMacsCard"; import { settingsRouteFor } from "../settings/settingsManifest"; +export { describeThisComputerMissing, reconnectNeedsFreshSignIn } from "./YourMacsCard"; + const REPO_BRIDGE_DISMISS_KEY = "ade.account.repoBridgeDismissed.v1"; -const MACHINES_REFRESH_MS = 30_000; -const ACCOUNT_MENU_WIDTH = 200; type AccountBridge = { - listMachines: () => Promise; - getLocalMachineIdentity: () => Promise; - removeMachine: (machineKey: string) => Promise; - repairMachinePairing: () => Promise; - renameMachine: ( - machineKey: string, - customName: string | null, - ) => Promise; signOut: () => Promise; }; -/** What the user is told after a reconnect attempt, and how it is styled. */ -type ReconnectOutcome = { tone: "success" | "warning" | "danger"; message: string }; - -/** Join the brain's reason onto our sentence without doubling its punctuation. */ -function sentence(reason: string): string { - const trimmed = reason.trim(); - return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; -} - -/** - * Turn a repair result into copy that stays true to what actually happened. - * - * Read against `repairMachinePairing` in ade-cli, not by intuition: - * `published` is true only on the path that also sets `repaired`, and a - * successful re-pair reports `pushRestored: false` whenever the push half was - * never gated — so `!pushRestored` on its own does NOT mean "still muted". - * - * The state that does mean it is `repaired && wasRevoked && !pushRestored`: - * something was gated, the directory took the machine back, and the push - * revocation did not lift with it. That machine is on the roster and silent — - * the exact failure the ade-cli side refuses to paper over — so it must not be - * reported as a clean reconnect. - */ -function describeReconnectOutcome( - result: AdeAccountMachinePairingRepairResult, -): ReconnectOutcome { - if (result.repaired) { - if (!result.wasRevoked) { - return { tone: "success", message: "This computer is already connected to your account." }; - } - return result.pushRestored - ? { - tone: "success", - message: "This computer is back on your account. Activity and alerts are delivering again.", - } - : { - tone: "warning", - message: - "This computer is back on your account, but it isn't delivering Activity yet. Reopen ADE on this computer to finish.", - }; - } - // Nothing was gated and the brain skipped the publish — no work to report. - if (result.state === "not_revoked") { - return { tone: "success", message: "This computer is already connected to your account." }; - } - return { - tone: "danger", - message: result.reason - ? `Couldn't reconnect this computer: ${sentence(result.reason)} It's still disconnected from your account.` - : "Couldn't reconnect this computer, so it's still disconnected from your account. Try again in a moment.", - }; -} - -/** - * Does this failed reconnect mean "prove a fresh sign-in", rather than a - * transport, configuration, or brain-availability failure? - * - * Decided by `reasonCode`, the brain's machine-readable answer. Both refusals - * still share `state: "http_error"`, but they no longer share a discriminator: - * `pairing_authentication_required` is the recoverable one, and a present code - * is authoritative — `machine_revoked` means the sentence must NOT be consulted - * to talk us into a sign-in the directory did not ask for. - * - * Fails CLOSED: an unrecognised or absent answer reports the brain's reason - * as-is rather than dragging the user into a browser sign-in that would not - * have fixed anything. - */ -export function reconnectNeedsFreshSignIn( - result: AdeAccountMachinePairingRepairResult, -): boolean { - if (result.repaired) return false; - if (result.reasonCode) { - return result.reasonCode === ADE_ACCOUNT_PAIRING_AUTHENTICATION_REQUIRED_CODE; - } - // COMPATIBILITY SHIM — older brain only. - // - // Brains before `reasonCode` existed encoded this refusal solely in the - // user-facing sentence `PAIRING_REAUTHENTICATION_REQUIRED_MESSAGE` (see - // `apps/ade-cli/src/services/account/accountMachinePublisherService.ts`; the - // renderer cannot import that module because it pulls in Node, so a test pins - // the two together). Matched loosely so small copy edits in those already- - // shipped builds do not break their recovery path. - // - // Delete this branch — and the test that pins the sentence — once the - // supported brain floor includes `reasonCode`. - return /\bsign in\b[\s\S]*\bagain on this computer\b/i.test(result.reason ?? ""); -} - function accountBridge(): Partial | undefined { return (window.ade as typeof window.ade & { account?: Partial }).account; } @@ -208,18 +86,6 @@ function writeDismissed(key: string): void { } } -function machineRouteHint(machine: AdeAccountMachine): string | null { - const endpoint = machine.reachableEndpoints[0]; - if (!endpoint) return null; - // Beginners never need the relay URL; the word is enough. - if (endpoint.kind === "relay") return "Relay"; - return formatMachineEndpoint(endpoint); -} - -function lastSeenLabel(lastSeenAt: number | null): string { - const phrase = relativeLastSeenPhrase(lastSeenAt); - return phrase ? `Last seen ${phrase}` : "Never seen"; -} const sectionLabelStyle: CSSProperties = { fontFamily: SANS_FONT, @@ -230,110 +96,6 @@ const sectionLabelStyle: CSSProperties = { color: COLORS.textMuted, }; -// --------------------------------------------------------------------------- -// Confirmation sheet — a calm modal consistent with the settings surface. -// --------------------------------------------------------------------------- - -function ConfirmSheet({ - title, - body, - confirmLabel, - danger, - busy, - onConfirm, - onCancel, -}: { - title: string; - body: string; - confirmLabel: string; - danger?: boolean; - busy?: boolean; - onConfirm: () => void; - onCancel: () => void; -}) { - useEffect(() => { - const handler = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.stopPropagation(); - onCancel(); - } - }; - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); - }, [onCancel]); - - return ( -
{ - if (event.target === event.currentTarget && !busy) onCancel(); - }} - style={{ - position: "fixed", - inset: 0, - zIndex: 9999, - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: 24, - background: "color-mix(in srgb, #000 55%, transparent)", - backdropFilter: "blur(2px)", - WebkitBackdropFilter: "blur(2px)", - }} - > -
-
-
- {title} -
-
- {body} -
-
-
- - -
-
-
- ); -} - // --------------------------------------------------------------------------- // Signed-out: the rich sign-in card. // --------------------------------------------------------------------------- @@ -564,843 +326,6 @@ export function SignInCard({ ); } -// --------------------------------------------------------------------------- -// Signed-in: Your computers — the account directory, this computer pinned first. -// --------------------------------------------------------------------------- - -function YourMacsCard() { - const webMode = isWebClientMode(); - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(true); - const [localIdentity, setLocalIdentity] = useState(null); - const [openMenuKey, setOpenMenuKey] = useState(null); - const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null); - const [pendingRemoval, setPendingRemoval] = useState(null); - const [removing, setRemoving] = useState(false); - const [removeError, setRemoveError] = useState(null); - const [renamingKey, setRenamingKey] = useState(null); - const [renameValue, setRenameValue] = useState(""); - const [renameBusy, setRenameBusy] = useState(false); - const [renameError, setRenameError] = useState(null); - const [reconnecting, setReconnecting] = useState(false); - const [reconnectOutcome, setReconnectOutcome] = useState(null); - const [signInPrompt, setSignInPrompt] = useState(null); - // A ref, not state: the in-flight sign-in loop reads it between polls, and a - // state value captured in that closure would stay false forever. - const reconnectCancelledRef = useRef(false); - - // Returns what it loaded as well as storing it: the reconnect flow reports - // its outcome from the directory's own answer, and reading it back out of - // state would race the render that has not happened yet. - const load = useCallback(async (): Promise => { - const api = accountBridge(); - const unavailable: AdeAccountMachinesResult = { - state: "unavailable", - machines: [], - message: null, - }; - if (!api?.listMachines) { - setResult(unavailable); - setLoading(false); - return unavailable; - } - try { - const next = await api.listMachines(); - setResult(next); - // Warm the shared cache so the Connections popover opens with this list - // instead of racing its own cold fetch. - publishAccountMachines(next); - return next; - } catch { - setResult(unavailable); - return unavailable; - } finally { - setLoading(false); - } - }, []); - - // Identify this computer once so it can be pinned and shielded from removal. - useEffect(() => { - let cancelled = false; - const api = accountBridge(); - void api - ?.getLocalMachineIdentity?.() - .then((identity) => { - if (!cancelled) setLocalIdentity(identity); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); - - // Load now, then keep fresh while visible and on window focus. - useEffect(() => { - void load(); - const interval = window.setInterval(() => void load(), MACHINES_REFRESH_MS); - const onFocus = () => void load(); - window.addEventListener("focus", onFocus); - return () => { - window.clearInterval(interval); - window.removeEventListener("focus", onFocus); - }; - }, [load]); - - const isThisMac = useCallback( - (machine: AdeAccountMachine): boolean => { - if (!localIdentity) return false; - if (machine.machineKey === localIdentity.machineKey) return true; - return Boolean(machine.deviceId) && machine.deviceId === localIdentity.deviceId; - }, - [localIdentity], - ); - - const machines = useMemo(() => { - const list = [...(result?.machines ?? [])]; - // Pin this computer first; keep directory order otherwise. - return list.sort((a, b) => (isThisMac(b) ? 1 : 0) - (isThisMac(a) ? 1 : 0)); - }, [result?.machines, isThisMac]); - - const onlineCount = machines.filter((m) => m.online).length; - - /** - * Is THIS computer missing from its own account directory? - * - * A connected machine republishes itself every 30 seconds, so a directory - * that answers `ok` without a row for this machine is not a slow read — it is - * the account-side removal, still latched. `machineKey` is checked for - * emptiness because the hosted web adapter reports a blank identity, and a - * browser is a controller rather than a directory machine: without this, the - * banner would fire on every web session and offer a repair no browser can - * perform. - */ - const thisMachineMissing = - !webMode - && result?.state === "ok" - && Boolean(localIdentity?.machineKey) - && machines.length > 0 - && !machines.some((candidate) => isThisMac(candidate)); - - const canReconnect = !webMode && typeof accountBridge()?.repairMachinePairing === "function"; - - /** - * Reconnect this computer, signing in again first when the directory demands - * proof of one. - * - * The re-pair is attempted first, because it is the only step needed when the - * removal left nothing that requires fresh authentication (a push-only gate, - * or a directory grant already in hand). When the directory does refuse for - * want of a fresh sign-in, escalating in the same click is the whole point: - * the refusal's own advice — "sign in again on this computer" — is exactly - * what the user just did by pressing this button. - * - * The sign-in runs through the DEVICE flow, not the loopback flow the sign-in - * card uses. Only the device flow passes through ADE's account directory, so - * only it can end with the directory minting the single-use pairing grant - * that gets a removed machine back on the roster. - * - * Nothing re-triggers the repair afterwards: the brain already re-pairs on - * its own when an interactive sign-in completes while this machine is - * revoked. So the follow-through is the directory read below, which reports - * the outcome the user cares about — is this computer on the list again. - */ - const reconnectThisMachine = useCallback(async () => { - const api = accountBridge(); - if (!api?.repairMachinePairing) return; - setReconnecting(true); - setReconnectOutcome(null); - setSignInPrompt(null); - reconnectCancelledRef.current = false; - try { - const first = await api.repairMachinePairing(); - if (!reconnectNeedsFreshSignIn(first)) { - setReconnectOutcome(describeReconnectOutcome(first)); - invalidateAccountMachines(); - await load(); - return; - } - const signIn = await runAccountDeviceLogin({ - onPrompt: setSignInPrompt, - isCancelled: () => reconnectCancelledRef.current, - }); - setSignInPrompt(null); - if (signIn.status === "cancelled") return; - if (signIn.status === "failed") { - setReconnectOutcome({ tone: "danger", message: signIn.message }); - return; - } - invalidateAccountMachines(); - const refreshed = await load(); - const back = Boolean( - refreshed?.state === "ok" - && refreshed.machines.some((candidate) => isThisMac(candidate)), - ); - setReconnectOutcome( - back - ? { - tone: "success", - message: "This computer is back on your account. Activity and alerts are delivering again.", - } - : { - tone: "danger", - message: - "You're signed in, but this computer still isn't on your account. Try reconnecting it again.", - }, - ); - } catch (err) { - // Main already translated the brain's failure into a sentence; only a - // truly unexpected throw reaches the fallback. - setReconnectOutcome({ - tone: "danger", - message: err instanceof Error && err.message - ? err.message - : "Couldn't reconnect this computer to your account. Try again in a moment.", - }); - } finally { - setSignInPrompt(null); - setReconnecting(false); - } - }, [load, isThisMac]); - - const cancelReconnect = useCallback(() => { - reconnectCancelledRef.current = true; - setSignInPrompt(null); - }, []); - - // The ⋮ menu is rendered in a fixed portal so it can never be clipped by, or - // stack behind, the cards that follow this one (mirrors the TabNav pattern). - const { ref: menuRef, position: menuPosition } = useClampedFixedPosition(menuAnchor, openMenuKey); - const menuItemRef = useRef(null); - const menuTriggerRef = useRef(null); - const openMenuMachine = useMemo( - () => machines.find((m) => m.machineKey === openMenuKey) ?? null, - [machines, openMenuKey], - ); - const closeMenu = useCallback(() => { - const trigger = menuTriggerRef.current; - setOpenMenuKey(null); - setMenuAnchor(null); - menuTriggerRef.current = null; - if (trigger?.isConnected) trigger.focus(); - }, []); - const openMenu = useCallback((machineKey: string, anchorEl: HTMLElement) => { - const rect = anchorEl.getBoundingClientRect(); - menuTriggerRef.current = anchorEl; - setMenuAnchor({ x: rect.right - ACCOUNT_MENU_WIDTH, y: rect.bottom + 4 }); - setOpenMenuKey(machineKey); - }, []); - - useEffect(() => { - if (openMenuKey) menuItemRef.current?.focus(); - }, [openMenuKey]); - - const startRename = useCallback((machine: AdeAccountMachine) => { - setRenameError(null); - setRenameValue(accountMachineDisplayName(machine) ?? ""); - setRenamingKey(machine.machineKey); - }, []); - - const cancelRename = useCallback(() => { - setRenamingKey(null); - setRenameError(null); - }, []); - - /** - * `customName === null` clears the override and falls back to the hostname — - * the same contract the Connections panel's rename uses, so a machine renamed - * here and a machine renamed there cannot end up in different states. - */ - const saveRename = useCallback( - async (machine: AdeAccountMachine, customName?: string | null) => { - const api = accountBridge(); - if (!api?.renameMachine) return; - const nextName = customName === undefined ? renameValue.trim() : customName; - if (nextName !== null && !nextName) return; - setRenameBusy(true); - setRenameError(null); - try { - await api.renameMachine(machine.machineKey, nextName); - setRenamingKey(null); - await load(); - } catch (err) { - setRenameError( - err instanceof Error ? err.message : "Couldn't rename this computer.", - ); - } finally { - setRenameBusy(false); - } - }, - [renameValue, load], - ); - - let summary: string; - if (loading && !result) summary = "Checking your computers…"; - else if (result?.state === "ok") { - summary = - machines.length === 0 - ? "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 computers"; - } else { - summary = "Can't reach the account directory"; - } - - const confirmRemoval = useCallback(async () => { - const target = pendingRemoval; - const api = accountBridge(); - if (!target || !api?.removeMachine) { - setPendingRemoval(null); - return; - } - setRemoving(true); - setRemoveError(null); - try { - await api.removeMachine(target.machineKey); - setPendingRemoval(null); - invalidateAccountMachines(); - await load(); - } catch (err) { - setRemoveError(err instanceof Error ? err.message : "Couldn't remove that computer from your account."); - } finally { - setRemoving(false); - } - }, [pendingRemoval, load]); - - return ( -
-
-
- - - -
-
- Your computers -
-
{summary}
-
-
- {!webMode ? ( - - ) : null} -
- - {/* - The removal is a one-way door without this. Restarting, signing out and - back in, and reinstalling all leave both latches set, so a user who - removed the wrong machine has no way back — which is why this is a - banner in the place they are already looking, not a buried button. - */} - {thisMachineMissing && canReconnect ? ( -
- -
-
- This computer isn't on your account -
-
- It was removed, so it stopped sharing Activity and your other computers can't reach it. -
-
- -
- ) : null} - - {result?.state === "ok" && machines.length > 0 ? ( -
- {machines.map((machine) => { - const thisMac = isThisMac(machine); - const menuOpen = openMenuKey === machine.machineKey; - const renaming = renamingKey === machine.machineKey; - const rightText = thisMac - ? null - : machine.online - ? machineRouteHint(machine) ?? "Online" - : lastSeenLabel(machine.lastSeenAt); - return ( -
- - - {renaming ? ( -
{ - event.preventDefault(); - void saveRename(machine); - }} - style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0, flex: 1 }} - > - setRenameValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault(); - cancelRename(); - } - }} - style={{ - minWidth: 0, - flex: 1, - height: 28, - borderRadius: RADII.sm, - border: `1px solid ${COLORS.borderMuted}`, - background: COLORS.recessedBg, - color: COLORS.textPrimary, - fontFamily: SANS_FONT, - fontSize: 12.5, - padding: "0 9px", - outline: "none", - }} - /> - - {machine.customName ? ( - - ) : null} - -
- ) : ( - <> - - {accountMachineDisplayName(machine) ?? "Unnamed computer"} - - {thisMac ? ( - - {THIS_MACHINE_NAME} - - ) : null} - - )} - - {rightText ? ( - - {rightText} - - ) : null} - {renaming ? ( - - ) : ( - - )} -
- ); - })} -
- ) : null} - - {renameError ? ( -
- {renameError} -
- ) : null} - - {/* - The directory refused the re-pair without proof of a fresh sign-in, so - one is in flight. The browser is already open on the pre-filled page; - the code is shown for the case where it opened without it. - */} - {signInPrompt ? ( -
- -
- Finish signing in in your browser to reconnect this computer… -
- If the page asks for a code, enter{" "} - - {signInPrompt.userCode} - - . -
-
- -
- ) : null} - - {/* - Rendered independently of the banner: a successful reconnect refreshes - the directory and the banner disappears with it, and the confirmation - must outlive the state that prompted it. - */} - {reconnectOutcome ? ( -
- {reconnectOutcome.message} -
- ) : null} - - {removeError ? ( -
- {removeError} -
- ) : null} - - {result?.state === "unavailable" || result?.state === "not_configured" ? ( -
- - {webMode - ? "Use the machine menu above to switch computers." - : result.state === "not_configured" - ? "Your computers still connect from Connections — the shared directory just isn't live yet." - : "Your computers still connect from Connections while the directory reconnects."} - - {result.state === "unavailable" ? ( - - ) : null} -
- ) : null} - - {openMenuKey && openMenuMachine && menuAnchor - ? createPortal( - <> -
-
{ - if (event.key !== "Escape") return; - event.preventDefault(); - event.stopPropagation(); - closeMenu(); - }} - style={{ - position: "fixed", - left: menuPosition?.left ?? menuAnchor.x, - top: menuPosition?.top ?? menuAnchor.y, - visibility: menuPosition ? "visible" : "hidden", - zIndex: 9999, - width: ACCOUNT_MENU_WIDTH, - padding: 4, - borderRadius: RADII.md, - background: COLORS.cardBgSolid, - border: `1px solid ${COLORS.outlineBorder}`, - boxShadow: "0 18px 44px -24px rgba(0,0,0,0.8)", - }} - > - - {/* - Always offered for the local row, not only when the banner - fires. Detection needs the directory to answer `ok`, so a - machine whose directory read is failing — or whose account has - no rows at all — would otherwise have no way back at all. - */} - {isThisMac(openMenuMachine) && canReconnect ? ( - - ) : null} - {/* - Removal stays withheld for the local machine. Signing this - computer out of the account from this computer is what the - sign-out card is for; "remove" here means "evict some other - machine", and pointing it at yourself would be a different - and far more destructive action wearing the same label. - */} - {!isThisMac(openMenuMachine) ? ( - - ) : null} -
- , - document.body, - ) - : null} - - {/* - Removal is only reachable from a row's options menu, and that menu is - withheld for the local machine — so this sheet always names some OTHER - machine. It must never borrow THIS_MACHINE_NAME, and it can't assume the - machine on the far end runs macOS. - */} - {pendingRemoval ? ( - void confirmRemoval()} - onCancel={() => { - if (!removing) setPendingRemoval(null); - }} - /> - ) : null} -
- ); -} // --------------------------------------------------------------------------- // Signed-in: sign-out card (honest single-machine scope, behind a confirmation). diff --git a/apps/desktop/src/renderer/components/account/YourMacsCard.tsx b/apps/desktop/src/renderer/components/account/YourMacsCard.tsx new file mode 100644 index 000000000..bc32b8d81 --- /dev/null +++ b/apps/desktop/src/renderer/components/account/YourMacsCard.tsx @@ -0,0 +1,1163 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ArrowRight, + CircleNotch, + DesktopTower, + DotsThreeVertical, + Laptop, + WarningCircle, +} from "@phosphor-icons/react"; +import type { + AdeAccountLocalMachineIdentity, + AdeAccountMachinePairingRepairResult, + AdeAccountMachineRemovalResult, +} from "../../../shared/types"; +import { ADE_ACCOUNT_PAIRING_AUTHENTICATION_REQUIRED_CODE } from "../../../shared/types/account"; +import { accountMachineDisplayName } from "../../../shared/accountDirectory"; +import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; +import { + COLORS, + RADII, + SANS_FONT, + cardStyle, + dangerButton, + inlineBadge, + outlineButton, + primaryButton, +} from "../lanes/laneDesignTokens"; +import { + accountSessionState, + invalidateAccountMachines, + publishAccountMachines, + useAccountStatus, + type AdeAccountMachine, + type AdeAccountMachinesResult, + type AdeAccountSessionState, + type AdeAccountStatus, +} from "../../lib/account"; +import { + runAccountDeviceLogin, + type AccountDeviceLoginPrompt, +} from "../../lib/accountLogin"; +import { + formatMachineEndpoint, + relativeLastSeenPhrase, +} from "../remoteTargets/remoteMachineModel"; +import { openConnectionsPanel } from "../../lib/connectionsPanel"; +import { isWebClientMode } from "../../lib/webClientMode"; +import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; +import { useBrainRepair } from "../../hooks/useBrainRepair"; +import { BrainRepairButton } from "../settings/BrainRepairButton"; + +const MACHINES_REFRESH_MS = 30_000; +const ACCOUNT_MENU_WIDTH = 200; + +type AccountBridge = { + listMachines: () => Promise; + getLocalMachineIdentity: () => Promise; + removeMachine: (machineKey: string) => Promise; + repairMachinePairing: () => Promise; + renameMachine: ( + machineKey: string, + customName: string | null, + ) => Promise; + signOut: () => Promise; +}; + +function accountBridge(): Partial | undefined { + return (window.ade as typeof window.ade & { account?: Partial }).account; +} + +// --------------------------------------------------------------------------- +// Confirmation sheet — a calm modal consistent with the settings surface. +// --------------------------------------------------------------------------- + +export function ConfirmSheet({ + title, + body, + confirmLabel, + danger, + busy, + onConfirm, + onCancel, +}: { + title: string; + body: string; + confirmLabel: string; + danger?: boolean; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + onCancel(); + } + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [onCancel]); + + return ( +
{ + if (event.target === event.currentTarget && !busy) onCancel(); + }} + style={{ + position: "fixed", + inset: 0, + zIndex: 9999, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 24, + background: "color-mix(in srgb, #000 55%, transparent)", + backdropFilter: "blur(2px)", + WebkitBackdropFilter: "blur(2px)", + }} + > +
+
+
+ {title} +
+
+ {body} +
+
+
+ + +
+
+
+ ); +} + +/** What the user is told after a reconnect attempt, and how it is styled. */ +type ReconnectOutcome = { tone: "success" | "warning" | "danger"; message: string }; + +/** Join the brain's reason onto our sentence without doubling its punctuation. */ +function sentence(reason: string): string { + const trimmed = reason.trim(); + return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; +} + +/** + * Turn a repair result into copy that stays true to what actually happened. + * + * Read against `repairMachinePairing` in ade-cli, not by intuition: + * `published` is true only on the path that also sets `repaired`, and a + * successful re-pair reports `pushRestored: false` whenever the push half was + * never gated — so `!pushRestored` on its own does NOT mean "still muted". + * + * The state that does mean it is `repaired && wasRevoked && !pushRestored`: + * something was gated, the directory took the machine back, and the push + * revocation did not lift with it. That machine is on the roster and silent — + * the exact failure the ade-cli side refuses to paper over — so it must not be + * reported as a clean reconnect. + */ +function describeReconnectOutcome( + result: AdeAccountMachinePairingRepairResult, +): ReconnectOutcome { + if (result.repaired) { + if (!result.wasRevoked) { + return { tone: "success", message: "This computer is already connected to your account." }; + } + return result.pushRestored + ? { + tone: "success", + message: "This computer is back on your account. Activity and alerts are delivering again.", + } + : { + tone: "warning", + message: + "This computer is back on your account, but it isn't delivering Activity yet. Reopen ADE on this computer to finish.", + }; + } + // Nothing was gated and the brain skipped the publish — no work to report. + if (result.state === "not_revoked") { + return { tone: "success", message: "This computer is already connected to your account." }; + } + return { + tone: "danger", + message: result.reason + ? `Couldn't reconnect this computer: ${sentence(result.reason)} It's still disconnected from your account.` + : "Couldn't reconnect this computer, so it's still disconnected from your account. Try again in a moment.", + }; +} + +/** + * Does this failed reconnect mean "prove a fresh sign-in", rather than a + * transport, configuration, or brain-availability failure? + * + * Decided by `reasonCode`, the brain's machine-readable answer. Both refusals + * still share `state: "http_error"`, but they no longer share a discriminator: + * `pairing_authentication_required` is the recoverable one, and a present code + * is authoritative — `machine_revoked` means the sentence must NOT be consulted + * to talk us into a sign-in the directory did not ask for. + * + * Fails CLOSED: an unrecognised or absent answer reports the brain's reason + * as-is rather than dragging the user into a browser sign-in that would not + * have fixed anything. + */ +export function reconnectNeedsFreshSignIn( + result: AdeAccountMachinePairingRepairResult, +): boolean { + if (result.repaired) return false; + if (result.reasonCode) { + return result.reasonCode === ADE_ACCOUNT_PAIRING_AUTHENTICATION_REQUIRED_CODE; + } + // COMPATIBILITY SHIM — older brain only. + // + // Brains before `reasonCode` existed encoded this refusal solely in the + // user-facing sentence `PAIRING_REAUTHENTICATION_REQUIRED_MESSAGE` (see + // `apps/ade-cli/src/services/account/accountMachinePublisherService.ts`; the + // renderer cannot import that module because it pulls in Node, so a test pins + // the two together). Matched loosely so small copy edits in those already- + // shipped builds do not break their recovery path. + // + // Delete this branch — and the test that pins the sentence — once the + // supported brain floor includes `reasonCode`. + return /\bsign in\b[\s\S]*\bagain on this computer\b/i.test(result.reason ?? ""); +} + +/** + * Body copy when this computer is missing from the account directory. + * + * Absence is not proof of removal: a publish gap, a split sync listener, or an + * expired sign-in all produce the same empty row. Never claim the computer was + * removed as fact. + */ +export function describeThisComputerMissing(sessionState: AdeAccountSessionState): { + title: string; + body: string; +} { + const title = "This computer isn't on your account"; + switch (sessionState) { + case "expired": + return { + title, + body: "This computer's ADE sign-in expired, so it stopped publishing itself to your account. Sign in again, then Reconnect. If the row still doesn't come back, Repair restarts ADE's background service on this computer.", + }; + case "unreadable": + return { + title, + body: "ADE can't read this computer's sign-in, so it isn't publishing itself. Repair the stored sign-in, then Reconnect.", + }; + case "signed_out": + case "active": + return { + title, + body: "This computer isn't listed on your account, so it isn't sharing Activity and your other computers can't reach it. That can happen if it was removed, or if ADE on this computer stopped publishing. Reconnect puts it back. If it still doesn't stick, Repair restarts ADE's background service here, then try Reconnect again.", + }; + default: { + const _exhaustive: never = sessionState; + return _exhaustive; + } + } +} + +function machineRouteHint(machine: AdeAccountMachine): string | null { + const endpoint = machine.reachableEndpoints[0]; + if (!endpoint) return null; + // Beginners never need the relay URL; the word is enough. + if (endpoint.kind === "relay") return "Relay"; + return formatMachineEndpoint(endpoint); +} + +function lastSeenLabel(lastSeenAt: number | null): string { + const phrase = relativeLastSeenPhrase(lastSeenAt); + return phrase ? `Last seen ${phrase}` : "Never seen"; +} + +// --------------------------------------------------------------------------- +// Signed-in: Your computers — the account directory, this computer pinned first. +// --------------------------------------------------------------------------- + +export function YourMacsCard() { + const webMode = isWebClientMode(); + const { status } = useAccountStatus(); + const missingCopy = describeThisComputerMissing(accountSessionState(status)); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [localIdentity, setLocalIdentity] = useState(null); + const [openMenuKey, setOpenMenuKey] = useState(null); + const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number } | null>(null); + const [pendingRemoval, setPendingRemoval] = useState(null); + const [removing, setRemoving] = useState(false); + const [removeError, setRemoveError] = useState(null); + const [renamingKey, setRenamingKey] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [renameBusy, setRenameBusy] = useState(false); + const [renameError, setRenameError] = useState(null); + const [reconnecting, setReconnecting] = useState(false); + const [reconnectOutcome, setReconnectOutcome] = useState(null); + const [signInPrompt, setSignInPrompt] = useState(null); + // A ref, not state: the in-flight sign-in loop reads it between polls, and a + // state value captured in that closure would stay false forever. + const reconnectCancelledRef = useRef(false); + + // Returns what it loaded as well as storing it: the reconnect flow reports + // its outcome from the directory's own answer, and reading it back out of + // state would race the render that has not happened yet. + const load = useCallback(async (): Promise => { + const api = accountBridge(); + const unavailable: AdeAccountMachinesResult = { + state: "unavailable", + machines: [], + message: null, + }; + if (!api?.listMachines) { + setResult(unavailable); + setLoading(false); + return unavailable; + } + try { + const next = await api.listMachines(); + setResult(next); + // Warm the shared cache so the Connections popover opens with this list + // instead of racing its own cold fetch. + publishAccountMachines(next); + return next; + } catch { + setResult(unavailable); + return unavailable; + } finally { + setLoading(false); + } + }, []); + + const repair = useBrainRepair(() => { + invalidateAccountMachines(); + void load(); + }); + + // Identify this computer once so it can be pinned and shielded from removal. + useEffect(() => { + let cancelled = false; + const api = accountBridge(); + void api + ?.getLocalMachineIdentity?.() + .then((identity) => { + if (!cancelled) setLocalIdentity(identity); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + // Load now, then keep fresh while visible and on window focus. + useEffect(() => { + void load(); + const interval = window.setInterval(() => void load(), MACHINES_REFRESH_MS); + const onFocus = () => void load(); + window.addEventListener("focus", onFocus); + return () => { + window.clearInterval(interval); + window.removeEventListener("focus", onFocus); + }; + }, [load]); + + const isThisMac = useCallback( + (machine: AdeAccountMachine): boolean => { + if (!localIdentity) return false; + if (machine.machineKey === localIdentity.machineKey) return true; + return Boolean(machine.deviceId) && machine.deviceId === localIdentity.deviceId; + }, + [localIdentity], + ); + + const machines = useMemo(() => { + const list = [...(result?.machines ?? [])]; + // Pin this computer first; keep directory order otherwise. + return list.sort((a, b) => (isThisMac(b) ? 1 : 0) - (isThisMac(a) ? 1 : 0)); + }, [result?.machines, isThisMac]); + + const onlineCount = machines.filter((m) => m.online).length; + + /** + * Is THIS computer missing from its own account directory? + * + * A connected machine republishes itself every 30 seconds. A directory that + * answers `ok` without a row for this machine can mean removal — or a publish + * gap (expired sign-in, split sync listener, skipped publisher). `machineKey` + * is checked for emptiness because the hosted web adapter reports a blank + * identity, and a browser is a controller rather than a directory machine: + * without this, the banner would fire on every web session and offer a repair + * no browser can perform. + */ + const thisMachineMissing = + !webMode + && result?.state === "ok" + && Boolean(localIdentity?.machineKey) + && machines.length > 0 + && !machines.some((candidate) => isThisMac(candidate)); + + const canReconnect = !webMode && typeof accountBridge()?.repairMachinePairing === "function"; + + /** + * Reconnect this computer, signing in again first when the directory demands + * proof of one. + * + * The re-pair is attempted first, because it is the only step needed when the + * removal left nothing that requires fresh authentication (a push-only gate, + * or a directory grant already in hand). When the directory does refuse for + * want of a fresh sign-in, escalating in the same click is the whole point: + * the refusal's own advice — "sign in again on this computer" — is exactly + * what the user just did by pressing this button. + * + * The sign-in runs through the DEVICE flow, not the loopback flow the sign-in + * card uses. Only the device flow passes through ADE's account directory, so + * only it can end with the directory minting the single-use pairing grant + * that gets a removed machine back on the roster. + * + * Nothing re-triggers the repair afterwards: the brain already re-pairs on + * its own when an interactive sign-in completes while this machine is + * revoked. So the follow-through is the directory read below, which reports + * the outcome the user cares about — is this computer on the list again. + */ + const reconnectThisMachine = useCallback(async () => { + const api = accountBridge(); + if (!api?.repairMachinePairing) return; + setReconnecting(true); + setReconnectOutcome(null); + setSignInPrompt(null); + reconnectCancelledRef.current = false; + try { + const first = await api.repairMachinePairing(); + if (!reconnectNeedsFreshSignIn(first)) { + setReconnectOutcome(describeReconnectOutcome(first)); + invalidateAccountMachines(); + await load(); + return; + } + const signIn = await runAccountDeviceLogin({ + onPrompt: setSignInPrompt, + isCancelled: () => reconnectCancelledRef.current, + }); + setSignInPrompt(null); + if (signIn.status === "cancelled") return; + if (signIn.status === "failed") { + setReconnectOutcome({ tone: "danger", message: signIn.message }); + return; + } + invalidateAccountMachines(); + const refreshed = await load(); + const back = Boolean( + refreshed?.state === "ok" + && refreshed.machines.some((candidate) => isThisMac(candidate)), + ); + setReconnectOutcome( + back + ? { + tone: "success", + message: "This computer is back on your account. Activity and alerts are delivering again.", + } + : { + tone: "danger", + message: + "You're signed in, but this computer still isn't on your account. Try reconnecting it again.", + }, + ); + } catch (err) { + // Main already translated the brain's failure into a sentence; only a + // truly unexpected throw reaches the fallback. + setReconnectOutcome({ + tone: "danger", + message: err instanceof Error && err.message + ? err.message + : "Couldn't reconnect this computer to your account. Try again in a moment.", + }); + } finally { + setSignInPrompt(null); + setReconnecting(false); + } + }, [load, isThisMac]); + + const cancelReconnect = useCallback(() => { + reconnectCancelledRef.current = true; + setSignInPrompt(null); + }, []); + + // The ⋮ menu is rendered in a fixed portal so it can never be clipped by, or + // stack behind, the cards that follow this one (mirrors the TabNav pattern). + const { ref: menuRef, position: menuPosition } = useClampedFixedPosition(menuAnchor, openMenuKey); + const menuItemRef = useRef(null); + const menuTriggerRef = useRef(null); + const openMenuMachine = useMemo( + () => machines.find((m) => m.machineKey === openMenuKey) ?? null, + [machines, openMenuKey], + ); + const closeMenu = useCallback(() => { + const trigger = menuTriggerRef.current; + setOpenMenuKey(null); + setMenuAnchor(null); + menuTriggerRef.current = null; + if (trigger?.isConnected) trigger.focus(); + }, []); + const openMenu = useCallback((machineKey: string, anchorEl: HTMLElement) => { + const rect = anchorEl.getBoundingClientRect(); + menuTriggerRef.current = anchorEl; + setMenuAnchor({ x: rect.right - ACCOUNT_MENU_WIDTH, y: rect.bottom + 4 }); + setOpenMenuKey(machineKey); + }, []); + + useEffect(() => { + if (openMenuKey) menuItemRef.current?.focus(); + }, [openMenuKey]); + + const startRename = useCallback((machine: AdeAccountMachine) => { + setRenameError(null); + setRenameValue(accountMachineDisplayName(machine) ?? ""); + setRenamingKey(machine.machineKey); + }, []); + + const cancelRename = useCallback(() => { + setRenamingKey(null); + setRenameError(null); + }, []); + + /** + * `customName === null` clears the override and falls back to the hostname — + * the same contract the Connections panel's rename uses, so a machine renamed + * here and a machine renamed there cannot end up in different states. + */ + const saveRename = useCallback( + async (machine: AdeAccountMachine, customName?: string | null) => { + const api = accountBridge(); + if (!api?.renameMachine) return; + const nextName = customName === undefined ? renameValue.trim() : customName; + if (nextName !== null && !nextName) return; + setRenameBusy(true); + setRenameError(null); + try { + await api.renameMachine(machine.machineKey, nextName); + setRenamingKey(null); + await load(); + } catch (err) { + setRenameError( + err instanceof Error ? err.message : "Couldn't rename this computer.", + ); + } finally { + setRenameBusy(false); + } + }, + [renameValue, load], + ); + + let summary: string; + if (loading && !result) summary = "Checking your computers…"; + else if (result?.state === "ok") { + summary = + machines.length === 0 + ? "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 computers"; + } else { + summary = "Can't reach the account directory"; + } + + const confirmRemoval = useCallback(async () => { + const target = pendingRemoval; + const api = accountBridge(); + if (!target || !api?.removeMachine) { + setPendingRemoval(null); + return; + } + setRemoving(true); + setRemoveError(null); + try { + await api.removeMachine(target.machineKey); + setPendingRemoval(null); + invalidateAccountMachines(); + await load(); + } catch (err) { + setRemoveError(err instanceof Error ? err.message : "Couldn't remove that computer from your account."); + } finally { + setRemoving(false); + } + }, [pendingRemoval, load]); + + return ( +
+
+
+ + + +
+
+ Your computers +
+
{summary}
+
+
+ {!webMode ? ( + + ) : null} +
+ + {/* + A missing directory row used to be treated as a one-way removal. Restart, + sign-out, and reinstall still cannot put a truly revoked machine back, + which is why Reconnect lives here — but the same empty row also appears + when this computer simply stopped publishing. The copy must not claim + removal as fact. + */} + {thisMachineMissing && canReconnect ? ( +
+ +
+
+ {missingCopy.title} +
+
+ {missingCopy.body} +
+
+ + {repair.available ? ( + + ) : null} +
+
+
+ ) : null} + + {result?.state === "ok" && machines.length > 0 ? ( +
+ {machines.map((machine) => { + const thisMac = isThisMac(machine); + const menuOpen = openMenuKey === machine.machineKey; + const renaming = renamingKey === machine.machineKey; + const rightText = thisMac + ? null + : machine.online + ? machineRouteHint(machine) ?? "Online" + : lastSeenLabel(machine.lastSeenAt); + return ( +
+ + + {renaming ? ( +
{ + event.preventDefault(); + void saveRename(machine); + }} + style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0, flex: 1 }} + > + setRenameValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelRename(); + } + }} + style={{ + minWidth: 0, + flex: 1, + height: 28, + borderRadius: RADII.sm, + border: `1px solid ${COLORS.borderMuted}`, + background: COLORS.recessedBg, + color: COLORS.textPrimary, + fontFamily: SANS_FONT, + fontSize: 12.5, + padding: "0 9px", + outline: "none", + }} + /> + + {machine.customName ? ( + + ) : null} + +
+ ) : ( + <> + + {accountMachineDisplayName(machine) ?? "Unnamed computer"} + + {thisMac ? ( + + {THIS_MACHINE_NAME} + + ) : null} + + )} + + {rightText ? ( + + {rightText} + + ) : null} + {renaming ? ( + + ) : ( + + )} +
+ ); + })} +
+ ) : null} + + {renameError ? ( +
+ {renameError} +
+ ) : null} + + {/* + The directory refused the re-pair without proof of a fresh sign-in, so + one is in flight. The browser is already open on the pre-filled page; + the code is shown for the case where it opened without it. + */} + {signInPrompt ? ( +
+ +
+ Finish signing in in your browser to reconnect this computer… +
+ If the page asks for a code, enter{" "} + + {signInPrompt.userCode} + + . +
+
+ +
+ ) : null} + + {/* + Rendered independently of the banner: a successful reconnect refreshes + the directory and the banner disappears with it, and the confirmation + must outlive the state that prompted it. + */} + {reconnectOutcome ? ( +
+ {reconnectOutcome.message} +
+ ) : null} + + {removeError ? ( +
+ {removeError} +
+ ) : null} + + {result?.state === "unavailable" || result?.state === "not_configured" ? ( +
+ + {webMode + ? "Use the machine menu above to switch computers." + : result.state === "not_configured" + ? "Your computers still connect from Connections — the shared directory just isn't live yet." + : "Your computers still connect from Connections while the directory reconnects."} + + {result.state === "unavailable" ? ( + + ) : null} +
+ ) : null} + + {openMenuKey && openMenuMachine && menuAnchor + ? createPortal( + <> +
+
{ + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + closeMenu(); + }} + style={{ + position: "fixed", + left: menuPosition?.left ?? menuAnchor.x, + top: menuPosition?.top ?? menuAnchor.y, + visibility: menuPosition ? "visible" : "hidden", + zIndex: 9999, + width: ACCOUNT_MENU_WIDTH, + padding: 4, + borderRadius: RADII.md, + background: COLORS.cardBgSolid, + border: `1px solid ${COLORS.outlineBorder}`, + boxShadow: "0 18px 44px -24px rgba(0,0,0,0.8)", + }} + > + + {/* + Always offered for the local row, not only when the banner + fires. Detection needs the directory to answer `ok`, so a + machine whose directory read is failing — or whose account has + no rows at all — would otherwise have no way back at all. + */} + {isThisMac(openMenuMachine) && canReconnect ? ( + + ) : null} + {/* + Removal stays withheld for the local machine. Signing this + computer out of the account from this computer is what the + sign-out card is for; "remove" here means "evict some other + machine", and pointing it at yourself would be a different + and far more destructive action wearing the same label. + */} + {!isThisMac(openMenuMachine) ? ( + + ) : null} +
+ , + document.body, + ) + : null} + + {/* + Removal is only reachable from a row's options menu, and that menu is + withheld for the local machine — so this sheet always names some OTHER + machine. It must never borrow THIS_MACHINE_NAME, and it can't assume the + machine on the far end runs macOS. + */} + {pendingRemoval ? ( + void confirmRemoval()} + onCancel={() => { + if (!removing) setPendingRemoval(null); + }} + /> + ) : null} +
+ ); +} + diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.test.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.test.ts index f0fdee39c..959e75614 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.test.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.test.ts @@ -11,6 +11,7 @@ import { accountMachineMatchesTarget, assignMachineSections, describePublishHealth, + formatRemoteTargetError, type LocalPublishHealth, } from "./remoteMachineModel"; @@ -363,3 +364,21 @@ describe("describePublishHealth", () => { ).toEqual({ kind: "failing", minutes: 5 }); }); }); + +describe("formatRemoteTargetError", () => { + it("does not blame sshd for a refused paired-sync port", () => { + expect(formatRemoteTargetError("ECONNREFUSED")).toBe( + "The machine refused the connection. Check that ADE is running on that computer and reachable on the saved port.", + ); + expect(formatRemoteTargetError("ECONNREFUSED")).not.toMatch(/sshd|Remote Login/); + }); + + it("does not blame sshd for a reset paired connection", () => { + expect(formatRemoteTargetError("ECONNRESET")).not.toMatch(/sshd|Remote Login|SSH/); + }); + + it("keeps SSH recovery copy when the error already names SSH", () => { + expect(formatRemoteTargetError("SSH ECONNREFUSED")).toMatch(/Remote Login\/sshd/); + expect(formatRemoteTargetError("sshd ECONNRESET")).toMatch(/Remote Login\/sshd/); + }); +}); diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts index ca9daa5ca..c54794d9b 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -320,9 +320,12 @@ export function formatRemoteTargetError(error: unknown): string { .replace(/^Error invoking remote method '[^']+':\s*/i, "") .replace(/^Error:\s*/i, "") .trim(); + const sshHint = /\b(?:ssh|sshd|remote login)\b/i.test(message); - if (/^(?:read\s+)?ECONNRESET$/i.test(message)) { - return "SSH server closed the connection before ADE could finish the SSH handshake. Check that Remote Login/sshd is enabled on the remote machine and try again."; + if (/ECONNRESET/i.test(message)) { + return sshHint + ? "SSH server closed the connection before ADE could finish the SSH handshake. Check that Remote Login/sshd is enabled on the remote machine and try again." + : "The connection was reset before ADE could finish connecting. Check that the machine is awake and reachable, then try again."; } if ( @@ -342,11 +345,15 @@ export function formatRemoteTargetError(error: unknown): string { message, ) ) { - return "SSH did not finish connecting. Check that the machine is awake, reachable on Tailscale or LAN, and Remote Login is enabled."; + return sshHint + ? "SSH did not finish connecting. Check that the machine is awake, reachable on Tailscale or LAN, and Remote Login is enabled." + : "ADE did not finish connecting. Check that the machine is awake and reachable on Tailscale or LAN."; } if (/ECONNREFUSED/i.test(message)) { - return "The machine refused the SSH connection. Check the port and make sure Remote Login/sshd is running."; + return sshHint + ? "The machine refused the SSH connection. Check the port and make sure Remote Login/sshd is running." + : "The machine refused the connection. Check that ADE is running on that computer and reachable on the saved port."; } if ( diff --git a/apps/desktop/src/renderer/components/settings/BrainRepairButton.tsx b/apps/desktop/src/renderer/components/settings/BrainRepairButton.tsx index 293c7a50c..443a63e16 100644 --- a/apps/desktop/src/renderer/components/settings/BrainRepairButton.tsx +++ b/apps/desktop/src/renderer/components/settings/BrainRepairButton.tsx @@ -9,19 +9,23 @@ import { COLORS, SANS_FONT, outlineButton } from "../lanes/laneDesignTokens"; export function BrainRepairButton({ repair, height, + disabled = false, }: { repair: BrainRepair; height: number; + /** Extra disable, e.g. while a sibling Reconnect is in flight. */ + disabled?: boolean; }) { + const blocked = repair.pending || disabled; return ( <>