fix(relay): stop a mid-read profile switch reporting as a sign-out

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.
This commit is contained in:
Neil
2026-09-10 18:35:23 -07:00
parent f1eb8037c8
commit ed35db892b
2 changed files with 100 additions and 1 deletions
+8 -1
View File
@@ -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 {
@@ -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
})
})
})