diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f2b04b3a282..d5acd56b91a 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -74,7 +74,6 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ npmPackageName: "@anthropic-ai/claude-code", homebrewFormula: "claude-code", nativeUpdate: { - executable: "claude", args: ["update"], lockKey: "claude-native", isCommandPath: isClaudeNativeCommandPath, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts new file mode 100644 index 00000000000..3e8ca55a517 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + deriveCodexStandaloneUpdateEnvironment, + isCodexStandaloneCommandPath, + resolveCodexProviderMaintenanceCapabilities, +} from "./CodexDriver.ts"; + +describe("isCodexStandaloneCommandPath", () => { + it("matches current-symlink standalone layouts", () => { + expect( + isCodexStandaloneCommandPath("/home/u/.codex/packages/standalone/current/bin/codex"), + ).toBe(true); + expect(isCodexStandaloneCommandPath("/home/u/.codex/packages/standalone/current/codex")).toBe( + true, + ); + }); + + it("matches the versioned release binary the current symlink resolves to", () => { + expect( + isCodexStandaloneCommandPath( + "/home/u/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl/codex", + ), + ).toBe(true); + expect( + isCodexStandaloneCommandPath( + "C:\\Users\\u\\.codex\\packages\\standalone\\releases\\0.111.0-x86_64-pc-windows-msvc\\codex.EXE", + ), + ).toBe(true); + }); + + it("rejects paths outside a standalone install", () => { + expect(isCodexStandaloneCommandPath("/home/u/.local/bin/codex")).toBe(false); + expect(isCodexStandaloneCommandPath("/usr/lib/node_modules/@openai/codex/bin/codex.js")).toBe( + false, + ); + expect( + isCodexStandaloneCommandPath("/home/u/monorepo/packages/standalone/node_modules/.bin/codex"), + ).toBe(false); + expect( + isCodexStandaloneCommandPath( + "/home/u/.codex/packages/standalone/releases/0.111.0/notes/codex.txt", + ), + ).toBe(false); + }); +}); + +describe("deriveCodexStandaloneUpdateEnvironment", () => { + it("pins CODEX_HOME to the install root of the matched binary", () => { + expect( + deriveCodexStandaloneUpdateEnvironment( + "/home/julius/codex-home/packages/standalone/current/codex", + ), + ).toEqual({ CODEX_HOME: "/home/julius/codex-home" }); + expect( + deriveCodexStandaloneUpdateEnvironment( + "/home/u/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl/codex", + ), + ).toEqual({ CODEX_HOME: "/home/u/.codex" }); + expect( + deriveCodexStandaloneUpdateEnvironment( + "C:\\Users\\u\\.codex\\packages\\standalone\\releases\\1.0.0\\codex.exe", + ), + ).toEqual({ CODEX_HOME: "C:\\Users\\u\\.codex" }); + }); + + it("returns null when no install root precedes the standalone segment", () => { + expect(deriveCodexStandaloneUpdateEnvironment("/packages/standalone/current/codex")).toBeNull(); + }); +}); + +describe("resolveCodexProviderMaintenanceCapabilities", () => { + it.each([ + { + label: "bare command", + binaryPath: "codex", + resolvedCommandPath: "/home/u/.local/bin/codex", + expectedExecutable: "/home/u/.local/bin/codex", + }, + { + label: "current alias", + binaryPath: "/home/u/.codex/packages/standalone/current/codex", + resolvedCommandPath: "/home/u/.codex/packages/standalone/current/codex", + expectedExecutable: "/home/u/.codex/packages/standalone/current/codex", + }, + { + label: "visible alias", + binaryPath: "/usr/local/bin/codex", + resolvedCommandPath: "/usr/local/bin/codex", + expectedExecutable: "/usr/local/bin/codex", + }, + ])("allows native updates for a $label with a versioned release realpath", (testCase) => { + expect( + resolveCodexProviderMaintenanceCapabilities({ + binaryPath: testCase.binaryPath, + platform: "linux", + resolvedCommandPath: testCase.resolvedCommandPath, + realCommandPath: + "/home/u/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl/codex", + }), + ).toEqual({ + provider: "codex", + packageName: "@openai/codex", + update: { + command: `CODEX_HOME=/home/u/.codex ${testCase.expectedExecutable} update`, + executable: testCase.expectedExecutable, + args: ["update"], + lockKey: "codex-native", + env: { CODEX_HOME: "/home/u/.codex" }, + }, + }); + }); + + it.each([ + "/home/u/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl/codex", + "/home/u/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl/bin/codex", + ])("disables one-click updates for an explicitly configured release path: %s", (binaryPath) => { + expect( + resolveCodexProviderMaintenanceCapabilities({ + binaryPath, + resolvedCommandPath: binaryPath, + realCommandPath: binaryPath, + }), + ).toEqual({ + provider: "codex", + packageName: "@openai/codex", + update: null, + }); + }); + + it("disables one-click updates when a bare command resolves directly to a release", () => { + const releasePath = + "/home/u/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl/codex"; + + expect( + resolveCodexProviderMaintenanceCapabilities({ + binaryPath: "codex", + resolvedCommandPath: releasePath, + realCommandPath: releasePath, + }), + ).toEqual({ + provider: "codex", + packageName: "@openai/codex", + update: null, + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..d6997feb503 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -44,7 +44,11 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, + makeManualOnlyProviderMaintenanceCapabilities, makePackageManagedProviderMaintenanceResolver, + normalizeCommandPath, + type ProviderMaintenanceCapabilityResolutionOptions, + type ProviderMaintenanceCapabilitiesResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { @@ -61,13 +65,80 @@ const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); -const UPDATE = makePackageManagedProviderMaintenanceResolver({ + +// The standalone installer's `current` entry is a symlink into `releases/`, +// so the realpath of an on-PATH codex is the versioned binary — which sits +// directly in the release dir, without a `bin/` segment. +const CODEX_STANDALONE_RELEASE_BINARY_PATTERN = + /\/packages\/standalone\/releases\/[^/]+\/(?:bin\/)?codex(?:\.exe)?$/; +const CODEX_STANDALONE_ROOT_SEGMENT_PATTERN = /\/packages\/standalone\//i; +const CODEX_NPM_PACKAGE_NAME = "@openai/codex"; + +export function isCodexStandaloneCommandPath(commandPath: string): boolean { + const normalized = normalizeCommandPath(commandPath); + return ( + normalized.includes("/packages/standalone/") && + (normalized.endsWith("/bin/codex") || + normalized.endsWith("/bin/codex.exe") || + normalized.endsWith("/packages/standalone/current/codex") || + normalized.endsWith("/packages/standalone/current/codex.exe") || + CODEX_STANDALONE_RELEASE_BINARY_PATTERN.test(normalized)) + ); +} + +export function deriveCodexStandaloneUpdateEnvironment( + commandPath: string, +): Readonly> | null { + // `codex update` locates the standalone install via $CODEX_HOME, and the + // maintenance runner spawns with the server's own environment — pin + // CODEX_HOME to the install root the matched binary actually lives in. + const match = CODEX_STANDALONE_ROOT_SEGMENT_PATTERN.exec(commandPath.replaceAll("\\", "/")); + if (!match || match.index === 0) { + return null; + } + return { CODEX_HOME: commandPath.slice(0, match.index) }; +} + +const PACKAGE_MANAGED_UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, - npmPackageName: "@openai/codex", + npmPackageName: CODEX_NPM_PACKAGE_NAME, homebrewFormula: "codex", - nativeUpdate: null, + nativeUpdate: { + args: ["update"], + lockKey: "codex-native", + isCommandPath: isCodexStandaloneCommandPath, + deriveEnv: deriveCodexStandaloneUpdateEnvironment, + }, }); +export function resolveCodexProviderMaintenanceCapabilities( + options?: ProviderMaintenanceCapabilityResolutionOptions, +) { + const binaryPath = options?.binaryPath?.trim(); + const resolvedCommandPath = options?.resolvedCommandPath?.trim() ?? binaryPath; + + // A command that resolves directly to a release path stays pinned after + // `codex update`: the installer adds a sibling release and repoints + // `current`, but it does not replace that path. Keep matching release + // *realpaths* below so movable aliases still get native updates; only the + // path the provider will continue invoking makes this manual-only. + if ( + resolvedCommandPath && + CODEX_STANDALONE_RELEASE_BINARY_PATTERN.test(normalizeCommandPath(resolvedCommandPath)) + ) { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: CODEX_NPM_PACKAGE_NAME, + }); + } + + return PACKAGE_MANAGED_UPDATE.resolve(options); +} + +const UPDATE = { + resolve: resolveCodexProviderMaintenanceCapabilities, +} satisfies ProviderMaintenanceCapabilitiesResolver; + /** * Services the driver needs to materialize an instance. Surfaced as the * driver's `R` so the registry layer aggregates these across every diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 6342d176590..9a24079fc78 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -70,7 +70,6 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ npmPackageName: "opencode-ai", homebrewFormula: "anomalyco/tap/opencode", nativeUpdate: { - executable: "opencode", args: ["upgrade"], lockKey: "opencode-native", isCommandPath: isOpenCodeNativeCommandPath, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dbfa7faffea..8345021e18c 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -135,7 +135,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { displayName: "Codex (work)", enabled: false, config: makeCodexConfig({ - binaryPath: "/opt/codex-work/bin/codex", + binaryPath: "/home/julius/codex-home/packages/standalone/current/codex", homePath: "/home/julius/.codex", customModels: ["work-preview"], }), @@ -180,6 +180,17 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { expect(workSnapshot.driver).toBe(codexDriverKind); expect(workSnapshot.enabled).toBe(false); expect(workSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex"); + expect(work!.snapshot.maintenanceCapabilities).toMatchObject({ + packageName: "@openai/codex", + update: { + command: + "CODEX_HOME=/home/julius/codex-home /home/julius/codex-home/packages/standalone/current/codex update", + executable: "/home/julius/codex-home/packages/standalone/current/codex", + args: ["update"], + lockKey: "codex-native", + env: { CODEX_HOME: "/home/julius/codex-home" }, + }, + }); // Nothing goes to the unavailable bucket — both drivers are registered. const unavailable = yield* registry.listUnavailable; diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 8937844f613..3e0a1aab58e 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -42,18 +42,27 @@ const nativePackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ npmPackageName: "@example/native-package-tool", homebrewFormula: "native-package-tool", nativeUpdate: { - executable: "native-package-tool", args: ["update"], lockKey: "native-package-tool-native", isCommandPath: isNativeTestCommandPath("/.local/bin/native-package-tool"), }, }); +const envPackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ + provider: driver("envPackageTool"), + npmPackageName: "@example/env-package-tool", + homebrewFormula: "env-package-tool", + nativeUpdate: { + args: ["update"], + lockKey: "env-package-tool-native", + isCommandPath: isNativeTestCommandPath("/.env-package-tool/bin/env-package-tool"), + deriveEnv: (commandPath) => ({ TOOL_HOME: commandPath }), + }, +}); const scopedPackageToolUpdate = makePackageManagedProviderMaintenanceResolver({ provider: driver("scopedPackageTool"), npmPackageName: "@example/scoped-package-tool", homebrewFormula: "example/tap/scoped-package-tool", nativeUpdate: { - executable: "scoped-package-tool", args: ["upgrade"], lockKey: "scoped-package-tool-native", isCommandPath: isNativeTestCommandPath("/.scoped-package-tool/bin/scoped-package-tool"), @@ -356,9 +365,9 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { provider: driver("nativePackageTool"), packageName: "@example/native-package-tool", update: { - command: "native-package-tool update", + command: `${nativePackageToolPath} update`, - executable: "native-package-tool", + executable: nativePackageToolPath, args: ["update"], @@ -393,9 +402,9 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { provider: driver("scopedPackageTool"), packageName: "@example/scoped-package-tool", update: { - command: "scoped-package-tool upgrade", + command: `${scopedPackageToolPath} upgrade`, - executable: "scoped-package-tool", + executable: scopedPackageToolPath, args: ["upgrade"], @@ -428,6 +437,100 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }); }); + it("uses explicit native binary paths as the native update executable", () => { + expect( + nativePackageToolUpdate.resolve({ + binaryPath: "/custom/.local/bin/native-package-tool", + }), + ).toEqual({ + provider: driver("nativePackageTool"), + packageName: "@example/native-package-tool", + update: { + command: "/custom/.local/bin/native-package-tool update", + + executable: "/custom/.local/bin/native-package-tool", + + args: ["update"], + + lockKey: "native-package-tool-native", + }, + }); + }); + + it("derives the native update environment from the matched command path", () => { + expect( + envPackageToolUpdate.resolve({ + binaryPath: "env-package-tool", + platform: "linux", + resolvedCommandPath: "/home/u/.local/bin/env-package-tool", + realCommandPath: "/home/u/.env-package-tool/bin/env-package-tool", + }), + ).toEqual({ + provider: driver("envPackageTool"), + packageName: "@example/env-package-tool", + update: { + command: + "TOOL_HOME=/home/u/.env-package-tool/bin/env-package-tool /home/u/.local/bin/env-package-tool update", + + executable: "/home/u/.local/bin/env-package-tool", + + args: ["update"], + + lockKey: "env-package-tool-native", + + env: { TOOL_HOME: "/home/u/.env-package-tool/bin/env-package-tool" }, + }, + }); + }); + + it("shell-escapes native manual update commands and includes their environment", () => { + expect( + makeProviderMaintenanceCapabilities({ + provider: driver("nativePackageTool"), + packageName: "@example/native-package-tool", + updateExecutable: "/home/user/Native Tool/bin/native-package-tool", + updateArgs: ["update", "channel; echo injected"], + updateLockKey: "native-package-tool-native", + updateEnv: { TOOL_HOME: "/home/user/It's Native Tool" }, + platform: "linux", + }).update?.command, + ).toBe( + "TOOL_HOME='/home/user/It'\\''s Native Tool' '/home/user/Native Tool/bin/native-package-tool' update 'channel; echo injected'", + ); + }); + + it("formats native manual update commands for PowerShell on Windows", () => { + expect( + makeProviderMaintenanceCapabilities({ + provider: driver("nativePackageTool"), + packageName: "@example/native-package-tool", + updateExecutable: "C:\\Program Files\\Native Tool\\native-package-tool.exe", + updateArgs: ["update", "release candidate"], + updateLockKey: "native-package-tool-native", + updateEnv: { + TOOL_HOME: "C:\\Users\\O'Brien\\Native Tool", + UPDATE_CHANNEL: "stable", + }, + platform: "win32", + }).update?.command, + ).toBe( + "$env:TOOL_HOME = 'C:\\Users\\O''Brien\\Native Tool'; $env:UPDATE_CHANNEL = 'stable'; & 'C:\\Program Files\\Native Tool\\native-package-tool.exe' update 'release candidate'", + ); + }); + + it("keeps existing package-manager commands copyable in PowerShell", () => { + expect( + makeProviderMaintenanceCapabilities({ + provider: driver("packageTool"), + packageName: "@example/package-tool", + updateExecutable: "npm", + updateArgs: ["install", "-g", "@example/package-tool@latest"], + updateLockKey: "npm-global", + platform: "win32", + }).update?.command, + ).toBe("npm install -g @example/package-tool@latest"); + }); + it("switches scoped-package-tool to Homebrew updates when the binary resolves through Homebrew", () => { expect( scopedPackageToolUpdate.resolve({ diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 8645f9f943c..73d84518429 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -3,6 +3,7 @@ import { type ServerProvider, type ServerProviderVersionAdvisory, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { resolveCommandPath } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; @@ -14,6 +15,8 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { makeProviderMaintenanceManualCommand } from "./providerMaintenanceCommand.ts"; + const LATEST_VERSION_CACHE_TTL_MS = 60 * 60 * 1_000; const LATEST_VERSION_TIMEOUT_MS = 4_000; const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review provider settings."; @@ -44,15 +47,17 @@ export interface ProviderMaintenanceCapabilities { } export interface ProviderMaintenanceCommandAction { - readonly command: string; + readonly command: string | null; readonly executable: string; readonly args: ReadonlyArray; readonly lockKey: string; + readonly env?: Readonly>; } export interface ProviderMaintenanceCapabilityResolutionOptions { readonly binaryPath?: string | null; readonly env?: NodeJS.ProcessEnv; + readonly platform?: NodeJS.Platform; readonly resolvedCommandPath?: string | null; readonly realCommandPath?: string | null; } @@ -68,10 +73,16 @@ export interface PackageManagedProviderMaintenanceDefinition { readonly npmPackageName: string; readonly homebrewFormula: string | null; readonly nativeUpdate: { - readonly executable: string; readonly args: ReadonlyArray; readonly lockKey: string; readonly isCommandPath: (commandPath: string) => boolean; + /** + * Extra environment for the update spawn, derived from the command path + * that matched `isCommandPath`. The maintenance runner spawns with the + * server's own environment, so anything the updater needs beyond that + * (e.g. the install root of the matched binary) must be supplied here. + */ + readonly deriveEnv?: (commandPath: string) => Readonly> | null; } | null; } @@ -100,15 +111,23 @@ export function makeProviderMaintenanceCapabilities(input: { readonly updateExecutable: string | null; readonly updateArgs: ReadonlyArray; readonly updateLockKey: string | null; + readonly updateEnv?: Readonly> | null; + readonly platform?: NodeJS.Platform; }): ProviderMaintenanceCapabilities { const update = input.updateExecutable === null || input.updateLockKey === null ? null : { - command: [input.updateExecutable, ...input.updateArgs].join(" "), + command: makeProviderMaintenanceManualCommand({ + executable: input.updateExecutable, + args: input.updateArgs, + ...(input.updateEnv !== undefined ? { env: input.updateEnv } : {}), + ...(input.platform !== undefined ? { platform: input.platform } : {}), + }), executable: input.updateExecutable, args: input.updateArgs, lockKey: input.updateLockKey, + ...(input.updateEnv ? { env: input.updateEnv } : {}), }; return { provider: input.provider, @@ -117,6 +136,22 @@ export function makeProviderMaintenanceCapabilities(input: { }; } +function makeProviderMaintenanceUpdateActionKey(update: ProviderMaintenanceCommandAction): string { + // Opaque identity for "do these instances run the same update action?" — + // JSON keeps path/arg/env boundaries unambiguous where a plain join would + // alias (executables are user-controlled paths that may contain spaces). + return JSON.stringify([ + update.lockKey, + update.executable, + update.args, + update.env + ? Object.entries(update.env).toSorted(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ) + : null, + ]); +} + export function makeManualOnlyProviderMaintenanceCapabilities(input: { readonly provider: ProviderDriverKind; readonly packageName: string | null; @@ -199,17 +234,21 @@ function makeHomebrewProviderMaintenanceCapabilities( function makeNativeProviderMaintenanceCapabilities( definition: PackageManagedProviderMaintenanceDefinition, -): ProviderMaintenanceCapabilities | null { - if (!definition.nativeUpdate) { - return null; - } - + nativeUpdate: NonNullable, + options: { + readonly updateExecutable: string; + readonly updateEnv: Readonly> | null; + readonly platform?: NodeJS.Platform; + }, +): ProviderMaintenanceCapabilities { return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName: definition.npmPackageName, - updateExecutable: definition.nativeUpdate.executable, - updateArgs: definition.nativeUpdate.args, - updateLockKey: definition.nativeUpdate.lockKey, + updateExecutable: options.updateExecutable, + updateArgs: nativeUpdate.args, + updateLockKey: nativeUpdate.lockKey, + updateEnv: options.updateEnv, + ...(options.platform !== undefined ? { platform: options.platform } : {}), }); } @@ -282,14 +321,21 @@ export function resolvePackageManagedProviderMaintenance( ]; const nativeUpdate = definition.nativeUpdate; - if ( - nativeUpdate && - commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath)) - ) { - return ( - makeNativeProviderMaintenanceCapabilities(definition) ?? - makeNpmGlobalProviderMaintenanceCapabilities(definition) + if (nativeUpdate) { + const nativeCommandPath = commandPaths.find((commandPath) => + nativeUpdate.isCommandPath(commandPath), ); + if (nativeCommandPath !== undefined) { + return makeNativeProviderMaintenanceCapabilities(definition, nativeUpdate, { + // Capability detection may use an instance-specific PATH that is not + // present in the long-lived server process. Pin the executable we + // actually resolved so the later update spawn cannot select a + // different installation (or fail with ENOENT). + updateExecutable: resolvedCommandPath, + updateEnv: nativeUpdate.deriveEnv?.(nativeCommandPath) ?? null, + ...(options?.platform !== undefined ? { platform: options.platform } : {}), + }); + } } if (commandPaths.some(isVitePlusGlobalCommandPath)) { return makeVitePlusGlobalProviderMaintenanceCapabilities(definition); @@ -349,9 +395,11 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( resolver: ProviderMaintenanceCapabilitiesResolver, options?: Omit, ) { + const platform = options?.platform ?? (yield* HostProcessPlatform); + const resolutionOptions = { ...options, platform }; const binaryPath = nonEmptyString(options?.binaryPath); if (!binaryPath) { - return resolver.resolve(options); + return resolver.resolve(resolutionOptions); } const env = options?.env ?? (yield* readCommandLookupEnv); @@ -360,7 +408,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); if (!resolvedCommandPath) { - return resolver.resolve(options); + return resolver.resolve(resolutionOptions); } const fileSystem = yield* FileSystem.FileSystem; @@ -368,7 +416,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( .realPath(resolvedCommandPath) .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); return resolver.resolve({ - ...options, + ...resolutionOptions, env, resolvedCommandPath, realCommandPath, @@ -414,6 +462,9 @@ export function createProviderVersionAdvisory(input: { currentVersion: input.currentVersion, latestVersion, updateCommand: capabilities.update?.command ?? null, + ...(capabilities.update + ? { updateActionKey: makeProviderMaintenanceUpdateActionKey(capabilities.update) } + : {}), canUpdate: capabilities.update !== null, checkedAt: input.checkedAt ?? null, message: advisory.message, diff --git a/apps/server/src/provider/providerMaintenanceCommand.ts b/apps/server/src/provider/providerMaintenanceCommand.ts new file mode 100644 index 00000000000..ea725630524 --- /dev/null +++ b/apps/server/src/provider/providerMaintenanceCommand.ts @@ -0,0 +1,108 @@ +const POSIX_SHELL_SAFE_WORD_PATTERN = /^[A-Za-z0-9_@%+=:,./-]+$/; +const POWERSHELL_SAFE_WORD_PATTERN = /^[A-Za-z0-9_@%+=:,./\\-]+$/; +const SHELL_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function containsNullByte(value: string): boolean { + return value.includes("\0"); +} + +function quotePosixShellWord(value: string): string | null { + if (containsNullByte(value)) { + return null; + } + if (value.length > 0 && POSIX_SHELL_SAFE_WORD_PATTERN.test(value)) { + return value; + } + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function quotePowerShellStringLiteral(value: string): string | null { + if (containsNullByte(value)) { + return null; + } + return `'${value.replaceAll("'", "''")}'`; +} + +function quotePowerShellWord(value: string): string | null { + if (containsNullByte(value)) { + return null; + } + if (value.length > 0 && POWERSHELL_SAFE_WORD_PATTERN.test(value)) { + return value; + } + return quotePowerShellStringLiteral(value); +} + +/** + * Render the structured maintenance action for the host's interactive shell. + * Returns null instead of publishing a command that would lose environment, + * argument boundaries, or executable-path quoting. + */ +export function makeProviderMaintenanceManualCommand(input: { + readonly executable: string; + readonly args: ReadonlyArray; + readonly env?: Readonly> | null; + readonly platform?: NodeJS.Platform; +}): string | null { + const envEntries = Object.entries(input.env ?? {}).toSorted(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); + if (envEntries.some(([name]) => !SHELL_ENV_NAME_PATTERN.test(name))) { + return null; + } + + // Module-level static capabilities do not have an Effect runtime from + // which to read the host platform. Preserve the existing portable command + // form for simple words, but do not guess a shell when quoting or + // environment assignment syntax would be platform-specific. + if (input.platform === undefined) { + const words = [input.executable, ...input.args]; + return envEntries.length === 0 && + words.every( + (word) => + !containsNullByte(word) && word.length > 0 && POSIX_SHELL_SAFE_WORD_PATTERN.test(word), + ) + ? words.join(" ") + : null; + } + + if (input.platform === "win32") { + const executable = quotePowerShellWord(input.executable); + const args = input.args.map(quotePowerShellWord); + const env = envEntries.map(([name, value]) => { + // Assignment RHS is parsed in PowerShell's expression mode rather than + // native-command argument mode, so even path-looking values must be + // explicit string literals. + const quotedValue = quotePowerShellStringLiteral(value); + return quotedValue === null ? null : `$env:${name} = ${quotedValue}`; + }); + if ( + executable === null || + args.some((arg) => arg === null) || + env.some((item) => item === null) + ) { + return null; + } + + const invocation = [ + POWERSHELL_SAFE_WORD_PATTERN.test(input.executable) ? executable : `& ${executable}`, + ...args, + ].join(" "); + return [...env, invocation].join("; "); + } + + const executable = quotePosixShellWord(input.executable); + const args = input.args.map(quotePosixShellWord); + const env = envEntries.map(([name, value]) => { + const quotedValue = quotePosixShellWord(value); + return quotedValue === null ? null : `${name}=${quotedValue}`; + }); + if ( + executable === null || + args.some((arg) => arg === null) || + env.some((item) => item === null) + ) { + return null; + } + return [...env, executable, ...args].join(" "); +} diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f33a9bbce42..867967a61ba 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -666,6 +666,72 @@ describe("providerMaintenanceRunner", () => { ), ); + it.effect("spawns native updates with the action's derived environment", () => { + const captured: Array<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly env: Record | undefined; + readonly extendEnv: boolean | undefined; + }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry(baseProvider); + const runner = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "/home/u/codex-home/packages/standalone/current/codex", + updateArgs: ["update"], + updateLockKey: "codex-native", + updateEnv: { CODEX_HOME: "/home/u/codex-home" }, + }), + ), + }); + + const result = yield* runner.updateProvider(CODEX_DRIVER); + + assert.strictEqual(captured.length, 1); + const call = captured[0]; + assert.ok(call, "expected the spawner to be invoked once"); + assert.strictEqual(call.command, "/home/u/codex-home/packages/standalone/current/codex"); + assert.deepStrictEqual(call.args, ["update"]); + // The derived env extends the server env rather than replacing it, so + // PATH and friends stay intact for the updater. + assert.deepStrictEqual(call.env, { CODEX_HOME: "/home/u/codex-home" }); + assert.strictEqual(call.extendEnv, true); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.0.0"), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { + readonly env?: Record | undefined; + readonly extendEnv?: boolean | undefined; + }; + }; + captured.push({ + command: childProcess.command, + args: childProcess.args, + env: childProcess.options.env, + extendEnv: childProcess.options.extendEnv, + }); + return Effect.succeed(mockHandle({ stdout: "updated" })); + }), + ), + ), + ), + ); + }); + it.effect("resolves npm to a .cmd shim and routes through the shell on win32", () => { const captured: Array<{ readonly command: string; diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 3c114dd83d8..edbe1aa3b8f 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -73,6 +73,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly command: string; readonly args: ReadonlyArray; + readonly env?: Readonly>; }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { @@ -83,7 +84,12 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR // shell. On Linux/macOS (incl. the WSL backend) this is a no-op. const resolved = yield* resolveSpawnCommand(input.command, input.args); const child = yield* input.spawner - .spawn(ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell })) + .spawn( + ChildProcess.make(resolved.command, resolved.args, { + shell: resolved.shell, + ...(input.env ? { env: input.env, extendEnv: true } : {}), + }), + ) .pipe( Effect.mapError( (cause) => @@ -201,11 +207,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; - const runMaintenanceCommand = (command: string, args: ReadonlyArray) => + const runMaintenanceCommand = ( + command: string, + args: ReadonlyArray, + env?: Readonly>, + ) => runProviderMaintenanceCommandWithSpawner({ spawner, command, args, + ...(env ? { env } : {}), }); const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ makeAlreadyRunningError: () => @@ -339,7 +350,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), ); - const result = yield* runMaintenanceCommand(update.executable, update.args); + const result = yield* runMaintenanceCommand(update.executable, update.args, update.env); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx index b34f82a775a..155dc3b8a0e 100644 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx +++ b/apps/web/src/components/ProviderUpdateEnvironmentRows.test.tsx @@ -6,6 +6,7 @@ import { ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import type { @@ -16,6 +17,7 @@ import type { const testState = vi.hoisted(() => ({ groups: [] as LocalEnvironmentUpdateGroup[], + isAnySettling: false, updateProvider: vi.fn(), })); @@ -41,6 +43,10 @@ const hooks = vi.hoisted(() => { nextIndex(); return factory(); }, + useEffect(effect: () => void | (() => void)): void { + nextIndex(); + effect(); + }, useMemoCache(size: number): unknown[] { const index = nextIndex(); if (!slots[index]) { @@ -76,6 +82,7 @@ vi.mock("react", async (importOriginal) => { return { ...actual, useCallback: hooks.useCallback, + useEffect: hooks.useEffect, useMemo: hooks.useMemo, useRef: hooks.useRef, useState: hooks.useState, @@ -97,7 +104,7 @@ vi.mock("~/state/use-atom-command", () => ({ vi.mock("./ProviderUpdateLaunchNotification.environments", () => ({ useLocalEnvironmentUpdateGroups: () => ({ groups: testState.groups, - isAnySettling: false, + isAnySettling: testState.isAnySettling, }), })); @@ -154,6 +161,7 @@ function deferred() { type RowElement = ReactElement<{ readonly status: ProviderUpdateRowStatus; + readonly canUpdate: boolean; readonly onUpdate: () => void; }>; @@ -176,6 +184,7 @@ describe("ProviderUpdateEnvironmentRows", () => { beforeEach(() => { vi.useFakeTimers(); hooks.reset(); + testState.isAnySettling = false; testState.updateProvider.mockReset(); const candidate = provider() as ProviderUpdateCandidate; testState.groups = [ @@ -183,8 +192,11 @@ describe("ProviderUpdateEnvironmentRows", () => { environmentId, label: "WSL", isPrimary: false, + connectionState: "ready", isSettling: false, candidates: [candidate], + oneClickCandidates: [candidate], + runnableCandidates: [candidate], providers: [candidate], }, ]; @@ -194,6 +206,254 @@ describe("ProviderUpdateEnvironmentRows", () => { vi.useRealTimers(); }); + it("does not expose or dispatch an update for settings-only candidates", () => { + testState.groups = testState.groups.map((group) => ({ + ...group, + oneClickCandidates: [], + runnableCandidates: [], + })); + + const row = renderRow(); + + expect(row.props.canUpdate).toBe(false); + row.props.onUpdate(); + expect(testState.updateProvider).not.toHaveBeenCalled(); + }); + + it("does not mark unattempted settings-only candidates as updated", async () => { + const runnable = testState.groups[0]!.candidates[0]!; + const settingsOnly = { + ...provider(), + instanceId: ProviderInstanceId.make("claude-wsl"), + driver: ProviderDriverKind.make("claudeAgent"), + versionAdvisory: { + ...provider().versionAdvisory!, + updateCommand: null, + canUpdate: false, + }, + } as ProviderUpdateCandidate; + testState.groups = testState.groups.map((group) => ({ + ...group, + candidates: [runnable, settingsOnly], + oneClickCandidates: [runnable], + runnableCandidates: [runnable], + providers: [runnable, settingsOnly], + })); + testState.updateProvider.mockResolvedValue( + AsyncResult.success({ providers: [provider("succeeded")] }), + ); + + renderRow().props.onUpdate(); + await flushPromises(); + + testState.groups = testState.groups.map((group) => ({ + ...group, + candidates: [settingsOnly], + oneClickCandidates: [], + runnableCandidates: [], + providers: [settingsOnly], + })); + const row = renderRow(); + + expect(row.props.status.kind).toBe("idle"); + expect(row.props.canUpdate).toBe(false); + }); + + it("shows sibling progress without allowing an idle representative to be redispatched", () => { + const candidate = provider() as ProviderUpdateCandidate; + const activeSibling = { + ...provider(), + instanceId: ProviderInstanceId.make("codex-work"), + updateState: { + status: "running" as const, + startedAt: "2099-01-01T00:00:00.000Z", + finishedAt: null, + message: "Updating provider.", + output: null, + }, + } as ProviderUpdateCandidate; + testState.groups = testState.groups.map((group) => ({ + ...group, + candidates: [candidate], + oneClickCandidates: [candidate], + runnableCandidates: [], + providers: [candidate, activeSibling], + })); + + const row = renderRow(); + + expect(row.props.status.kind).toBe("loading"); + expect(row.props.canUpdate).toBe(false); + }); + + it("does not let a sibling's success hide an outdated runnable target", () => { + const candidate = provider() as ProviderUpdateCandidate; + const successfulSibling = { + ...provider("succeeded"), + instanceId: ProviderInstanceId.make("codex-work"), + updateState: { + status: "succeeded" as const, + startedAt: "2099-01-01T00:00:00.000Z", + finishedAt: "2099-01-01T00:00:01.000Z", + message: "Provider updated.", + output: null, + }, + }; + testState.groups = testState.groups.map((group) => ({ + ...group, + candidates: [candidate], + oneClickCandidates: [candidate], + runnableCandidates: [candidate], + providers: [successfulSibling, candidate], + })); + + const row = renderRow(); + + expect(row.props.status.kind).toBe("idle"); + expect(row.props.canUpdate).toBe(true); + }); + + it("hides a transport error after its attempted target is no longer offered", async () => { + const runnable = testState.groups[0]!.candidates[0]!; + const settingsOnly = { + ...provider(), + instanceId: ProviderInstanceId.make("claude-wsl"), + driver: ProviderDriverKind.make("claudeAgent"), + versionAdvisory: { + ...provider().versionAdvisory!, + updateCommand: null, + canUpdate: false, + }, + } as ProviderUpdateCandidate; + testState.groups = testState.groups.map((group) => ({ + ...group, + candidates: [runnable, settingsOnly], + oneClickCandidates: [runnable], + runnableCandidates: [runnable], + providers: [runnable, settingsOnly], + })); + testState.updateProvider.mockRejectedValue(new Error("WebSocket closed")); + + renderRow().props.onUpdate(); + await flushPromises(); + expect(renderRow().props.status).toMatchObject({ kind: "failed", text: "WebSocket closed" }); + + testState.groups = testState.groups.map((group) => ({ + ...group, + candidates: [settingsOnly], + oneClickCandidates: [], + runnableCandidates: [], + providers: [settingsOnly], + })); + + expect(renderRow().props.status.kind).toBe("idle"); + }); + + it("notifies the host when no row remains to render", () => { + const onEmpty = vi.fn(); + testState.groups = testState.groups.map((group) => ({ + ...group, + candidates: [], + oneClickCandidates: [], + runnableCandidates: [], + providers: [], + })); + + hooks.beginRender(); + const output = ProviderUpdateEnvironmentRows({ onEmpty }); + + expect(output).toBeNull(); + expect(onEmpty).toHaveBeenCalledOnce(); + }); + + it.each(["connecting", "disconnected", "error"] as const)( + "keeps an empty interacted host open while an environment is %s", + async (connectionState) => { + const onEmpty = vi.fn(); + testState.updateProvider.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); + renderRow().props.onUpdate(); + await flushPromises(); + testState.isAnySettling = connectionState === "connecting"; + testState.groups = testState.groups.map((group) => ({ + ...group, + connectionState, + isSettling: connectionState === "connecting", + candidates: [], + oneClickCandidates: [], + runnableCandidates: [], + providers: [], + })); + + hooks.beginRender(); + const output = ProviderUpdateEnvironmentRows({ onEmpty }); + + expect(output).toBeNull(); + expect(onEmpty).not.toHaveBeenCalled(); + }, + ); + + it("ignores an unattempted disconnected candidate when closing an empty host", async () => { + const onEmpty = vi.fn(); + const attemptedGroup = testState.groups[0]!; + const unattemptedCandidate = { + ...provider(), + instanceId: ProviderInstanceId.make("codex-other-wsl"), + } as ProviderUpdateCandidate; + testState.groups = [ + attemptedGroup, + { + ...attemptedGroup, + environmentId: "env-unrelated" as EnvironmentId, + label: "Other WSL", + candidates: [unattemptedCandidate], + oneClickCandidates: [unattemptedCandidate], + runnableCandidates: [unattemptedCandidate], + providers: [unattemptedCandidate], + }, + ]; + testState.updateProvider.mockResolvedValue(AsyncResult.failure(Cause.interrupt())); + + renderRow().props.onUpdate(); + await flushPromises(); + + testState.groups = testState.groups.map((group) => ({ + ...group, + connectionState: group.environmentId === environmentId ? "ready" : "disconnected", + candidates: [], + oneClickCandidates: [], + runnableCandidates: [], + providers: group.environmentId === environmentId ? [provider("succeeded")] : [], + })); + + hooks.beginRender(); + const output = ProviderUpdateEnvironmentRows({ onEmpty }); + + expect(output).toBeNull(); + expect(onEmpty).toHaveBeenCalledOnce(); + }); + + it("clears a lost-response error once a ready snapshot confirms the target is current", async () => { + const onEmpty = vi.fn(); + testState.updateProvider.mockRejectedValue(new Error("WebSocket closed")); + + renderRow().props.onUpdate(); + await flushPromises(); + expect(renderRow().props.status.kind).toBe("failed"); + + testState.groups = testState.groups.map((group) => ({ + ...group, + candidates: [], + oneClickCandidates: [], + runnableCandidates: [], + providers: [provider("succeeded")], + })); + hooks.beginRender(); + const output = ProviderUpdateEnvironmentRows({ onEmpty }); + + expect(output).toBeNull(); + expect(onEmpty).toHaveBeenCalledOnce(); + }); + it("keeps a successor pending when an expired request resolves late, then shows its success", async () => { const firstRequest = deferred>>(); diff --git a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx index 28242b88fd3..d11ba1257a1 100644 --- a/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx +++ b/apps/web/src/components/ProviderUpdateEnvironmentRows.tsx @@ -1,5 +1,5 @@ import { CheckIcon } from "lucide-react"; -import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EnvironmentId, ServerProvider } from "@t3tools/contracts"; import { isAtomCommandInterrupted, @@ -16,10 +16,12 @@ import { firstRejectedProviderUpdateMessage, getProviderUpdateProgressToastView, getProviderUpdateSidebarPillView, + isProviderUpdateActive, isTerminalProviderUpdatePhase, resolveEnvironmentUpdateRowStatus, type LocalEnvironmentUpdateGroup, type LocalProviderUpdateOutcome, + type ProviderUpdateCandidate, type ProviderUpdateRowStatus, type ProviderUpdateRowStatusKind, type ProviderUpdateToastView, @@ -32,6 +34,25 @@ type ProviderUpdateCommandResult = AtomCommandResult< unknown >; +interface EnvironmentUpdateResult { + readonly view: ProviderUpdateToastView; + readonly attemptedCandidateKeys: ReadonlySet; +} + +interface EnvironmentUpdateError { + readonly message: string; + readonly attemptedCandidateKeys: ReadonlySet; +} + +function providerUpdateResultKey(candidate: ProviderUpdateCandidate): string { + const advisory = candidate.versionAdvisory; + return JSON.stringify([ + candidate.driver, + advisory.latestVersion, + advisory.updateActionKey ?? advisory.updateCommand, + ]); +} + /** * Map one targeted instance's update command result into the settled-outcome * shape the multi-backend reducers consume: a non-interrupted failure becomes a @@ -109,10 +130,12 @@ function rowToneClass(kind: ProviderUpdateRowStatusKind): string { function EnvironmentUpdateRow({ group, status, + canUpdate, onUpdate, }: { readonly group: LocalEnvironmentUpdateGroup; readonly status: ProviderUpdateRowStatus; + readonly canUpdate: boolean; readonly onUpdate: () => void; }) { let trailing: ReactNode; @@ -125,18 +148,18 @@ function EnvironmentUpdateRow({ break; case "failed": case "unchanged": - trailing = ( + trailing = canUpdate ? ( - ); + ) : null; break; default: - trailing = ( + trailing = canUpdate ? ( - ); + ) : null; break; } @@ -153,14 +176,17 @@ function EnvironmentUpdateRow({ /** * The launch popover's body when WSL is present: one row per local environment - * (Windows + WSL), each with its own "update all" trigger that targets only - * that environment's backend. + * (Windows + WSL). Each row targets only its own backend and exposes an update + * trigger only for candidates whose actions can be safely dispatched. */ export function ProviderUpdateEnvironmentRows({ onInteract, + onEmpty, }: { /** Called the first time the user triggers an update, so the host can stop refreshing the prompt. */ readonly onInteract?: () => void; + /** Called once no update, progress, or result row remains for the host toast to display. */ + readonly onEmpty?: () => void; }) { const { groups } = useLocalEnvironmentUpdateGroups(); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { @@ -191,12 +217,17 @@ export function ProviderUpdateEnvironmentRows({ const [pendingEnvironments, setPendingEnvironments] = useState>( () => new Set(), ); - const [errorByEnvironment, setErrorByEnvironment] = useState>( - () => new Map(), - ); + const [errorByEnvironment, setErrorByEnvironment] = useState< + ReadonlyMap + >(() => new Map()); const [resultByEnvironment, setResultByEnvironment] = useState< - ReadonlyMap + ReadonlyMap >(() => new Map()); + // Remember only environments where this mounted prompt actually accepted an + // update attempt. If an interrupted request loses its row while that backend + // reconnects, this keeps the prompt alive until its snapshot is authoritative + // again without letting an unrelated offline candidate strand the prompt. + const attemptedEnvironmentIdsRef = useRef>(new Set()); const clearPending = useCallback((environmentId: EnvironmentId) => { setPendingEnvironments((previous) => { @@ -212,20 +243,22 @@ export function ProviderUpdateEnvironmentRows({ const handleUpdate = useCallback( async (environmentId: EnvironmentId) => { const group = groupByEnvironment.get(environmentId); - if (!group || group.candidates.length === 0) { + if (!group || group.runnableCandidates.length === 0) { return; } if (inFlightEnvironmentsRef.current.has(environmentId)) { return; } inFlightEnvironmentsRef.current.add(environmentId); + attemptedEnvironmentIdsRef.current.add(environmentId); const requestVersion = (requestVersionRef.current.get(environmentId) ?? 0) + 1; requestVersionRef.current.set(environmentId, requestVersion); const isCurrentRequest = () => requestVersionRef.current.get(environmentId) === requestVersion; onInteract?.(); - const providerCount = group.candidates.length; - const targets = group.candidates.map((candidate) => ({ + const providerCount = group.runnableCandidates.length; + const attemptedCandidateKeys = new Set(group.runnableCandidates.map(providerUpdateResultKey)); + const targets = group.runnableCandidates.map((candidate) => ({ driver: candidate.driver, instanceId: candidate.instanceId, })); @@ -261,7 +294,10 @@ export function ProviderUpdateEnvironmentRows({ inFlightEnvironmentsRef.current.delete(environmentId); clearPending(environmentId); setErrorByEnvironment((previous) => - new Map(previous).set(environmentId, "Update timed out — try again."), + new Map(previous).set(environmentId, { + message: "Update timed out — try again.", + attemptedCandidateKeys, + }), ); }, PENDING_EXPIRY_MS); try { @@ -306,17 +342,20 @@ export function ProviderUpdateEnvironmentRows({ }); if (results.length === 0) { setErrorByEnvironment((previous) => - new Map(previous).set( - environmentId, - "This environment isn’t connected — try again once it reconnects.", - ), + new Map(previous).set(environmentId, { + message: "This environment isn’t connected — try again once it reconnects.", + attemptedCandidateKeys, + }), ); return; } const rejectedMessage = firstRejectedProviderUpdateMessage(results); if (rejectedMessage) { setErrorByEnvironment((previous) => - new Map(previous).set(environmentId, rejectedMessage), + new Map(previous).set(environmentId, { + message: rejectedMessage, + attemptedCandidateKeys, + }), ); return; } @@ -334,15 +373,17 @@ export function ProviderUpdateEnvironmentRows({ // the live per-environment provider state (pill) plus the pending expiry // drive the row, so it self-heals to whatever the backend actually did. if (isTerminalProviderUpdatePhase(view.phase)) { - setResultByEnvironment((previous) => new Map(previous).set(environmentId, view)); + setResultByEnvironment((previous) => + new Map(previous).set(environmentId, { view, attemptedCandidateKeys }), + ); } } catch (error) { if (isCurrentRequest()) { setErrorByEnvironment((previous) => - new Map(previous).set( - environmentId, - error instanceof Error ? error.message : "Provider update failed.", - ), + new Map(previous).set(environmentId, { + message: error instanceof Error ? error.message : "Provider update failed.", + attemptedCandidateKeys, + }), ); } } finally { @@ -359,25 +400,80 @@ export function ProviderUpdateEnvironmentRows({ ); const rows = groups - .map((group) => ({ - group, - status: resolveEnvironmentUpdateRowStatus({ + .map((group) => { + const storedResult = resultByEnvironment.get(group.environmentId); + const storedError = errorByEnvironment.get(group.environmentId); + const hasAttemptedCandidate = + storedResult !== undefined && + group.candidates.some((candidate) => + storedResult.attemptedCandidateKeys.has(providerUpdateResultKey(candidate)), + ); + const hasUnattemptedCandidate = + storedResult !== undefined && + group.candidates.some( + (candidate) => + !storedResult.attemptedCandidateKeys.has(providerUpdateResultKey(candidate)), + ); + const oneClickCandidateKeys = new Set(group.oneClickCandidates.map(providerUpdateResultKey)); + const hasSettingsOnlyCandidate = group.candidates.some( + (candidate) => !oneClickCandidateKeys.has(providerUpdateResultKey(candidate)), + ); + const pillProviders = group.oneClickCandidates.map( + (candidate) => + group.providers.find( + (provider) => provider.driver === candidate.driver && isProviderUpdateActive(provider), + ) ?? candidate, + ); + const pill = getProviderUpdateSidebarPillView(pillProviders, { + visibleAfterIso: visibleAfterIsoRef.current, + }); + const hasErrorTarget = + storedError !== undefined && + group.candidates.some((candidate) => + storedError.attemptedCandidateKeys.has(providerUpdateResultKey(candidate)), + ); + const visibleError = + storedError !== undefined && group.connectionState === "ready" && !hasErrorTarget + ? undefined + : storedError?.message; + const visibleResult = + (group.candidates.length > 0 && storedResult !== undefined && !hasAttemptedCandidate) || + (storedResult?.view.phase === "succeeded" && hasUnattemptedCandidate) + ? undefined + : storedResult?.view; + const visiblePill = pill?.tone === "success" && hasSettingsOnlyCandidate ? null : pill; + + return { group, - error: errorByEnvironment.get(group.environmentId), - result: resultByEnvironment.get(group.environmentId), - // Derive the live pill from the candidates this row is actually - // tracking, not every provider in the environment. Otherwise an - // unrelated provider's recent success (or one candidate succeeding while - // another was interrupted) makes the pill report success and hides the - // Update action for candidates that are still outdated. - pill: getProviderUpdateSidebarPillView(group.candidates, { - visibleAfterIso: visibleAfterIsoRef.current, + status: resolveEnvironmentUpdateRowStatus({ + group, + error: visibleError, + result: visibleResult, + // Derive the live pill from the candidates this row is actually + // tracking, not every provider in the environment. Otherwise an + // unrelated provider's recent success (or one candidate succeeding while + // another was interrupted) makes the pill report success and hides the + // Update action for candidates that are still outdated. + pill: visiblePill, + isPending: pendingEnvironments.has(group.environmentId), }), - isPending: pendingEnvironments.has(group.environmentId), - }), - })) + }; + }) .filter(({ group, status }) => group.candidates.length > 0 || status.kind !== "idle"); + useEffect(() => { + // Empty provider snapshots from connecting, disconnected, or failed + // backends are not authoritative. Keep an interacted toast mounted until + // every backend that contributed an update or attempt is ready; unrelated + // offline environments must not strand an otherwise-empty toast. + const attemptedGroups = groups.filter((group) => + attemptedEnvironmentIdsRef.current.has(group.environmentId), + ); + if (rows.length === 0 && attemptedGroups.every((group) => group.connectionState === "ready")) { + onEmpty?.(); + } + }, [groups, onEmpty, rows.length]); + if (rows.length === 0) { return null; } @@ -389,6 +485,7 @@ export function ProviderUpdateEnvironmentRows({ key={group.environmentId} group={group} status={status} + canUpdate={group.runnableCandidates.length > 0} onUpdate={() => handleUpdate(group.environmentId)} /> ))} diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 223960f8314..e4bc9c21764 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -54,6 +54,7 @@ function provider(input: { readonly latestVersion?: string | null; readonly canUpdate?: boolean; readonly updateCommand?: string | null; + readonly updateActionKey?: string; readonly updateState?: ServerProvider["updateState"]; readonly advisoryStatus?: NonNullable["status"]; }): ServerProvider { @@ -74,6 +75,7 @@ function provider(input: { currentVersion: input.version ?? "1.0.0", latestVersion: "latestVersion" in input ? input.latestVersion : "1.1.0", updateCommand: "updateCommand" in input ? input.updateCommand : "npm install -g provider", + ...("updateActionKey" in input ? { updateActionKey: input.updateActionKey } : {}), canUpdate: input.canUpdate ?? true, checkedAt, message: "Update available.", @@ -146,6 +148,31 @@ describe("provider update launch notification logic", () => { ).toBe(false); }); + it("disables one-click updates when provider instances share a command but run different actions", () => { + const candidate = updateCandidate({ + driver: driver("codex"), + instanceId: instanceId("codex_personal"), + latestVersion: "0.143.0", + updateCommand: "codex update", + updateActionKey: "codex-native /home/me/.codex/packages/standalone/current/bin/codex update", + }); + + expect( + canOneClickUpdateProviderCandidate(candidate, [ + candidate, + provider({ + driver: driver("codex"), + instanceId: instanceId("codex_work"), + latestVersion: "0.143.0", + canUpdate: true, + updateCommand: "codex update", + updateActionKey: + "codex-native /home/me/work-codex/packages/standalone/current/bin/codex update", + }), + ]), + ).toBe(false); + }); + it("keeps one-click updates enabled when sibling instances are already current", () => { const candidate = updateCandidate({ driver: driver("claudeAgent"), @@ -200,6 +227,27 @@ describe("provider update launch notification logic", () => { expect(canOneClickUpdateProviderCandidate(candidate, [candidate])).toBe(false); }); + it("blocks a shared driver action while a sibling instance is already updating", () => { + const candidate = updateCandidate({ + driver: driver("codex"), + instanceId: instanceId("codex"), + }); + const activeSibling = provider({ + driver: driver("codex"), + instanceId: instanceId("codex_work"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }); + + expect(hasOneClickUpdateProviderCandidate(candidate, [candidate, activeSibling])).toBe(true); + expect(canOneClickUpdateProviderCandidate(candidate, [candidate, activeSibling])).toBe(false); + }); + it("builds a notification key from provider latest versions", () => { const codex = updateCandidate({ driver: driver("codex"), @@ -530,6 +578,29 @@ describe("provider update launch notification logic", () => { }); }); + it("shows a non-default instance's active update ahead of an idle default instance", () => { + const view = getProviderUpdateSidebarPillView([ + provider({ driver: driver("codex"), instanceId: instanceId("codex") }), + provider({ + driver: driver("codex"), + instanceId: instanceId("codex_work"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }), + ]); + + expect(view).toMatchObject({ + key: "loading:codex:running", + tone: "loading", + title: "Updating Codex", + }); + }); + it("uses the provider name for single failed sidebar pill updates", () => { const view = getProviderUpdateSidebarPillView( [ @@ -860,7 +931,7 @@ describe("provider update launch notification logic", () => { providers: input.providers, }); - it("groups each environment's outdated one-click candidates", () => { + it("groups each environment's outdated candidates and safe one-click targets", () => { const result = buildLocalEnvironmentUpdateGroups([ environment({ environmentId: "env-windows", @@ -878,6 +949,97 @@ describe("provider update launch notification logic", () => { expect(result.isAnySettling).toBe(false); expect(result.groups.map((group) => group.label)).toEqual(["Windows", "WSL"]); expect(result.groups.every((group) => group.candidates.length === 1)).toBe(true); + expect(result.groups.every((group) => group.oneClickCandidates.length === 1)).toBe(true); + expect(result.groups.every((group) => group.runnableCandidates.length === 1)).toBe(true); + }); + + it("keeps settings-only updates visible without creating a runnable target", () => { + const { groups } = buildLocalEnvironmentUpdateGroups([ + environment({ + environmentId: "env-wsl", + label: "WSL", + providers: [ + provider({ + driver: driver("codex"), + instanceId: instanceId("codex_personal"), + latestVersion: "0.143.0", + updateCommand: "codex update", + updateActionKey: + "codex-native /home/me/.codex/packages/standalone/current/bin/codex update", + }), + provider({ + driver: driver("codex"), + instanceId: instanceId("codex_work"), + latestVersion: "0.143.0", + updateCommand: "codex update", + updateActionKey: + "codex-native /home/me/work-codex/packages/standalone/current/bin/codex update", + }), + ], + }), + ]); + + expect(groups[0]?.candidates).toHaveLength(1); + expect(groups[0]?.oneClickCandidates).toEqual([]); + expect(groups[0]?.runnableCandidates).toEqual([]); + expect(environmentGroupsWithUpdates(groups).map((group) => group.environmentId)).toEqual([ + "env-wsl", + ]); + expect(localEnvironmentUpdateNotificationKey(groups)).toBe("env-wsl=codex:0.143.0"); + }); + + it("keeps active update targets available for live progress but not redispatch", () => { + const active = provider({ + driver: driver("codex"), + instanceId: instanceId("codex_wsl"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }); + const { groups } = buildLocalEnvironmentUpdateGroups([ + environment({ + environmentId: "env-wsl", + label: "WSL", + providers: [active], + }), + ]); + + expect(groups[0]?.candidates).toHaveLength(1); + expect(groups[0]?.oneClickCandidates).toHaveLength(1); + expect(groups[0]?.runnableCandidates).toEqual([]); + }); + + it("does not expose an idle representative while a same-driver sibling is active", () => { + const idleDefault = provider({ + driver: driver("codex"), + instanceId: instanceId("codex"), + }); + const activeSibling = provider({ + driver: driver("codex"), + instanceId: instanceId("codex_work"), + updateState: { + status: "running", + startedAt: checkedAt, + finishedAt: null, + message: "Updating provider.", + output: null, + }, + }); + const { groups } = buildLocalEnvironmentUpdateGroups([ + environment({ + environmentId: "env-wsl", + label: "WSL", + providers: [idleDefault, activeSibling], + }), + ]); + + expect(groups[0]?.candidates).toHaveLength(1); + expect(groups[0]?.oneClickCandidates).toHaveLength(1); + expect(groups[0]?.runnableCandidates).toEqual([]); }); it("flags settling while a secondary backend is still connecting", () => { @@ -1013,8 +1175,11 @@ describe("provider update launch notification logic", () => { environmentId: "env-wsl" as EnvironmentId, label: "WSL", isPrimary: false, + connectionState: "ready", isSettling: false, candidates: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], + oneClickCandidates: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], + runnableCandidates: [updateCandidate({ driver: driver("codex"), latestVersion: "1.1.0" })], providers: [], }; const runningResult: ProviderUpdateToastView = { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 55999d2a31d..73df1343b3d 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -165,7 +165,7 @@ export function hasOneClickUpdateProviderCandidate( return false; } - const updateCommands = new Set(); + const updateActionKeys = new Set(); for (const provider of driverProviders) { if (!isProviderUpdateCandidate(provider)) { continue; @@ -174,10 +174,10 @@ export function hasOneClickUpdateProviderCandidate( if (!advisory || advisory.canUpdate !== true || advisory.updateCommand === null) { return false; } - updateCommands.add(advisory.updateCommand); + updateActionKeys.add(advisory.updateActionKey ?? advisory.updateCommand); } - return updateCommands.size === 1; + return updateActionKeys.size === 1; } export function canOneClickUpdateProviderCandidate( @@ -185,7 +185,9 @@ export function canOneClickUpdateProviderCandidate( providers: ReadonlyArray, ): boolean { return ( - !isProviderUpdateActive(candidate) && hasOneClickUpdateProviderCandidate(candidate, providers) + !providers.some( + (provider) => provider.driver === candidate.driver && isProviderUpdateActive(provider), + ) && hasOneClickUpdateProviderCandidate(candidate, providers) ); } @@ -409,8 +411,12 @@ export function getProviderUpdateSidebarPillView( providers: ReadonlyArray, options?: ProviderUpdateSidebarPillOptions, ): ProviderUpdateSidebarPillView | null { + // Check activity before choosing one representative instance per driver. A + // shared driver update may be running on a non-default instance while the + // default instance remains idle; preferring the default first would hide the + // live update and allow the same driver action to be queued again. + const activeProviders = dedupeProvidersByDriver(providers.filter(isProviderUpdateActive)); const dedupedProviders = dedupeProvidersByDriver(providers); - const activeProviders = dedupedProviders.filter(isProviderUpdateActive); if (activeProviders.length > 0) { const activeProvider = activeProviders[0]!; const activeProviderName = @@ -709,39 +715,55 @@ export interface LocalEnvironmentUpdateGroup { readonly environmentId: EnvironmentId; readonly label: string; readonly isPrimary: boolean; + /** Whether this backend's provider snapshot is authoritative right now. */ + readonly connectionState: EnvironmentUpdateConnectionState; /** True while this environment's backend is still connecting (e.g. WSL booting). */ readonly isSettling: boolean; - /** Outdated, one-click-updatable providers in this environment. */ + /** Outdated driver candidates, including settings-only updates. */ readonly candidates: ProviderUpdateCandidate[]; + /** Outdated providers whose update actions can be safely grouped. */ + readonly oneClickCandidates: ProviderUpdateCandidate[]; + /** Group-safe providers that are not already queued or running. */ + readonly runnableCandidates: ProviderUpdateCandidate[]; /** Full provider list for this environment, used to derive live update progress. */ readonly providers: ReadonlyArray; } /** - * Build one update group per local environment, pairing each environment's - * outdated one-click candidates with its own provider list, and report whether - * any environment is still settling (so the caller can defer the popover). + * Build one update group per local environment, retaining each outdated driver + * candidate for notification while separately identifying the candidates that + * are safe to update with one click. Also report whether any environment is + * still settling (so the caller can defer the popover). */ export function buildLocalEnvironmentUpdateGroups( environments: ReadonlyArray, ): { groups: LocalEnvironmentUpdateGroup[]; isAnySettling: boolean } { - const groups = environments.map((environment) => ({ - environmentId: environment.environmentId, - label: environment.label, - isPrimary: environment.isPrimary, - isSettling: environment.connectionState === "connecting", - candidates: collectProviderUpdateCandidates(environment.providers).filter((candidate) => - canOneClickUpdateProviderCandidate(candidate, environment.providers), - ), - providers: environment.providers, - })); + const groups = environments.map((environment) => { + const candidates = collectProviderUpdateCandidates(environment.providers); + const oneClickCandidates = candidates.filter((candidate) => + hasOneClickUpdateProviderCandidate(candidate, environment.providers), + ); + return { + environmentId: environment.environmentId, + label: environment.label, + isPrimary: environment.isPrimary, + connectionState: environment.connectionState, + isSettling: environment.connectionState === "connecting", + candidates, + oneClickCandidates, + runnableCandidates: oneClickCandidates.filter((candidate) => + canOneClickUpdateProviderCandidate(candidate, environment.providers), + ), + providers: environment.providers, + }; + }); const isAnySettling = environments.some( (environment) => environment.connectionState === "connecting", ); return { groups, isAnySettling }; } -/** Groups that actually have a one-click update available, in display order (primary first). */ +/** Groups that have an update available, including settings-only updates. */ export function environmentGroupsWithUpdates( groups: ReadonlyArray, ): LocalEnvironmentUpdateGroup[] { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 2292a2c4510..bf97a0b98ca 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -94,6 +94,11 @@ function ProviderUpdateEnvironmentsNotification() { () => collectProviderUpdateCandidates(updateGroups.flatMap((group) => group.candidates)), [updateGroups], ); + const runnableCandidateUnion = useMemo( + () => + collectProviderUpdateCandidates(updateGroups.flatMap((group) => group.runnableCandidates)), + [updateGroups], + ); // Defer while any local backend is still connecting, up to the grace period. const [settleGraceElapsed, setSettleGraceElapsed] = useState(false); @@ -116,6 +121,14 @@ function ProviderUpdateEnvironmentsNotification() { void navigate({ to: "/settings/providers" }); }, [navigate]); + const closeEmptyPrompt = useCallback(() => { + const active = activeToastRef.current; + if (active !== null) { + toastManager.close(active.toastId); + activeToastRef.current = null; + } + }, []); + useEffect(() => { // Whether a fresh prompt can actually be shown for the current update set. const canShowPrompt = @@ -162,13 +175,14 @@ function ProviderUpdateEnvironmentsNotification() { type: "warning", title: getProviderUpdateInitialToastView({ updateProviders: candidateUnion, - oneClickProviders: candidateUnion, + oneClickProviders: runnableCandidateUnion, }).title, description: ( { hasInteractedRef.current = true; }} + onEmpty={closeEmptyPrompt} /> ), timeout: 0, @@ -189,9 +203,11 @@ function ProviderUpdateEnvironmentsNotification() { notificationKey, isGated, candidateUnion, + runnableCandidateUnion, dismissedNotificationKeys, dismissNotificationKey, openProviderSettings, + closeEmptyPrompt, ]); return null; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..42a8f485833 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -128,6 +128,7 @@ export const ServerProviderVersionAdvisory = Schema.Struct({ currentVersion: Schema.NullOr(TrimmedNonEmptyString), latestVersion: Schema.NullOr(TrimmedNonEmptyString), updateCommand: Schema.NullOr(TrimmedNonEmptyString), + updateActionKey: Schema.optionalKey(TrimmedNonEmptyString), canUpdate: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), checkedAt: Schema.NullOr(IsoDateTime), message: Schema.NullOr(TrimmedNonEmptyString),