From bc8272f3f2a379b7829be3009e476dd8fa2292ee Mon Sep 17 00:00:00 2001 From: Tyrus Malmstrom Date: Fri, 17 Jul 2026 20:18:03 -0600 Subject: [PATCH 1/2] fix: show desktop daemon origin info Render daemon registry info with launcher provenance, treat Electron utility workers as running when their parent is alive, and pass the worker marker through execArgv so it shows up in process listings. Add coverage for PID ownership, status output, and desktop daemon startup. --- .changeset/clever-pillows-cough.md | 5 ++ packages/cli/src/pid.test.ts | 55 +++++++++++++++++++ packages/cli/src/pid.ts | 27 ++++++++- packages/cli/src/status.test.ts | 26 ++++++++- packages/cli/src/status.ts | 8 +++ .../src/main/desktop-daemon-manager.test.ts | 3 + .../src/main/desktop-daemon-manager.ts | 24 +++++++- 7 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 .changeset/clever-pillows-cough.md diff --git a/.changeset/clever-pillows-cough.md b/.changeset/clever-pillows-cough.md new file mode 100644 index 0000000..a4d90b2 --- /dev/null +++ b/.changeset/clever-pillows-cough.md @@ -0,0 +1,5 @@ +--- +'agendex-cli': patch +--- + +Updates to how we render daemon registry info diff --git a/packages/cli/src/pid.test.ts b/packages/cli/src/pid.test.ts index efde74a..03838a1 100644 --- a/packages/cli/src/pid.test.ts +++ b/packages/cli/src/pid.test.ts @@ -131,6 +131,61 @@ test('daemon PID ownership accepts only CLI or marked desktop daemon commands', ).toBe(false); }); +test('desktop launcher ownership accepts Electron utility processes without visible Node args', () => { + const info = { + pid: process.pid, + hostname: 'current-host', + bootId: 'boot-current', + launcher: 'desktop' as const, + parentPid: process.pid, + }; + const runtime = { + currentHostname: 'current-host', + currentBootId: 'boot-current', + processRunning: true, + parentProcessRunning: true, + }; + + // Real Electron utilityProcess listings often omit fork() Node args from `ps`/WMI. + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + processCommand: + 'Agendex Helper (Plugin) --type=utility --utility-sub-type=node.mojom.NodeService', + }), + ).toBe(true); + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + processCommand: 'RenamedDesktop.exe --type=utility --utility-sub-type=network', + }), + ).toBe(true); + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + parentProcessRunning: false, + processCommand: + 'Agendex Helper (Plugin) --type=utility --utility-sub-type=node.mojom.NodeService', + }), + ).toBe(false); + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + processCommand: 'unrelated-process --serve', + }), + ).toBe(false); + expect( + isDaemonPidInfoRunning( + { ...info, launcher: 'cli' }, + { + ...runtime, + processCommand: + 'Agendex Helper (Plugin) --type=utility --utility-sub-type=node.mojom.NodeService', + }, + ), + ).toBe(false); +}); + test('legacy PID files retain metadata and require daemon process ownership', () => { useTempConfigDir(); const configDir = process.env.AGENDEX_CONFIG_DIR as string; diff --git a/packages/cli/src/pid.ts b/packages/cli/src/pid.ts index 34a24ae..bd375aa 100644 --- a/packages/cli/src/pid.ts +++ b/packages/cli/src/pid.ts @@ -145,6 +145,8 @@ export interface DaemonPidFreshnessOptions { currentBootId?: string | null; processCommand?: string | null; processRunning?: boolean; + /** Override for desktop launcher parent liveness checks (tests). */ + parentProcessRunning?: boolean; } /** Checks record provenance only; validate each live PID separately before signaling it. */ @@ -170,7 +172,13 @@ export function isDaemonPidInfoRunning( if (!running) return false; const command = options.processCommand !== undefined ? options.processCommand : readProcessCommand(info.pid); - return isAgendexDaemonCommand(command); + if (isAgendexDaemonCommand(command)) return true; + + // Electron utilityProcess.fork Node args are process.argv inside the worker, but often + // do not appear in OS process listings (`ps` / WMI). Trust desktop pid-file provenance + // while the recorded Electron parent is still alive and the live process still looks + // like an Electron utility worker — avoids false "not running" in `agendex status`. + return isDesktopDaemonOwnership(info, command, options); } export function isAgendexDaemonProcess(pid: number): boolean { @@ -277,6 +285,23 @@ function isAgendexDaemonCommand(command: string | null): boolean { return normalized.includes('--daemon') || normalized.includes('--worker'); } +function isElectronUtilityCommand(command: string | null): boolean { + if (!command) return false; + return command.toLowerCase().includes('--type=utility'); +} + +function isDesktopDaemonOwnership( + info: DaemonPidInfo, + command: string | null, + options: DaemonPidFreshnessOptions, +): boolean { + if (info.launcher !== 'desktop') return false; + if (!Number.isInteger(info.parentPid) || (info.parentPid as number) <= 0) return false; + const parentRunning = options.parentProcessRunning ?? isRunning(info.parentPid as number); + if (!parentRunning) return false; + return isElectronUtilityCommand(command); +} + export function requestDaemonStop(pid: number, options: DaemonPathOptions = {}): void { const path = getStopRequestPath(pid, options); mkdirSync(dirname(path), { recursive: true }); diff --git a/packages/cli/src/status.test.ts b/packages/cli/src/status.test.ts index 6fb0236..4a6b36e 100644 --- a/packages/cli/src/status.test.ts +++ b/packages/cli/src/status.test.ts @@ -49,7 +49,7 @@ test('renders grouped status with daemon, cloud, and source summaries', () => { const output = renderStatus({ config: config(), configPath: '/tmp/agendex/config.json', - pidInfo: { pid: 123, startedAtMs: NOW - 90_000, hostname: 'workstation' }, + pidInfo: { pid: 123, startedAtMs: NOW - 90_000, hostname: 'workstation', launcher: 'cli' }, running: true, cliVersion: '2.0.0', devices, @@ -62,7 +62,7 @@ test('renders grouped status with daemon, cloud, and source summaries', () => { expect(output).toContain('Cloud:'); expect(output).toContain('Plan sources:'); expect(output).toContain('✓ running'); - expect(output).toContain('PID 123 • up 1m 30s • host workstation'); + expect(output).toContain('PID 123 • up 1m 30s • host workstation • via CLI'); expect(output).toContain('✓ 2 devices'); expect(output).toContain('1 alive • 1 stale'); expect(output).toContain('workstation (this machine)'); @@ -74,6 +74,28 @@ test('renders grouped status with daemon, cloud, and source summaries', () => { expect(output).toContain('agendex cleanup --stale'); }); +test('renders desktop spawn origin for Electron-launched daemons', () => { + const output = renderStatus({ + config: config(), + configPath: '/tmp/agendex/config.json', + pidInfo: { + pid: 456, + startedAtMs: NOW - 30_000, + hostname: 'workstation', + launcher: 'desktop', + parentPid: 100, + }, + running: true, + cliVersion: '2.0.0', + devices: [], + now: NOW, + color: false, + }); + + expect(output).toContain('✓ running'); + expect(output).toContain('PID 456 • up 30s • host workstation • via desktop app'); +}); + test('renders actionable setup guidance when config is missing', () => { const output = renderStatus({ config: null, diff --git a/packages/cli/src/status.ts b/packages/cli/src/status.ts index b1ab9d0..4a16032 100644 --- a/packages/cli/src/status.ts +++ b/packages/cli/src/status.ts @@ -121,6 +121,12 @@ export function formatDuration(ms: number): string { return `${days}d ${remainingHours}h`; } +function formatLauncherOrigin(launcher: DaemonPidInfo['launcher']): string | null { + if (launcher === 'desktop') return 'via desktop app'; + if (launcher === 'cli') return 'via CLI'; + return null; +} + function localDaemonDetail(options: RenderStatusOptions, now: number): string { if (!options.running) return 'run `agendex start` to begin background sync'; @@ -133,6 +139,8 @@ function localDaemonDetail(options: RenderStatusOptions, now: number): string { } if (options.pidInfo?.hostname) parts.push(`host ${options.pidInfo.hostname}`); else parts.push('host unknown'); + const origin = formatLauncherOrigin(options.pidInfo?.launcher); + if (origin) parts.push(origin); return parts.join(' • '); } diff --git a/packages/desktop/src/main/desktop-daemon-manager.test.ts b/packages/desktop/src/main/desktop-daemon-manager.test.ts index 2aef054..7a8eee1 100644 --- a/packages/desktop/src/main/desktop-daemon-manager.test.ts +++ b/packages/desktop/src/main/desktop-daemon-manager.test.ts @@ -122,6 +122,9 @@ test('starts one utility worker without putting credentials in its environment', expect(forkCalls[0]?.path).toContain('worker path with spaces'); expect(forkCalls[0]?.options.env?.AGENDEX_DEV).toBe('1'); expect(forkCalls[0]?.options.env?.AGENDEX_CLOUD_TOKEN).toBeUndefined(); + expect((forkCalls[0]?.options as { execArgv?: string[] }).execArgv).toEqual([ + '--agendex-daemon-worker', + ]); expect( child.messages.some( (message) => diff --git a/packages/desktop/src/main/desktop-daemon-manager.ts b/packages/desktop/src/main/desktop-daemon-manager.ts index 01c5c07..abcb8bc 100644 --- a/packages/desktop/src/main/desktop-daemon-manager.ts +++ b/packages/desktop/src/main/desktop-daemon-manager.ts @@ -4,6 +4,7 @@ import { acquireDaemonStartLock, isAgendexDaemonProcess, isDaemonPidInfoCurrent, + isDaemonPidInfoRunning, isRunning, readPidInfo, removePid, @@ -164,7 +165,8 @@ export class DesktopDaemonManager { const orphanPid = observedIsCurrent && observed?.launcher === 'desktop' && - this.processIsDaemon(observed.pid) && + this.processIsRunning(observed.pid) && + this.processLooksLikeDaemon(observed) && observed.parentPid !== undefined && !this.processIsRunning(observed.parentPid) ? observed.pid @@ -194,7 +196,7 @@ export class DesktopDaemonManager { try { const current = readPidInfo(pathOptions); - if (current && this.pidInfoIsCurrent(current) && this.processIsDaemon(current.pid)) { + if (current && this.pidInfoMatchesRunningDaemon(current)) { return 'already-running'; } if (current) removePid(current.pid, pathOptions); @@ -207,8 +209,11 @@ export class DesktopDaemonManager { if (this.options.isDev) env.AGENDEX_DEV = '1'; else delete env.AGENDEX_DEV; + // Pass the marker via both Node args (process.argv) and execArgv (OS command line) + // so CLI ownership checks can see `--agendex-daemon-worker` in `ps` / WMI listings. const child = this.options.forkWorker(workerEntry, ['--agendex-daemon-worker'], { env, + execArgv: ['--agendex-daemon-worker'], serviceName: 'Agendex Sync', stdio: ['ignore', 'pipe', 'pipe'], }); @@ -314,6 +319,21 @@ export class DesktopDaemonManager { return (this.options.isPidInfoCurrent ?? isDaemonPidInfoCurrent)(info); } + private pidInfoMatchesRunningDaemon(info: DaemonPidInfo): boolean { + if (this.options.isDaemonProcess) { + return this.pidInfoIsCurrent(info) && this.processIsDaemon(info.pid); + } + return isDaemonPidInfoRunning(info); + } + + /** Orphan workers may still look like Electron utilities after the parent exits. */ + private processLooksLikeDaemon(info: DaemonPidInfo): boolean { + if (this.options.isDaemonProcess) return this.processIsDaemon(info.pid); + if (this.processIsDaemon(info.pid)) return true; + // Force parent-alive so desktop utility ownership can match after Electron exits. + return isDaemonPidInfoRunning(info, { parentProcessRunning: true }); + } + private postTo(child: UtilityProcess, message: DesktopDaemonParentMessage): boolean { try { child.postMessage(message); From f56ebb4e29cec0060bbc7af35a8c39b252d92fff Mon Sep 17 00:00:00 2001 From: Tyrus Malmstrom Date: Fri, 17 Jul 2026 20:35:31 -0600 Subject: [PATCH 2/2] fix: harden desktop daemon ownership checks Drop invalid execArgv that would crash Node workers, narrow utility matching to NodeService, and honor injected PID liveness hooks. Co-authored-by: Cursor --- packages/cli/src/pid.test.ts | 8 +++++++- packages/cli/src/pid.ts | 3 ++- .../src/main/desktop-daemon-manager.test.ts | 19 +++++++++++++------ .../src/main/desktop-daemon-manager.ts | 15 ++++++++++----- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/pid.test.ts b/packages/cli/src/pid.test.ts index 03838a1..2b5ae69 100644 --- a/packages/cli/src/pid.test.ts +++ b/packages/cli/src/pid.test.ts @@ -159,7 +159,13 @@ test('desktop launcher ownership accepts Electron utility processes without visi ...runtime, processCommand: 'RenamedDesktop.exe --type=utility --utility-sub-type=network', }), - ).toBe(true); + ).toBe(false); + expect( + isDaemonPidInfoRunning(info, { + ...runtime, + processCommand: 'Agendex.exe --type=utility', + }), + ).toBe(false); expect( isDaemonPidInfoRunning(info, { ...runtime, diff --git a/packages/cli/src/pid.ts b/packages/cli/src/pid.ts index bd375aa..9138f54 100644 --- a/packages/cli/src/pid.ts +++ b/packages/cli/src/pid.ts @@ -287,7 +287,8 @@ function isAgendexDaemonCommand(command: string | null): boolean { function isElectronUtilityCommand(command: string | null): boolean { if (!command) return false; - return command.toLowerCase().includes('--type=utility'); + // Node utilityProcess.fork workers only — not Chromium network/audio/GPU helpers. + return command.toLowerCase().includes('--utility-sub-type=node.mojom.nodeservice'); } function isDesktopDaemonOwnership( diff --git a/packages/desktop/src/main/desktop-daemon-manager.test.ts b/packages/desktop/src/main/desktop-daemon-manager.test.ts index 7a8eee1..4027601 100644 --- a/packages/desktop/src/main/desktop-daemon-manager.test.ts +++ b/packages/desktop/src/main/desktop-daemon-manager.test.ts @@ -96,12 +96,20 @@ afterEach(() => { test('starts one utility worker without putting credentials in its environment', async () => { useTempConfigDir('agendex daemon path with spaces '); const child = new FakeUtilityProcess(); - const forkCalls: Array<{ path: string; options: { env?: NodeJS.ProcessEnv } }> = []; + const forkCalls: Array<{ + path: string; + args: string[]; + options: { env?: NodeJS.ProcessEnv; execArgv?: string[] }; + }> = []; const manager = new DesktopDaemonManager({ isDev: true, workerEntry: join(tempRoot, 'worker path with spaces', 'daemon-worker.js'), - forkWorker: ((path: string, _args: string[], options: { env?: NodeJS.ProcessEnv }) => { - forkCalls.push({ path, options }); + forkWorker: (( + path: string, + args: string[], + options: { env?: NodeJS.ProcessEnv; execArgv?: string[] }, + ) => { + forkCalls.push({ path, args, options }); queueMicrotask(() => child.emit('spawn')); return child; }) as never, @@ -120,11 +128,10 @@ test('starts one utility worker without putting credentials in its environment', expect(second).toBe('started'); expect(forkCalls).toHaveLength(1); expect(forkCalls[0]?.path).toContain('worker path with spaces'); + expect(forkCalls[0]?.args).toEqual(['--agendex-daemon-worker']); expect(forkCalls[0]?.options.env?.AGENDEX_DEV).toBe('1'); expect(forkCalls[0]?.options.env?.AGENDEX_CLOUD_TOKEN).toBeUndefined(); - expect((forkCalls[0]?.options as { execArgv?: string[] }).execArgv).toEqual([ - '--agendex-daemon-worker', - ]); + expect(forkCalls[0]?.options.execArgv).toBeUndefined(); expect( child.messages.some( (message) => diff --git a/packages/desktop/src/main/desktop-daemon-manager.ts b/packages/desktop/src/main/desktop-daemon-manager.ts index abcb8bc..94374da 100644 --- a/packages/desktop/src/main/desktop-daemon-manager.ts +++ b/packages/desktop/src/main/desktop-daemon-manager.ts @@ -209,11 +209,10 @@ export class DesktopDaemonManager { if (this.options.isDev) env.AGENDEX_DEV = '1'; else delete env.AGENDEX_DEV; - // Pass the marker via both Node args (process.argv) and execArgv (OS command line) - // so CLI ownership checks can see `--agendex-daemon-worker` in `ps` / WMI listings. + // Script args land on the worker's process.argv. Do not put the marker in execArgv — + // Node rejects unrecognized -- flags at startup ("bad option"). const child = this.options.forkWorker(workerEntry, ['--agendex-daemon-worker'], { env, - execArgv: ['--agendex-daemon-worker'], serviceName: 'Agendex Sync', stdio: ['ignore', 'pipe', 'pipe'], }); @@ -320,10 +319,16 @@ export class DesktopDaemonManager { } private pidInfoMatchesRunningDaemon(info: DaemonPidInfo): boolean { + if (!this.pidInfoIsCurrent(info)) return false; if (this.options.isDaemonProcess) { - return this.pidInfoIsCurrent(info) && this.processIsDaemon(info.pid); + return this.processIsDaemon(info.pid); } - return isDaemonPidInfoRunning(info); + // Honor injected liveness/freshness; force provenance checks already satisfied above. + return isDaemonPidInfoRunning(info, { + processRunning: this.processIsRunning(info.pid), + currentHostname: info.hostname, + currentBootId: info.bootId ?? null, + }); } /** Orphan workers may still look like Electron utilities after the parent exits. */