diff --git a/src/main/__tests__/stdiodAccountSwitch.test.ts b/src/main/__tests__/stdiodAccountSwitch.test.ts new file mode 100644 index 0000000..0fb5656 --- /dev/null +++ b/src/main/__tests__/stdiodAccountSwitch.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +// Controller side effects are mocked so the tests never touch launchctl or +// write config.toml. Each mock is a spy we assert against. +let launchAgentLoaded = true +const uninstallMock = vi.fn(async (..._args: unknown[]) => ({ ok: true }) as const) +const resetMock = vi.fn(async (..._args: unknown[]) => ({ ok: true }) as const) + +vi.mock('../stdiod/controller', () => ({ + isLaunchAgentLoaded: async () => launchAgentLoaded, + uninstall: (...args: unknown[]) => uninstallMock(...(args as [])), + resetStdiod: (...args: unknown[]) => resetMock(...(args as [])) +})) + +vi.mock('../stdiod/stdiodLog', () => ({ stdiodLog: () => {} })) + +// Active-account credentials. Overridden per test. +let apiBaseUrl: string | null = 'https://api.testedison.example' +let creds: { apiKey: string; edisonSecretKey?: string } | null = { + apiKey: 'test-key', + edisonSecretKey: 'test-secret' +} + +vi.mock('../infra/setupConfig', () => ({ + getApiBaseUrl: () => apiBaseUrl, + getCredentialsForEnv: () => creds +})) + +import { + reprovisionStdiodForActiveAccount, + teardownStdiodForSignOut +} from '../stdiod/accountSwitch' + +describe('teardownStdiodForSignOut', () => { + beforeEach(() => { + uninstallMock.mockClear() + resetMock.mockClear() + delete process.env.EDISON_DRY_RUN + }) + + it('unloads the daemon but keeps config.toml (purge:false)', async () => { + await teardownStdiodForSignOut() + expect(uninstallMock).toHaveBeenCalledTimes(1) + expect(uninstallMock).toHaveBeenCalledWith({ purge: false }) + expect(resetMock).not.toHaveBeenCalled() + }) + + it('swallows uninstall failures so sign-out never blocks', async () => { + uninstallMock.mockRejectedValueOnce(new Error('launchctl exploded')) + await expect(teardownStdiodForSignOut()).resolves.toBeUndefined() + }) +}) + +describe('reprovisionStdiodForActiveAccount', () => { + beforeEach(() => { + uninstallMock.mockClear() + resetMock.mockClear() + launchAgentLoaded = true + apiBaseUrl = 'https://api.testedison.example' + creds = { apiKey: 'test-key', edisonSecretKey: 'test-secret' } + delete process.env.EDISON_DRY_RUN + }) + afterEach(() => { + delete process.env.EDISON_DRY_RUN + }) + + it('does nothing when the daemon is not installed', async () => { + launchAgentLoaded = false + await reprovisionStdiodForActiveAccount() + expect(resetMock).not.toHaveBeenCalled() + expect(uninstallMock).not.toHaveBeenCalled() + }) + + it('resets the daemon onto the active account when installed', async () => { + await reprovisionStdiodForActiveAccount() + expect(resetMock).toHaveBeenCalledTimes(1) + expect(resetMock).toHaveBeenCalledWith({ + backend: 'https://api.testedison.example', + apiKey: 'test-key', + edisonSecretKey: 'test-secret' + }) + expect(uninstallMock).not.toHaveBeenCalled() + }) + + it('stops the daemon when installed but the active account has no credentials', async () => { + creds = null + await reprovisionStdiodForActiveAccount() + expect(resetMock).not.toHaveBeenCalled() + expect(uninstallMock).toHaveBeenCalledWith({ purge: false }) + }) + + it('never re-points onto stale credentials when the backend url is missing', async () => { + apiBaseUrl = null + await reprovisionStdiodForActiveAccount() + expect(resetMock).not.toHaveBeenCalled() + expect(uninstallMock).toHaveBeenCalledWith({ purge: false }) + }) + + it('is a no-op under EDISON_DRY_RUN (no launchctl probe in e2e)', async () => { + process.env.EDISON_DRY_RUN = '1' + await reprovisionStdiodForActiveAccount() + expect(resetMock).not.toHaveBeenCalled() + expect(uninstallMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/ipcHandlers.ts b/src/main/ipc/ipcHandlers.ts index cf99a73..b6de975 100644 --- a/src/main/ipc/ipcHandlers.ts +++ b/src/main/ipc/ipcHandlers.ts @@ -37,6 +37,10 @@ import { import { showFeedbackWindow } from '../dialogs/feedbackWindow' import { restoreAllQuarantinedServers } from '../runtime/mcpConfigActions' import { applyAppIntegrations } from '../runtime/mcpConfigWriter' +import { + reprovisionStdiodForActiveAccount, + teardownStdiodForSignOut +} from '../stdiod/accountSwitch' import { registerMcpSubmitHandlers } from './ipcHandlersMcpSubmit' import { registerStdiodHandlers } from './ipcHandlersStdiod' import { @@ -170,7 +174,10 @@ export function registerIpcHandlers(deps: IpcHandlerDeps): void { } }) - ipcMain.handle('setup:reset', () => { + ipcMain.handle('setup:reset', async () => { + // In-app sign-out. Stop the daemon like the tray sign-out does, else it + // keeps tunneling under the old account. + await teardownStdiodForSignOut() markSetupIncomplete() return { ok: true } }) @@ -309,6 +316,10 @@ export function registerIpcHandlers(deps: IpcHandlerDeps): void { } } + // Re-point the daemon at the new account (or stop it) so it doesn't keep + // tunneling under the old credentials. + await reprovisionStdiodForActiveAccount() + return { ok: true } }) diff --git a/src/main/stdiod/accountSwitch.ts b/src/main/stdiod/accountSwitch.ts new file mode 100644 index 0000000..2897eb9 --- /dev/null +++ b/src/main/stdiod/accountSwitch.ts @@ -0,0 +1,60 @@ +// Keep the daemon bound to the active account on sign-out and account switch. +// config.toml only changes on `login`, so otherwise the daemon stays on the +// previous account. + +import { getApiBaseUrl, getCredentialsForEnv } from '../infra/setupConfig' + +import { isLaunchAgentLoaded, resetStdiod, uninstall } from './controller' +import { stdiodLog } from './stdiodLog' + +// Stop the daemon on sign-out. purge=false keeps config.toml for a quick +// re-enable; unloading the unit is what stops the old tunnel. +export async function teardownStdiodForSignOut(): Promise { + try { + await uninstall({ purge: false }) + } catch (err) { + stdiodLog(`sign-out teardown failed: ${err instanceof Error ? err.message : String(err)}`) + } +} + +// On account switch: reset an installed daemon onto the new account (keeping +// its device identity), leave it off if it was off, stop it if there are no +// credentials. +export async function reprovisionStdiodForActiveAccount(): Promise { + // Don't probe launchctl in tests. + if (process.env.EDISON_DRY_RUN === '1') return + + let installed = false + try { + installed = await isLaunchAgentLoaded() + } catch { + installed = false + } + if (!installed) return + + const backend = getApiBaseUrl() + const creds = getCredentialsForEnv() + if (!backend || !creds?.apiKey) { + stdiodLog('account switch: no credentials for the active account; stopping the daemon') + await teardownStdiodForSignOut() + return + } + + stdiodLog('account switch: re-pointing the daemon at the active account') + try { + const result = await resetStdiod({ + backend, + apiKey: creds.apiKey, + edisonSecretKey: creds.edisonSecretKey + }) + if (!result.ok) { + stdiodLog( + `account switch: reprovision failed: ${result.errorMessage ?? result.errorCode ?? 'unknown'}` + ) + } + } catch (err) { + stdiodLog( + `account switch: reprovision threw: ${err instanceof Error ? err.message : String(err)}` + ) + } +}