diff --git a/config/scripts/wsl-hook-relay-reattach-benchmark.mjs b/config/scripts/wsl-hook-relay-reattach-benchmark.mjs index 7595c539b..b87c413ee 100644 --- a/config/scripts/wsl-hook-relay-reattach-benchmark.mjs +++ b/config/scripts/wsl-hook-relay-reattach-benchmark.mjs @@ -364,8 +364,9 @@ async function main() { const { runtime, getController } = createRuntimeStub() // Why hooks off: fresh WSL spawn still runs buildPtyHostEnv, which calls ensureForDistro when - // hooks are on. This bench isolates the reattach call site (ensureWslHookRelayForReattach), - // which does not gate on agentStatusHooksEnabled. + // hooks are on. This bench isolates the reattach call site (ensureWslHookRelayForReattach); + // the manager's own hooks gate lives behind the ensureForDistro patch above, so it stays out + // of the measurement — `manager` below resolves its own (enabled) managed-hook settings. ptyIpc.registerPtyHandlers( createRendererWindowStub(), runtime, diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index a13450b0e..0e767ee72 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -31,7 +31,7 @@ type RemoveOptions = { } export function isAgentStatusHooksEnabled( - settings: Pick | null | undefined + settings: Partial> | null | undefined ): boolean { return settings?.agentStatusHooksEnabled !== false } diff --git a/src/main/agent-hooks/managed-hook-detection-commands.ts b/src/main/agent-hooks/managed-hook-detection-commands.ts index ba84c901b..b73588078 100644 --- a/src/main/agent-hooks/managed-hook-detection-commands.ts +++ b/src/main/agent-hooks/managed-hook-detection-commands.ts @@ -9,7 +9,7 @@ import type { GlobalSettings } from '../../shared/types' import type { TuiAgentDetectionCommand } from '../ipc/tui-agent-detection-commands' export type ManagedHookDetectionSettings = Partial< - Pick + Pick > | null export function buildManagedHookDetectionCommands( diff --git a/src/main/agent-hooks/wsl-hook-relay-deps.ts b/src/main/agent-hooks/wsl-hook-relay-deps.ts index 9d6fbccde..a7f1ef54b 100644 --- a/src/main/agent-hooks/wsl-hook-relay-deps.ts +++ b/src/main/agent-hooks/wsl-hook-relay-deps.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' +import { isAgentStatusHooksEnabled } from './managed-agent-hook-controls' import { agentHookServer } from './server' import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' import { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' @@ -67,6 +68,16 @@ export type WslHookRelayManagerDeps = { transientRetryDelayMs: number } +/** Every relay start — spawn, PTY reattach, crash recovery — funnels through this gate, + * so the user's agent-status-hooks switch is read live instead of at each call site. */ +export function isWslHookRelayAllowed(deps: WslHookRelayManagerDeps): boolean { + return ( + deps.platform() === 'win32' && + deps.remoteHooksEnabled() && + isAgentStatusHooksEnabled(deps.managedHookSettings()) + ) +} + export const defaultWslHookRelayDeps: WslHookRelayManagerDeps = { platform: () => process.platform, remoteHooksEnabled: () => isRemoteAgentHooksEnabled(), diff --git a/src/main/agent-hooks/wsl-hook-relay-guest-install.ts b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts new file mode 100644 index 000000000..d7e59651a --- /dev/null +++ b/src/main/agent-hooks/wsl-hook-relay-guest-install.ts @@ -0,0 +1,77 @@ +// The install pass a connected WSL relay runs inside its guest: the managed +// hook installers, the OpenCode plugin overlay, and the interval policy that +// decides when a still-running relay may install again. Kept out of the +// manager so that file stays about relay lifecycle. +import type { ManagedHookDetectionSettings } from './managed-hook-detection-commands' +import type { installRemoteManagedAgentHooks } from './remote-managed-hook-installers' +import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' +import { installWslGuestHooks } from './wsl-hook-fs-adapter' +import { REINSTALL_MIN_INTERVAL_MS } from './wsl-hook-relay-deps' +import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' +import type { PluginSources } from '../../relay/plugin-overlay' + +/** Structural slice of WslHookRelayManagerDeps — only what an install pass uses. */ +type GuestInstallDeps = { + installHooks: typeof installRemoteManagedAgentHooks + managedHookSettings: () => ManagedHookDetectionSettings + pluginSources: () => PluginSources + warn: (message: string) => void +} + +/** Structural slice of the manager's DistroState this pass reads and writes. */ +type GuestInstallState = { + distro: string + mux?: SshChannelMultiplexer + guestHome?: string + opencodeOverlayDir?: string + lastInstallAt?: number +} + +export async function runWslRelayGuestInstall( + deps: GuestInstallDeps, + state: GuestInstallState, + mux: SshChannelMultiplexer, + guestHome: string +): Promise { + state.lastInstallAt = Date.now() + await installWslGuestHooks({ + mux, + guestHome, + distro: state.distro, + installHooks: deps.installHooks, + settings: deps.managedHookSettings(), + warn: deps.warn + }) + // Why: ship OpenCode's status plugin and record the guest overlay dir the + // PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown. + const overlay = await requestGuestOpenCodeOverlayDir(mux, deps, state.distro) + if (state.mux === mux && overlay.kind !== 'unavailable') { + // Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir + // present but plugin-less, and advertising it would hide the user's own config. + state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined + } +} + +/** Rate-limited repeat of the (byte-equality idempotent) install pass on a live relay. */ +export async function maybeRerunWslRelayGuestInstall( + deps: GuestInstallDeps, + state: GuestInstallState +): Promise { + const mux = state.mux + const guestHome = state.guestHome + if ( + !mux || + !guestHome || + mux.isDisposed() || + Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS + ) { + return + } + try { + // Why: the pass also re-ships the plugin source, so a mid-session Orca upgrade refreshes it. + await runWslRelayGuestInstall(deps, state, mux, guestHome) + } catch (err) { + const detail = err instanceof Error ? err.message : String(err) + deps.warn(`[agent-hooks] WSL hook reinstall for '${state.distro}' failed: ${detail}`) + } +} diff --git a/src/main/agent-hooks/wsl-hook-relay-launch.ts b/src/main/agent-hooks/wsl-hook-relay-launch.ts index 72ff3bb0c..5cb25e312 100644 --- a/src/main/agent-hooks/wsl-hook-relay-launch.ts +++ b/src/main/agent-hooks/wsl-hook-relay-launch.ts @@ -264,6 +264,11 @@ export async function launchWslRelayWithInstall(options: { await options.connect(transport, child) return } catch (err) { + // Why before the failure triage: once disposed, the guest install and retries below are + // work the caller no longer wants — the "failure" is usually our own teardown kill. + if (options.isDisposed()) { + return + } const failure = (err as { startup?: WslRelayStartupFailure }).startup if (!failure) { throw err diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts index 39a9736a5..4d2ae9b7b 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.test.ts @@ -374,14 +374,92 @@ describe('WslHookRelayManager', () => { manager.disposeAll() }) - it('is inert off-Windows and when remote hooks are disabled', async () => { + it('is inert off-Windows, when remote hooks are disabled, and when agent status hooks are off', async () => { const offPlatform = createManager({ platform: () => 'darwin' }) offPlatform.manager.ensureForDistro('Ubuntu') const disabled = createManager({ remoteHooksEnabled: () => false }) disabled.manager.ensureForDistro('Ubuntu') + const hooksOff = createManager({ + managedHookSettings: () => ({ agentStatusHooksEnabled: false }) + }) + hooksOff.manager.ensureForDistro('Ubuntu') await new Promise((resolve) => setTimeout(resolve, 20)) expect(offPlatform.deps.spawnRelay).not.toHaveBeenCalled() expect(disabled.deps.spawnRelay).not.toHaveBeenCalled() + expect(hooksOff.deps.spawnRelay).not.toHaveBeenCalled() + }) + + it('stops live relays and refuses to revive them once agent status hooks are switched off', async () => { + const settings = { agentStatusHooksEnabled: true } + const { manager, deps } = createManager({ managedHookSettings: () => settings }) + manager.ensureForDistro('Ubuntu') + await vi.waitFor(() => expect(deps.installHooks).toHaveBeenCalledTimes(1)) + + settings.agentStatusHooksEnabled = false + manager.disposeAll({ permanent: false }) + // Reattach and crash recovery both re-enter ensureForDistro; neither may reinstall guest hooks now. + manager.ensureForDistro('Ubuntu') + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + expect(deps.installHooks).toHaveBeenCalledTimes(1) + expect(manager.getGuestEndpointFilePath('Ubuntu')).toBeNull() + + // Re-enabling puts the relay back without waiting for the next WSL spawn. + settings.agentStatusHooksEnabled = true + manager.resumeStoppedRelays() + await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(2)) + manager.disposeAll() + }) + + it('does not resume a relay whose distro the user shut down while hooks were off', async () => { + const settings = { agentStatusHooksEnabled: true } + const isDistroRunning = vi.fn(async () => true) + const { manager, deps } = createManager({ + isDistroRunning, + managedHookSettings: () => settings + }) + manager.ensureForDistro('Ubuntu') + await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(1)) + + settings.agentStatusHooksEnabled = false + manager.disposeAll({ permanent: false }) + settings.agentStatusHooksEnabled = true + // Why: resuming through `wsl -d` would boot the VM the user shut down, and no agent inside it + // is waiting on status — the next WSL terminal re-ensures anyway. + isDistroRunning.mockResolvedValue(false) + manager.resumeStoppedRelays() + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + // A second resume must not retry a distro already consumed by the first. + isDistroRunning.mockResolvedValue(true) + manager.resumeStoppedRelays() + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + }) + + it('abandons a launch that was still in flight when hooks were switched off', async () => { + let failSentinel: ((error: unknown) => void) | undefined + const { manager, deps } = createManager({ + waitForSentinel: vi.fn( + () => + new Promise((_resolve, reject) => { + failSentinel = reject + }) + ) + }) + manager.ensureForDistro('Ubuntu') + await vi.waitFor(() => expect(deps.spawnRelay).toHaveBeenCalledTimes(1)) + + manager.disposeAll({ permanent: false }) + // The teardown's child kill reaches the in-flight launch as a startup failure; its retry and + // guest-install paths must not run, or the user would get an untracked relay after opting out. + failSentinel?.(startupError(1)) + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(deps.spawnRelay).toHaveBeenCalledTimes(1) + expect(deps.runInstall).not.toHaveBeenCalled() }) it('requires WSL fs-bridge home coordinates before exposing an endpoint path', () => { diff --git a/src/main/agent-hooks/wsl-hook-relay-manager.ts b/src/main/agent-hooks/wsl-hook-relay-manager.ts index 9971ed04f..74d163ca4 100644 --- a/src/main/agent-hooks/wsl-hook-relay-manager.ts +++ b/src/main/agent-hooks/wsl-hook-relay-manager.ts @@ -3,14 +3,17 @@ // spawn, forwarding envelopes into ingestRemote and installing guest hooks. import type { ChildProcessWithoutNullStreams } from 'node:child_process' -import { installWslGuestHooks } from './wsl-hook-fs-adapter' +import { + runWslRelayGuestInstall, + maybeRerunWslRelayGuestInstall +} from './wsl-hook-relay-guest-install' import { buildWslRelaySpawnEnv, launchWslRelayWithInstall } from './wsl-hook-relay-launch' import { defaultWslHookRelayDeps, + isWslHookRelayAllowed, FAILURE_COOLDOWN_BASE_MS, FAILURE_COOLDOWN_MAX_MS, NO_NODE_COOLDOWN_MS, - REINSTALL_MIN_INTERVAL_MS, REINSTALL_ONE_SHOT_DELAY_MS, RUNNING_TEARDOWN_COOLDOWN_MS, STABLE_UPTIME_MS, @@ -19,7 +22,6 @@ import { import { wireWslRelayLink } from './wsl-hook-relay-link' import { WslRelayRecovery } from './wsl-hook-relay-recovery' import { wslHookRelayStateKey } from './wsl-hook-relay-state-key' -import { requestGuestOpenCodeOverlayDir } from './wsl-guest-plugin-install' import { SshChannelMultiplexer, type MultiplexerTransport } from '../ssh/ssh-channel-multiplexer' import { AGENT_HOOK_REQUEST_REPLAY_METHOD } from '../../shared/agent-hook-relay' import { @@ -49,6 +51,8 @@ export class WslHookRelayManager { private deps: WslHookRelayManagerDeps private recovery: WslRelayRecovery private states = new Map() + /** Distros a hooks-off teardown stopped, so re-enabling can put them back. */ + private stoppedByHooksOff = new Set() private defaultDistro: string | null = null private disposed = false private warnedBundleMissing = false @@ -78,13 +82,12 @@ export class WslHookRelayManager { /** Fire-and-forget from every WSL PTY spawn-env build; errors breadcrumb. */ ensureForDistro(distro: string | null): void { - if (this.disposed || this.deps.platform() !== 'win32' || !this.deps.remoteHooksEnabled()) { + if (this.disposed || !isWslHookRelayAllowed(this.deps)) { return } void this.ensureInternal(distro).catch((err) => { - this.deps.warn( - `[agent-hooks] WSL hook relay ensure failed: ${err instanceof Error ? err.message : String(err)}` - ) + const detail = err instanceof Error ? err.message : String(err) + this.deps.warn(`[agent-hooks] WSL hook relay ensure failed: ${detail}`) }) } @@ -106,16 +109,38 @@ export class WslHookRelayManager { return this.stateFor(distro)?.opencodeOverlayDir ?? null } - disposeAll(): void { - this.disposed = true + /** Kills every live relay. Non-permanent (hooks switched off mid-session) leaves the + * manager reusable, so re-enabling hooks can start relays again without an app restart. */ + disposeAll({ permanent = true }: { permanent?: boolean } = {}): void { + this.disposed ||= permanent for (const state of this.states.values()) { this.recovery.clearTimers(state) state.mux?.dispose() state.child?.kill() + if (!permanent) { + this.stoppedByHooksOff.add(state.distro) + } } this.states.clear() } + /** Restarts what a hooks-off teardown stopped. Skips distros the user has since shut + * down: `wsl -d` BOOTS a stopped distro, and nothing in it is waiting on status. */ + resumeStoppedRelays(): void { + const distros = [...this.stoppedByHooksOff] + this.stoppedByHooksOff.clear() + for (const distro of distros) { + void this.deps + .isDistroRunning(distro) + .then((running) => { + if (running) { + this.ensureForDistro(distro) + } + }) + .catch(() => undefined) + } + } + private async ensureInternal(requestedDistro: string | null): Promise { const distro = requestedDistro ?? (await this.resolveDefaultDistro()) if (!distro || this.disposed) { @@ -125,7 +150,7 @@ export class WslHookRelayManager { const existing = this.states.get(key) if (existing) { if (existing.phase === 'running') { - void this.maybeReinstallHooks(existing) + void maybeRerunWslRelayGuestInstall(this.deps, existing) return } if (existing.phase !== 'failed' || Date.now() < existing.cooldownUntil) { @@ -172,7 +197,9 @@ export class WslHookRelayManager { bundleJsPath: bundle.jsPath, version: bundle.version, io: this.deps, - isDisposed: () => this.disposed, + // Why the identity half: a hooks-off teardown drops this state and kills its child, but + // that kill reads as a startup failure and the retry loop would respawn an untracked relay. + isDisposed: () => this.disposed || this.states.get(key) !== state, onChild: (child) => { state.child = child }, @@ -252,7 +279,7 @@ export class WslHookRelayManager { } state.guestHome = homeResult.home state.guestEndpointFilePath = wslHookRelayEndpointFilePath(homeResult.home, instanceKey) - await this.runInstallers(state, mux, homeResult.home) + await runWslRelayGuestInstall(this.deps, state, mux, homeResult.home) if (state.phase === 'failed' || state.mux !== mux) { // Child died while installing — already recorded; don't revive. @@ -263,58 +290,13 @@ export class WslHookRelayManager { // Why: one-shot catch-up so a single-spawn session (no later ensure) // still writes Codex's deferred trust after the launch path seeds config.toml. this.recovery.scheduleOneShotReinstall(state, REINSTALL_ONE_SHOT_DELAY_MS, () => { - void this.maybeReinstallHooks(state) + void maybeRerunWslRelayGuestInstall(this.deps, state) }) void mux.request(AGENT_HOOK_REQUEST_REPLAY_METHOD).catch(() => { // Fresh relays have nothing to replay; tolerate. }) } - private async runInstallers( - state: DistroState, - mux: SshChannelMultiplexer, - guestHome: string - ): Promise { - state.lastInstallAt = Date.now() - await installWslGuestHooks({ - mux, - guestHome, - distro: state.distro, - installHooks: this.deps.installHooks, - settings: this.deps.managedHookSettings(), - warn: this.deps.warn - }) - // Why: ship OpenCode's status plugin and record the guest overlay dir the - // PTY env points OPENCODE_CONFIG_DIR at; identity-guarded against teardown. - const overlay = await requestGuestOpenCodeOverlayDir(mux, this.deps, state.distro) - if (state.mux === mux && overlay.kind !== 'unavailable') { - // Clearing on 'none' matters: a rebuild that failed after wiping leaves the dir - // present but plugin-less, and advertising it would hide the user's own config. - state.opencodeOverlayDir = overlay.kind === 'dir' ? overlay.dir : undefined - } - } - - private async maybeReinstallHooks(state: DistroState): Promise { - const mux = state.mux - const guestHome = state.guestHome - if ( - !mux || - !guestHome || - mux.isDisposed() || - Date.now() - (state.lastInstallAt ?? 0) < REINSTALL_MIN_INTERVAL_MS - ) { - return - } - try { - // Why: runInstallers also re-ships the plugin source so a mid-session Orca upgrade refreshes it. - await this.runInstallers(state, mux, guestHome) - } catch (err) { - this.deps.warn( - `[agent-hooks] WSL hook reinstall for '${state.distro}' failed: ${err instanceof Error ? err.message : String(err)}` - ) - } - } - /** Records + breadcrumbs the failure and always arms the restart timer — * one failed relaunch must not end self-recovery; the timer's * distro-running probe keeps this from booting stopped distros. */ diff --git a/src/main/index.ts b/src/main/index.ts index ed4e48b43..dc80f4198 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2136,6 +2136,16 @@ void app.whenReady().then(async () => { // Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live. syncMacMenuBarIcon(settings.showMenuBarIcon !== false) } + if ('agentStatusHooksEnabled' in updates) { + // Why both directions: the ensure gate only blocks NEW relays, so off must stop the running + // guest process and timers, and on must restart them — otherwise open WSL panes report no + // status until their next spawn. + if (isAgentStatusHooksEnabled(settings)) { + wslHookRelayManager.resumeStoppedRelays() + } else { + wslHookRelayManager.disposeAll({ permanent: false }) + } + } }) // Why: run before ClaudeRuntimeAuthService's constructor sync — a surviving daemon Claude CLI holds the single-use refresh token; early refresh rotates it out mid-session. attachClaudeLivePtyPersistence(store) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index db1a28340..2ea4956da 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -17124,6 +17124,33 @@ describe('registerPtyHandlers', () => { ]) }) + it('refreshes the WSL hook relay for the distro a reattached pane already owns', async () => { + // Why here and not only in the helper's unit test: nothing else catches pty.ts dropping the + // reattach call — the manager owns the hooks/platform gating this spy stands in for. + const ensureForDistro = vi + .spyOn(wslHookRelayManager, 'ensureForDistro') + .mockImplementation(() => {}) + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'pty-wsl', isReattach: true, wslDistro: 'Ubuntu-24.04' })), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + shutdown: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + listProcesses: vi.fn(async () => []), + getForegroundProcess: vi.fn(async () => null) + } as never) + registerPtyHandlers(mainWindow as never) + + try { + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: 'pty-wsl' }) + expect(ensureForDistro).toHaveBeenCalledWith('Ubuntu-24.04') + } finally { + ensureForDistro.mockRestore() + } + }) + posixOnlyIt( 'does not guess route provenance for a pane-local shell startup CODEX_HOME', async () => {