From 3dd91e505be9016c30e040898abbab22d0207e9a Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 11 Sep 2026 18:08:43 -0700 Subject: [PATCH 1/5] feat(installer): reload the agent after updates instead of prompting for sudo --- electron/src/backend/CLI.ts | 17 +++++- electron/src/backend/binaryInstaller.test.ts | 64 ++++++++++++++++++++ electron/src/backend/binaryInstaller.ts | 26 ++++++++ electron/src/backend/cliStrings.ts | 4 ++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/electron/src/backend/CLI.ts b/electron/src/backend/CLI.ts index c4ab5f910..45af567b3 100644 --- a/electron/src/backend/CLI.ts +++ b/electron/src/backend/CLI.ts @@ -27,6 +27,7 @@ type IExec = { skipSignInCheck?: boolean admin?: boolean quiet?: boolean + report?: boolean skipInstalledCheck?: boolean onCommand?: (command: string) => void onError?: (error: Error) => void @@ -298,6 +299,19 @@ export default class CLI { return result?.version } + // An agent from before reload support answers with an error, which is the expected first-update + // path and not worth an Airbrake report; the sudo install that follows reports its own failures. + async agentReload() { + const result = await this.exec({ + cmds: [strings.agentReload()], + skipSignInCheck: true, + skipInstalledCheck: true, + quiet: true, + report: false, + }) + return !!result?.version + } + async exec({ cmds, checkAuthHash = false, @@ -305,6 +319,7 @@ export default class CLI { skipInstalledCheck, admin = false, quiet = false, + report = true, onCommand, onError, }: IExec) { @@ -321,7 +336,7 @@ export default class CLI { return '' } - let commands = new Command({ admin, quiet }) + let commands = new Command({ admin, quiet, report }) cmds.forEach(cmd => commands.push(`"${cliBinary.path}" ${cmd}`)) if (!skipInstalledCheck) { diff --git a/electron/src/backend/binaryInstaller.test.ts b/electron/src/backend/binaryInstaller.test.ts index 571e9e866..76e92c88c 100644 --- a/electron/src/backend/binaryInstaller.test.ts +++ b/electron/src/backend/binaryInstaller.test.ts @@ -126,11 +126,14 @@ describe('backend/binaryInstaller', () => { let agentVersionSpy: jest.SpyInstance let prefSpy: jest.SpyInstance let agentSpy: jest.SpyInstance + let reloadSpy: jest.SpyInstance + let updateSpy: jest.SpyInstance let binary: Binary let path: string beforeAll(() => { environment.isWindows = false + environment.isHeadless = false binary = new Binary({ name, version }) path = binary.path }) @@ -144,6 +147,8 @@ describe('backend/binaryInstaller', () => { agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(true)) versionSpy = jest.spyOn(cli, 'version').mockImplementation(() => Promise.resolve(version)) agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve(agentVersion)) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(false)) + updateSpy = jest.spyOn(preferences, 'update').mockImplementation() jest.spyOn(fs, 'existsSync').mockImplementation(() => true) environment.version = desktopVersion }) @@ -155,6 +160,65 @@ describe('backend/binaryInstaller', () => { versionSpy.mockClear() agentVersionSpy.mockClear() agentSpy.mockClear() + reloadSpy.mockClear() + updateSpy.mockClear() + }) + + test('reloads the running agent instead of prompting when only the agent version differs', async () => { + agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(true)) + + await binaryInstaller.check() + + expect(reloadSpy).toBeCalledTimes(1) + expect(installSpy).toBeCalledTimes(0) + expect(eventSpy).toBeCalledWith('binary/installed', { path, version, name, installedVersion: version }) + expect(eventSpy).not.toBeCalledWith('binary/not-installed', expect.anything()) + expect(updateSpy).toBeCalledWith({ version: desktopVersion, cliVersion: version }) + expect(binaryInstaller.inProgress).toBe(false) + }) + + test('falls back to the install prompt when the agent cannot reload', async () => { + agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) + + await binaryInstaller.check() + + expect(reloadSpy).toBeCalledTimes(1) + expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentMismatched: true })) + expect(eventSpy).not.toBeCalledWith('binary/installed', expect.anything()) + }) + + test('does not reload when the agent is stopped', async () => { + agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(false)) + + await binaryInstaller.check() + + expect(reloadSpy).toBeCalledTimes(0) + expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentStopped: true })) + }) + + test('does not reload when the cli binary itself is outdated', async () => { + versionSpy = jest.spyOn(cli, 'version').mockImplementation(() => Promise.resolve(outdated)) + + await binaryInstaller.check() + + expect(reloadSpy).toBeCalledTimes(0) + expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ binariesOutdated: true })) + }) + + test('does not reload on windows or headless', async () => { + agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) + + environment.isWindows = true + await binaryInstaller.check() + environment.isWindows = false + environment.isHeadless = true + await binaryInstaller.check() + environment.isHeadless = false + + expect(reloadSpy).toBeCalledTimes(0) + expect(eventSpy).toBeCalledTimes(2) + expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentMismatched: true })) }) test('should notify if installed', async () => { diff --git a/electron/src/backend/binaryInstaller.ts b/electron/src/backend/binaryInstaller.ts index aa3f932c2..44691c253 100644 --- a/electron/src/backend/binaryInstaller.ts +++ b/electron/src/backend/binaryInstaller.ts @@ -36,6 +36,7 @@ export class BinaryInstaller { if (shouldInstall) { if (environment.isElevated) return await this.install() + if (this.canReload(status) && (await this.reload())) return return EventBus.emit(Binary.EVENTS.notInstalled, status) } else if (!this.ready) { Logger.info('INSTALLER DONE') @@ -58,6 +59,31 @@ export class BinaryInstaller { return status } + // An app update leaves the service definition and symlinks valid, so when only the versions + // disagree the running agent can adopt the new binaries itself and no elevation prompt is needed. + canReload(status: BinaryReason) { + if (environment.isWindows || environment.isHeadless) return false + return !status.binariesOutdated && !status.agentStopped + } + + async reload(): Promise { + Logger.info('START AGENT RELOAD') + this.inProgress = true + + const reloaded = await cli.agentReload() + if (reloaded) { + EventBus.emit(Binary.EVENTS.installed, this.cliBinary.toJSON()) + EventBus.emit(ConnectionPool.EVENTS.clearErrors) + await this.updateVersions() + this.ready = true + } else { + Logger.warn('AGENT RELOAD FAILED', { fallback: 'install' }) + } + + this.inProgress = false + return reloaded + } + async install() { if (this.inProgress) return Logger.warn('INSTALL IN PROGRESS', { error: 'Can not install while in progress' }) Logger.info('START INSTALLATION') diff --git a/electron/src/backend/cliStrings.ts b/electron/src/backend/cliStrings.ts index 7123d27d4..5bcb13502 100644 --- a/electron/src/backend/cliStrings.ts +++ b/electron/src/backend/cliStrings.ts @@ -92,6 +92,10 @@ export default { return `-j agent restart` }, + agentReload() { + return '-j agent reload' + }, + toolsInstall() { return '-j agent tools-install --yes' }, From cd303a27f692d34135cd427abd05255aaa9bc19c Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 11 Sep 2026 18:33:28 -0700 Subject: [PATCH 2/5] refactor(installer): let the agent decide reload support, share install completion, latch refused reloads --- electron/src/backend/CLI.ts | 7 +--- electron/src/backend/Command.ts | 3 ++ electron/src/backend/binaryInstaller.test.ts | 43 ++++---------------- electron/src/backend/binaryInstaller.ts | 32 ++++++--------- electron/src/backend/cliStrings.ts | 4 -- 5 files changed, 25 insertions(+), 64 deletions(-) diff --git a/electron/src/backend/CLI.ts b/electron/src/backend/CLI.ts index 45af567b3..07485f917 100644 --- a/electron/src/backend/CLI.ts +++ b/electron/src/backend/CLI.ts @@ -27,7 +27,6 @@ type IExec = { skipSignInCheck?: boolean admin?: boolean quiet?: boolean - report?: boolean skipInstalledCheck?: boolean onCommand?: (command: string) => void onError?: (error: Error) => void @@ -299,15 +298,12 @@ export default class CLI { return result?.version } - // An agent from before reload support answers with an error, which is the expected first-update - // path and not worth an Airbrake report; the sudo install that follows reports its own failures. async agentReload() { const result = await this.exec({ cmds: [strings.agentReload()], skipSignInCheck: true, skipInstalledCheck: true, quiet: true, - report: false, }) return !!result?.version } @@ -319,7 +315,6 @@ export default class CLI { skipInstalledCheck, admin = false, quiet = false, - report = true, onCommand, onError, }: IExec) { @@ -336,7 +331,7 @@ export default class CLI { return '' } - let commands = new Command({ admin, quiet, report }) + let commands = new Command({ admin, quiet }) cmds.forEach(cmd => commands.push(`"${cliBinary.path}" ${cmd}`)) if (!skipInstalledCheck) { diff --git a/electron/src/backend/Command.ts b/electron/src/backend/Command.ts index d378facc3..d4bf8ab0e 100644 --- a/electron/src/backend/Command.ts +++ b/electron/src/backend/Command.ts @@ -22,6 +22,7 @@ const IGNORED_MESSAGES = [ 'read-only file system', 'user did not grant permission', // elevation prompt dismissed 'no polkit authentication agent found', // no way to prompt for elevation + 'unknown-message', // agent predates the command, e.g. reload sent to an older daemon ] // CLI exit codes that report an expected state rather than a defect. Each is @@ -30,6 +31,8 @@ const EXPECTED_CLI_CODES = [ '12', // config - you must be signed in '101', // agent not reachable '101001', // agent version mismatch + '409', // control - reload not supported on this platform + '410', // control - agent service predates reload support '7003', // cmd - you must run this command with elevated privileges ] diff --git a/electron/src/backend/binaryInstaller.test.ts b/electron/src/backend/binaryInstaller.test.ts index 76e92c88c..2745d35d2 100644 --- a/electron/src/backend/binaryInstaller.test.ts +++ b/electron/src/backend/binaryInstaller.test.ts @@ -151,6 +151,7 @@ describe('backend/binaryInstaller', () => { updateSpy = jest.spyOn(preferences, 'update').mockImplementation() jest.spyOn(fs, 'existsSync').mockImplementation(() => true) environment.version = desktopVersion + binaryInstaller.reloadRefusedBy = undefined }) afterEach(() => { @@ -178,47 +179,18 @@ describe('backend/binaryInstaller', () => { expect(binaryInstaller.inProgress).toBe(false) }) - test('falls back to the install prompt when the agent cannot reload', async () => { + test('does not retry a refused reload until the agent itself changes', async () => { agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) await binaryInstaller.check() - + await binaryInstaller.check() expect(reloadSpy).toBeCalledTimes(1) + expect(eventSpy).toBeCalledTimes(2) expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentMismatched: true })) - expect(eventSpy).not.toBeCalledWith('binary/installed', expect.anything()) - }) - - test('does not reload when the agent is stopped', async () => { - agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(false)) - - await binaryInstaller.check() - - expect(reloadSpy).toBeCalledTimes(0) - expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentStopped: true })) - }) - - test('does not reload when the cli binary itself is outdated', async () => { - versionSpy = jest.spyOn(cli, 'version').mockImplementation(() => Promise.resolve(outdated)) - - await binaryInstaller.check() - - expect(reloadSpy).toBeCalledTimes(0) - expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ binariesOutdated: true })) - }) - - test('does not reload on windows or headless', async () => { - agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) - environment.isWindows = true - await binaryInstaller.check() - environment.isWindows = false - environment.isHeadless = true + agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('3.0.0')) await binaryInstaller.check() - environment.isHeadless = false - - expect(reloadSpy).toBeCalledTimes(0) - expect(eventSpy).toBeCalledTimes(2) - expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentMismatched: true })) + expect(reloadSpy).toBeCalledTimes(2) }) test('should notify if installed', async () => { @@ -250,6 +222,7 @@ describe('backend/binaryInstaller', () => { desktopUpdated: false, }) expect(versionSpy).toBeCalledTimes(1) + expect(reloadSpy).toBeCalledTimes(1) }) test('should notify if agent version is wrong', async () => { @@ -270,6 +243,7 @@ describe('backend/binaryInstaller', () => { desktopUpdated: false, }) expect(agentVersionSpy).toBeCalledTimes(1) + expect(reloadSpy).toBeCalledTimes(1) }) test('should notify with different installed cli version', async () => { @@ -304,6 +278,7 @@ describe('backend/binaryInstaller', () => { desktopUpdated: false, }) expect(versionSpy).toBeCalledTimes(1) + expect(reloadSpy).toBeCalledTimes(0) }) test('should notify if desktop version updated', async () => { diff --git a/electron/src/backend/binaryInstaller.ts b/electron/src/backend/binaryInstaller.ts index 44691c253..c34df8500 100644 --- a/electron/src/backend/binaryInstaller.ts +++ b/electron/src/backend/binaryInstaller.ts @@ -15,6 +15,7 @@ export class BinaryInstaller { ready = false inProgress = false uninstallInitiated = false + reloadRefusedBy?: string binaries: Binary[] cliBinary: Binary @@ -59,11 +60,10 @@ export class BinaryInstaller { return status } - // An app update leaves the service definition and symlinks valid, so when only the versions - // disagree the running agent can adopt the new binaries itself and no elevation prompt is needed. + // The running agent decides whether it can adopt the installed binaries itself; the desktop only + // knows when the bundled cli is unusable. A refusal stands until the agent itself changes. canReload(status: BinaryReason) { - if (environment.isWindows || environment.isHeadless) return false - return !status.binariesOutdated && !status.agentStopped + return !status.binariesOutdated && this.reloadRefusedBy !== this.cliBinary.agentVersion } async reload(): Promise { @@ -72,12 +72,10 @@ export class BinaryInstaller { const reloaded = await cli.agentReload() if (reloaded) { - EventBus.emit(Binary.EVENTS.installed, this.cliBinary.toJSON()) - EventBus.emit(ConnectionPool.EVENTS.clearErrors) - await this.updateVersions() - this.ready = true + await this.completeInstall() } else { - Logger.warn('AGENT RELOAD FAILED', { fallback: 'install' }) + this.reloadRefusedBy = this.cliBinary.agentVersion + Logger.warn('AGENT RELOAD REFUSED', { agentVersion: this.cliBinary.agentVersion, fallback: 'install' }) } this.inProgress = false @@ -90,12 +88,15 @@ export class BinaryInstaller { this.inProgress = true await this.installBinaries().catch(error => EventBus.emit(Binary.EVENTS.error, error)) + await this.completeInstall() + + this.inProgress = false + } + private async completeInstall() { EventBus.emit(Binary.EVENTS.installed, this.cliBinary.toJSON()) EventBus.emit(ConnectionPool.EVENTS.clearErrors) await this.updateVersions() - - this.inProgress = false this.ready = true } @@ -148,15 +149,6 @@ export class BinaryInstaller { }) } - async restart() { - const commands = new Command({ onError: e => EventBus.emit(Binary.EVENTS.error, e.toString()), admin: true }) - commands.push(`${this.envVar()} "${this.cliBinary.path}" ${strings.serviceRestart()}`) - - this.inProgress = true - await commands.exec() - this.inProgress = false - } - async uninstall() { if (this.inProgress) return Logger.warn('UNINSTALL IN PROGRESS', { error: 'Can not uninstall while in progress' }) Logger.info('START UNINSTALL') diff --git a/electron/src/backend/cliStrings.ts b/electron/src/backend/cliStrings.ts index 5bcb13502..28168659d 100644 --- a/electron/src/backend/cliStrings.ts +++ b/electron/src/backend/cliStrings.ts @@ -88,10 +88,6 @@ export default { return `-j agent uninstall` }, - serviceRestart() { - return `-j agent restart` - }, - agentReload() { return '-j agent reload' }, From 964674b4c3f5920c3f69b50d51454e70c674e5fa Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Fri, 11 Sep 2026 19:04:08 -0700 Subject: [PATCH 3/5] fix(installer): require the reloaded agent to match the bundled version, skip stopped agents, guard reload re-entry --- electron/src/backend/CLI.ts | 4 +-- electron/src/backend/binaryInstaller.test.ts | 36 ++++++++++++++++++-- electron/src/backend/binaryInstaller.ts | 17 +++++---- 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/electron/src/backend/CLI.ts b/electron/src/backend/CLI.ts index 07485f917..a2ad490be 100644 --- a/electron/src/backend/CLI.ts +++ b/electron/src/backend/CLI.ts @@ -298,14 +298,14 @@ export default class CLI { return result?.version } - async agentReload() { + async agentReload(): Promise { const result = await this.exec({ cmds: [strings.agentReload()], skipSignInCheck: true, skipInstalledCheck: true, quiet: true, }) - return !!result?.version + return result?.version } async exec({ diff --git a/electron/src/backend/binaryInstaller.test.ts b/electron/src/backend/binaryInstaller.test.ts index 2745d35d2..09d629c77 100644 --- a/electron/src/backend/binaryInstaller.test.ts +++ b/electron/src/backend/binaryInstaller.test.ts @@ -147,7 +147,7 @@ describe('backend/binaryInstaller', () => { agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(true)) versionSpy = jest.spyOn(cli, 'version').mockImplementation(() => Promise.resolve(version)) agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve(agentVersion)) - reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(false)) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(undefined)) updateSpy = jest.spyOn(preferences, 'update').mockImplementation() jest.spyOn(fs, 'existsSync').mockImplementation(() => true) environment.version = desktopVersion @@ -167,7 +167,7 @@ describe('backend/binaryInstaller', () => { test('reloads the running agent instead of prompting when only the agent version differs', async () => { agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) - reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(true)) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(version)) await binaryInstaller.check() @@ -179,6 +179,18 @@ describe('backend/binaryInstaller', () => { expect(binaryInstaller.inProgress).toBe(false) }) + test('treats a reload that leaves another version running as refused', async () => { + agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve('2.0.0')) + + await binaryInstaller.check() + + expect(reloadSpy).toBeCalledTimes(1) + expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentMismatched: true })) + expect(eventSpy).not.toBeCalledWith('binary/installed', expect.anything()) + expect(binaryInstaller.reloadRefusedBy).toBe('2.0.0') + }) + test('does not retry a refused reload until the agent itself changes', async () => { agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) @@ -188,11 +200,29 @@ describe('backend/binaryInstaller', () => { expect(eventSpy).toBeCalledTimes(2) expect(eventSpy).toBeCalledWith('binary/not-installed', expect.objectContaining({ agentMismatched: true })) + agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(false)) + await binaryInstaller.check() + agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(true)) + await binaryInstaller.check() + expect(reloadSpy).toBeCalledTimes(1) + expect(binaryInstaller.reloadRefusedBy).toBe('2.0.0') + agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('3.0.0')) await binaryInstaller.check() expect(reloadSpy).toBeCalledTimes(2) }) + test('clears a refusal once the agent has been brought current', async () => { + binaryInstaller.reloadRefusedBy = '1.0.0' + agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(version)) + + await binaryInstaller.check() + + expect(binaryInstaller.reloadRefusedBy).toBeUndefined() + expect(eventSpy).toBeCalledWith('binary/installed', expect.anything()) + }) + test('should notify if installed', async () => { await binaryInstaller.check() @@ -222,7 +252,7 @@ describe('backend/binaryInstaller', () => { desktopUpdated: false, }) expect(versionSpy).toBeCalledTimes(1) - expect(reloadSpy).toBeCalledTimes(1) + expect(reloadSpy).toBeCalledTimes(0) }) test('should notify if agent version is wrong', async () => { diff --git a/electron/src/backend/binaryInstaller.ts b/electron/src/backend/binaryInstaller.ts index c34df8500..acd2bbeed 100644 --- a/electron/src/backend/binaryInstaller.ts +++ b/electron/src/backend/binaryInstaller.ts @@ -60,22 +60,26 @@ export class BinaryInstaller { return status } - // The running agent decides whether it can adopt the installed binaries itself; the desktop only - // knows when the bundled cli is unusable. A refusal stands until the agent itself changes. + // The running agent decides whether it can adopt the installed binaries itself. canReload(status: BinaryReason) { - return !status.binariesOutdated && this.reloadRefusedBy !== this.cliBinary.agentVersion + return !status.binariesOutdated && !status.agentStopped && this.reloadRefusedBy !== this.cliBinary.agentVersion } async reload(): Promise { + if (this.inProgress) { + Logger.info('AGENT RELOAD ALREADY IN PROGRESS') + return true + } Logger.info('START AGENT RELOAD') this.inProgress = true - const reloaded = await cli.agentReload() + const version = await cli.agentReload() + const reloaded = !!version && version === this.cliBinary.version if (reloaded) { await this.completeInstall() } else { this.reloadRefusedBy = this.cliBinary.agentVersion - Logger.warn('AGENT RELOAD REFUSED', { agentVersion: this.cliBinary.agentVersion, fallback: 'install' }) + Logger.warn('AGENT RELOAD REFUSED', { version, agentVersion: this.cliBinary.agentVersion, fallback: 'install' }) } this.inProgress = false @@ -94,6 +98,7 @@ export class BinaryInstaller { } private async completeInstall() { + this.reloadRefusedBy = undefined EventBus.emit(Binary.EVENTS.installed, this.cliBinary.toJSON()) EventBus.emit(ConnectionPool.EVENTS.clearErrors) await this.updateVersions() @@ -220,7 +225,7 @@ export class BinaryInstaller { } async updateVersions() { - const cliVersion = await cli.version() + const cliVersion = this.cliBinary.installedVersion || (await cli.version()) Logger.info('CLI VERSION UPDATE', { cliVersion }) preferences.update({ version: environment.version, cliVersion }) } From e3f8f01194e50c16e252178648c9c2a1cab62563 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 14 Sep 2026 13:47:02 -0700 Subject: [PATCH 4/5] fix(installer): latch only reload refusals the agent will repeat --- electron/src/backend/CLI.ts | 30 +++++++++------ electron/src/backend/binaryInstaller.test.ts | 40 ++++++++++++++++++-- electron/src/backend/binaryInstaller.ts | 23 +++++++++-- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/electron/src/backend/CLI.ts b/electron/src/backend/CLI.ts index a2ad490be..ce5f722b5 100644 --- a/electron/src/backend/CLI.ts +++ b/electron/src/backend/CLI.ts @@ -298,14 +298,18 @@ export default class CLI { return result?.version } - async agentReload(): Promise { + async agentReload(): Promise<{ version?: string; error?: Error }> { + let error: Error | undefined const result = await this.exec({ cmds: [strings.agentReload()], skipSignInCheck: true, skipInstalledCheck: true, quiet: true, + onError: e => { + error = e + }, }) - return result?.version + return { version: result?.version, error } } async exec({ @@ -334,17 +338,19 @@ export default class CLI { let commands = new Command({ admin, quiet }) cmds.forEach(cmd => commands.push(`"${cliBinary.path}" ${cmd}`)) - if (!skipInstalledCheck) { - commands.onError = async (e: Error) => { - if (!quiet) { - if (typeof onError === 'function') onError(e) - EventBus.emit(this.EVENTS.error, e.message) - } - // can't decrypt authHash - if (e.name === '11') await this.signOut() - - binaryInstaller.check() + commands.onError = async (e: Error) => { + if (skipInstalledCheck) { + if (typeof onError === 'function') onError(e) + return } + if (!quiet) { + if (typeof onError === 'function') onError(e) + EventBus.emit(this.EVENTS.error, e.message) + } + // can't decrypt authHash + if (e.name === '11') await this.signOut() + + binaryInstaller.check() } const result = await commands.exec() diff --git a/electron/src/backend/binaryInstaller.test.ts b/electron/src/backend/binaryInstaller.test.ts index 09d629c77..d26543310 100644 --- a/electron/src/backend/binaryInstaller.test.ts +++ b/electron/src/backend/binaryInstaller.test.ts @@ -14,6 +14,12 @@ describe('Test framework is working', () => { expect(true).toBeTruthy() }) +const refusal = (code: string, message = 'refused') => { + const error = new Error(message) + error.name = code + return error +} + describe('backend/binaryInstaller', () => { const version = '0.37.6' const agentVersion = '0.37.6' @@ -147,7 +153,7 @@ describe('backend/binaryInstaller', () => { agentSpy = jest.spyOn(cli, 'agentRunning').mockImplementation(() => Promise.resolve(true)) versionSpy = jest.spyOn(cli, 'version').mockImplementation(() => Promise.resolve(version)) agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve(agentVersion)) - reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(undefined)) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve({ error: refusal('410') })) updateSpy = jest.spyOn(preferences, 'update').mockImplementation() jest.spyOn(fs, 'existsSync').mockImplementation(() => true) environment.version = desktopVersion @@ -167,7 +173,7 @@ describe('backend/binaryInstaller', () => { test('reloads the running agent instead of prompting when only the agent version differs', async () => { agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) - reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(version)) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve({ version })) await binaryInstaller.check() @@ -179,9 +185,35 @@ describe('backend/binaryInstaller', () => { expect(binaryInstaller.inProgress).toBe(false) }) + test('does not latch a reload that failed because the agent was unreachable', async () => { + agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) + reloadSpy = jest + .spyOn(cli, 'agentReload') + .mockImplementation(() => Promise.resolve({ error: refusal('101', 'agent not reachable') })) + + await binaryInstaller.check() + expect(binaryInstaller.reloadRefusedBy).toBeUndefined() + + await binaryInstaller.check() + expect(reloadSpy).toBeCalledTimes(2) + }) + + test('latches a refusal the agent will repeat', async () => { + agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) + reloadSpy = jest + .spyOn(cli, 'agentReload') + .mockImplementation(() => Promise.resolve({ error: refusal('3', 'UNKNOWN-MESSAGE: {}') })) + + await binaryInstaller.check() + await binaryInstaller.check() + + expect(reloadSpy).toBeCalledTimes(1) + expect(binaryInstaller.reloadRefusedBy).toBe('2.0.0') + }) + test('treats a reload that leaves another version running as refused', async () => { agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) - reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve('2.0.0')) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve({ version: '2.0.0' })) await binaryInstaller.check() @@ -215,7 +247,7 @@ describe('backend/binaryInstaller', () => { test('clears a refusal once the agent has been brought current', async () => { binaryInstaller.reloadRefusedBy = '1.0.0' agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve('2.0.0')) - reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve(version)) + reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve({ version })) await binaryInstaller.check() diff --git a/electron/src/backend/binaryInstaller.ts b/electron/src/backend/binaryInstaller.ts index acd2bbeed..532f449f6 100644 --- a/electron/src/backend/binaryInstaller.ts +++ b/electron/src/backend/binaryInstaller.ts @@ -11,6 +11,15 @@ import Binary, { binaries, cliBinary } from './Binary' import { existsSync, lstatSync } from 'fs' import Logger from './Logger' +// Refusals the running agent will give again: unsupported platform, a service definition that +// predates staging, a signature it will not accept, and a daemon too old to know the command. +const PERMANENT_REFUSAL_CODES = ['409', '410', '411'] + +function permanentRefusal(error?: Error) { + if (!error) return false + return PERMANENT_REFUSAL_CODES.includes(error.name) || error.message.toLowerCase().includes('unknown-message') +} + export class BinaryInstaller { ready = false inProgress = false @@ -73,13 +82,21 @@ export class BinaryInstaller { Logger.info('START AGENT RELOAD') this.inProgress = true - const version = await cli.agentReload() + const { version, error } = await cli.agentReload() const reloaded = !!version && version === this.cliBinary.version if (reloaded) { await this.completeInstall() } else { - this.reloadRefusedBy = this.cliBinary.agentVersion - Logger.warn('AGENT RELOAD REFUSED', { version, agentVersion: this.cliBinary.agentVersion, fallback: 'install' }) + // A daemon that answered, or refused for a reason it will repeat, is worth latching. An + // unreachable agent or a failed spawn is not, or the unprivileged retry never happens again. + if (!!version || permanentRefusal(error)) this.reloadRefusedBy = this.cliBinary.agentVersion + Logger.warn('AGENT RELOAD REFUSED', { + version, + code: error?.name, + error: error?.message, + latched: this.reloadRefusedBy, + agentVersion: this.cliBinary.agentVersion, + }) } this.inProgress = false From d407da8fa06f813bd942293539d6350422f2757c Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 14 Sep 2026 13:54:08 -0700 Subject: [PATCH 5/5] test(installer): follow the preferences.update rename from main --- electron/src/backend/binaryInstaller.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/src/backend/binaryInstaller.test.ts b/electron/src/backend/binaryInstaller.test.ts index d26543310..e0d20268d 100644 --- a/electron/src/backend/binaryInstaller.test.ts +++ b/electron/src/backend/binaryInstaller.test.ts @@ -154,7 +154,7 @@ describe('backend/binaryInstaller', () => { versionSpy = jest.spyOn(cli, 'version').mockImplementation(() => Promise.resolve(version)) agentVersionSpy = jest.spyOn(cli, 'agentVersion').mockImplementation(() => Promise.resolve(agentVersion)) reloadSpy = jest.spyOn(cli, 'agentReload').mockImplementation(() => Promise.resolve({ error: refusal('410') })) - updateSpy = jest.spyOn(preferences, 'update').mockImplementation() + updateSpy = jest.spyOn(preferences, 'set').mockImplementation() jest.spyOn(fs, 'existsSync').mockImplementation(() => true) environment.version = desktopVersion binaryInstaller.reloadRefusedBy = undefined