From ed35db892bc7388f7dc1c8d2c83d8eca6b9af357 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 10 Sep 2026 18:35:23 -0700 Subject: [PATCH] fix(relay): stop a mid-read profile switch reporting as a sign-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same taxonomy error 8972d744 fixed for the session file, in the other null exit. readRelayAuthContext re-reads the active profile after the refresh, because refresh and org selection can rewrite cloud linkage in flight. It then collapsed two unrelated outcomes into null: if (!cloud || refreshed.profile.id !== active.profile.id) return null A profile switch landing inside that window is not "the cloud session is gone", which is the contract the coordinator's own comment says null carries. Measured before: offlineReason "signed-out" — the terminal wire reason every paired phone latches as "sign in on your desktop to reconnect", arming no retry — for a race the switch's own authMutated() re-reads moments later. After: auth_unavailable, which is retryable. Split the condition: throw for the id mismatch, keep null for a profile that genuinely has no cloud linkage. Still unfixed, and not reachable from here: ensureActiveOrcaProfile treats an unreadable profile index the same as a corrupt one — readProfileIndexFile catches every error including EACCES/EMFILE, index and .bak fail together under descriptor exhaustion, and it fabricates a default local profile and writes it over the unreadable file. readRelayAuthContext then sees no cloud and reports a sign-out honestly, because by that point the index really has been replaced. That one belongs in profile-index-store.ts and changes app-wide sign-in semantics, the same caveat 8972d744 raised for decrypt-failed. --- src/main/runtime/relay/relay-auth-context.ts | 9 +- ...fecycle-auth-context-identity-race.test.ts | 92 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/relay/relay-lifecycle-auth-context-identity-race.test.ts diff --git a/src/main/runtime/relay/relay-auth-context.ts b/src/main/runtime/relay/relay-auth-context.ts index 53f156e8915..090a1f79975 100644 --- a/src/main/runtime/relay/relay-auth-context.ts +++ b/src/main/runtime/relay/relay-auth-context.ts @@ -24,8 +24,15 @@ export async function readRelayAuthContext( // Why: refresh and org-selection can rewrite cloud linkage while the request // is in flight; identity must come from the post-refresh profile state. const refreshed = ensureActiveOrcaProfile(userDataPath) + // Why throw rather than return null, same argument as the unreadable session above: + // a profile switch landing inside this read is not a sign-out, and null spends the + // terminal SIGNED_OUT — latched by every paired phone, arming no retry — on a race + // the switch's own auth mutation re-reads moments later. + if (refreshed.profile.id !== active.profile.id) { + throw new Error('orca_profile_switched_during_read') + } const cloud = refreshed.profile.cloud - if (!cloud || refreshed.profile.id !== active.profile.id) { + if (!cloud) { return null } return { diff --git a/src/main/runtime/relay/relay-lifecycle-auth-context-identity-race.test.ts b/src/main/runtime/relay/relay-lifecycle-auth-context-identity-race.test.ts new file mode 100644 index 00000000000..7e2f9a0b737 --- /dev/null +++ b/src/main/runtime/relay/relay-lifecycle-auth-context-identity-race.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest' +import { RELAY_HOST_CLOSE_REASON } from '../../../shared/relay-host-close-reason' + +// Same taxonomy question 8972d744 answered for the session file, asked of the +// other null exit: readRelayAuthContext re-reads the profile after the refresh, +// and a profile switch landing in that window is not a sign-out. +const fakes = vi.hoisted(() => ({ + ensureActiveOrcaProfile: vi.fn(), + readFreshOrcaCloudSession: vi.fn() +})) + +vi.mock('../../orca-profiles/profile-index-store', () => ({ + ensureActiveOrcaProfile: fakes.ensureActiveOrcaProfile +})) +vi.mock('../../orca-profiles/profile-cloud-session-refresh', () => ({ + readFreshOrcaCloudSession: fakes.readFreshOrcaCloudSession +})) + +import { readRelayAuthContext } from './relay-auth-context' +import { RelayAuthCoordinator } from './relay-auth-coordinator' + +const authConfig = {} as never + +function profile(id: string, cloud: object | null) { + return { profile: { id, ...(cloud ? { cloud } : {}) } } +} + +const signedIn = { userId: 'u1', cloudProfileId: 'cp1', activeOrgId: 'org-1' } + +function foundSession() { + return { + status: 'found', + session: { accessToken: 'access-1', capabilities: { flags: { 'relay.use': true } } } + } +} + +describe('readRelayAuthContext profile-switch race', () => { + it('refuses to call a mid-read profile switch a sign-out', async () => { + // ensureActiveOrcaProfile is called twice — once before the refresh and once + // after, because refresh and org selection can rewrite cloud linkage. A + // switch between them means "re-read", not "the cloud session is gone". + fakes.ensureActiveOrcaProfile + .mockReturnValueOnce(profile('profile-1', signedIn)) + .mockReturnValueOnce(profile('profile-2', signedIn)) + fakes.readFreshOrcaCloudSession.mockResolvedValue(foundSession()) + + await expect(readRelayAuthContext(authConfig, '/tmp/x')).rejects.toThrow( + 'orca_profile_switched_during_read' + ) + }) + + it('classifies the switch as auth_unavailable, never signed_out', async () => { + fakes.ensureActiveOrcaProfile + .mockReturnValueOnce(profile('profile-1', signedIn)) + .mockReturnValueOnce(profile('profile-2', signedIn)) + fakes.readFreshOrcaCloudSession.mockResolvedValue(foundSession()) + const broker = { closeNow: vi.fn() } + const coordinator = new RelayAuthCoordinator({ + readContext: () => readRelayAuthContext(authConfig, '/tmp/x'), + openBroker: async () => broker, + onStatus: vi.fn() + }) + coordinator.reconcile() + + const result = await coordinator.waitForLiveBrokerResult(0) + expect(result).toEqual({ broker: null, offlineReason: 'auth_unavailable' }) + // SIGNED_OUT is terminal on the wire — the phone latches it and arms no retry. + expect(broker.closeNow).not.toHaveBeenCalledWith(RELAY_HOST_CLOSE_REASON.SIGNED_OUT) + }) + + it('still reports a profile that genuinely has no cloud linkage as gone', async () => { + fakes.ensureActiveOrcaProfile + .mockReturnValueOnce(profile('profile-1', signedIn)) + .mockReturnValueOnce(profile('profile-1', null)) + fakes.readFreshOrcaCloudSession.mockResolvedValue(foundSession()) + + await expect(readRelayAuthContext(authConfig, '/tmp/x')).resolves.toBeNull() + }) + + it('returns the post-refresh identity when the profile held still', async () => { + fakes.ensureActiveOrcaProfile + .mockReturnValueOnce(profile('profile-1', signedIn)) + .mockReturnValueOnce(profile('profile-1', { ...signedIn, activeOrgId: 'org-2' })) + fakes.readFreshOrcaCloudSession.mockResolvedValue(foundSession()) + + await expect(readRelayAuthContext(authConfig, '/tmp/x')).resolves.toEqual({ + identity: { userId: 'u1', profileId: 'cp1', organizationId: 'org-2' }, + accessToken: 'access-1', + relayEntitled: true + }) + }) +})