diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 15d7a1ff021..b449a88c3e1 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -44,7 +44,8 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { enrichProviderSnapshotWithVersionAdvisory, - makePackageManagedProviderMaintenanceResolver, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; import { @@ -60,12 +61,18 @@ import { const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); -const UPDATE = makePackageManagedProviderMaintenanceResolver({ - provider: DRIVER_KIND, - npmPackageName: "@openai/codex", - homebrewFormula: "codex", - nativeUpdate: null, -}); +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (options) => + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: "@openai/codex", + updateExecutable: options?.binaryPath?.trim() || "codex", + updateArgs: ["update"], + ...(options?.env ? { updateEnv: options.env } : {}), + updateLockKey: "codex-self-update", + updateMinimumVersion: "0.126.0", + }), +}; /** * Services the driver needs to materialize an instance. Surfaced as the diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 8937844f613..7866af8c30c 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -68,6 +68,16 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( updateLockKey: "static-tool", }), ); +const selfUpdatingTool = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: driver("selfUpdatingTool"), + packageName: "@example/self-updating-tool", + updateExecutable: "self-updating-tool", + updateArgs: ["update"], + updateLockKey: "self-updating-tool", + updateMinimumVersion: "2.0.0", + }), +); const installedPackageToolProvider: ServerProvider = { instanceId: ProviderInstanceId.make("packageTool"), driver: driver("packageTool"), @@ -198,6 +208,51 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }); }); + it("keeps self-update guidance manual below the supported provider version", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("selfUpdatingTool"), + currentVersion: "1.9.9", + latestVersion: "2.1.0", + maintenanceCapabilities: selfUpdatingTool.resolve(), + }), + ).toMatchObject({ + status: "behind_latest", + updateCommand: null, + canUpdate: false, + message: "Update this provider manually, then refresh provider status.", + }); + }); + + it("enables self-updates at the minimum supported provider version", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("selfUpdatingTool"), + currentVersion: "2.0.0", + latestVersion: "2.1.0", + maintenanceCapabilities: selfUpdatingTool.resolve(), + }), + ).toMatchObject({ + status: "behind_latest", + updateCommand: "self-updating-tool update", + canUpdate: true, + }); + }); + + it("keeps self-update guidance manual when the provider version is not semver", () => { + expect( + createProviderVersionAdvisory({ + driver: driver("selfUpdatingTool"), + currentVersion: "unknown", + latestVersion: "2.1.0", + maintenanceCapabilities: selfUpdatingTool.resolve(), + }), + ).toMatchObject({ + updateCommand: null, + canUpdate: false, + }); + }); + it.effect( "switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin", () => diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 8645f9f943c..8e97a3c4dd9 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -3,7 +3,7 @@ import { type ServerProvider, type ServerProviderVersionAdvisory, } from "@t3tools/contracts"; -import { compareSemverVersions } from "@t3tools/shared/semver"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; import { resolveCommandPath } from "@t3tools/shared/shell"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; @@ -47,7 +47,9 @@ export interface ProviderMaintenanceCommandAction { readonly command: string; readonly executable: string; readonly args: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; readonly lockKey: string; + readonly minimumVersion?: string; } export interface ProviderMaintenanceCapabilityResolutionOptions { @@ -99,7 +101,9 @@ export function makeProviderMaintenanceCapabilities(input: { readonly packageName: string | null; readonly updateExecutable: string | null; readonly updateArgs: ReadonlyArray; + readonly updateEnv?: NodeJS.ProcessEnv; readonly updateLockKey: string | null; + readonly updateMinimumVersion?: string; }): ProviderMaintenanceCapabilities { const update = input.updateExecutable === null || input.updateLockKey === null @@ -108,7 +112,9 @@ export function makeProviderMaintenanceCapabilities(input: { command: [input.updateExecutable, ...input.updateArgs].join(" "), executable: input.updateExecutable, args: input.updateArgs, + ...(input.updateEnv ? { env: input.updateEnv } : {}), lockKey: input.updateLockKey, + ...(input.updateMinimumVersion ? { minimumVersion: input.updateMinimumVersion } : {}), }; return { provider: input.provider, @@ -408,18 +414,39 @@ export function createProviderVersionAdvisory(input: { currentVersion: input.currentVersion, latestVersion, }); + const canUpdate = canRunProviderMaintenanceUpdate(capabilities, input.currentVersion); return { status: advisory.status, currentVersion: input.currentVersion, latestVersion, - updateCommand: capabilities.update?.command ?? null, - canUpdate: capabilities.update !== null, + updateCommand: canUpdate ? (capabilities.update?.command ?? null) : null, + canUpdate, checkedAt: input.checkedAt ?? null, - message: advisory.message, + message: + advisory.status === "behind_latest" && !canUpdate + ? "Update this provider manually, then refresh provider status." + : advisory.message, }; } +export function canRunProviderMaintenanceUpdate( + maintenanceCapabilities: ProviderMaintenanceCapabilities, + currentVersion: string | null | undefined, +): boolean { + const update = maintenanceCapabilities.update; + if (!update) { + return false; + } + if (!update.minimumVersion) { + return true; + } + if (!currentVersion || !parseSemver(currentVersion) || !parseSemver(update.minimumVersion)) { + return false; + } + return compareSemverVersions(currentVersion, update.minimumVersion) >= 0; +} + const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) { const client = yield* HttpClient.HttpClient; const request = HttpClientRequest.get( diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 641c9b52e56..8eb4e12f87d 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -129,6 +129,10 @@ function mockSpawnerLayer( handler: ( command: string, args: ReadonlyArray, + options: { + readonly env?: Record; + readonly extendEnv?: boolean; + }, ) => { readonly stdout?: string; readonly stderr?: string; @@ -142,8 +146,14 @@ function mockSpawnerLayer( const childProcess = command as unknown as { readonly command: string; readonly args: ReadonlyArray; + readonly options: { + readonly env?: Record; + readonly extendEnv?: boolean; + }; }; - return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); + return Effect.succeed( + mockHandle(handler(childProcess.command, childProcess.args, childProcess.options)), + ); }), ); } @@ -299,6 +309,108 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("requires manual updates below a provider self-update minimum version", () => { + const calls: Array<{ command: string; args: ReadonlyArray }> = []; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry({ + ...baseProvider, + version: "0.125.0", + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "codex", + updateArgs: ["update"], + updateLockKey: "codex-self-update", + updateMinimumVersion: "0.126.0", + }), + ), + }); + + const exit = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit); + assert(Exit.isFailure(exit)); + const error = Cause.squash(exit.cause); + assert(isServerProviderUpdateError(error)); + assert.strictEqual(error.reason, "This installed provider version must be updated manually."); + assert.deepStrictEqual(calls, []); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.126.0"), + mockSpawnerLayer((command, args) => { + calls.push({ command, args }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + + it.effect("preserves the provider environment when running a self-update", () => { + const calls: Array<{ + command: string; + args: ReadonlyArray; + env: Record | undefined; + extendEnv: boolean | undefined; + }> = []; + const updateEnv = { + PATH: "/custom/codex/bin", + CODEX_HOME: "/custom/codex/home", + }; + return Effect.gen(function* () { + const { registry } = yield* makeRegistry({ + ...baseProvider, + version: "0.126.0", + }); + const updater = yield* makeTestRunner({ + ...registry, + getProviderMaintenanceCapabilitiesForInstance: () => + Effect.succeed( + makeProviderMaintenanceCapabilities({ + provider: CODEX_DRIVER, + packageName: "@openai/codex", + updateExecutable: "codex", + updateArgs: ["update"], + updateEnv, + updateLockKey: "codex-self-update", + updateMinimumVersion: "0.126.0", + }), + ), + }); + + yield* updater.updateProvider(CODEX_DRIVER); + assert.deepStrictEqual(calls, [ + { + command: "codex", + args: ["update"], + env: updateEnv, + extendEnv: true, + }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + NonWindowsPlatform, + latestVersionHttpClient("0.126.0"), + mockSpawnerLayer((command, args, options) => { + calls.push({ + command, + args, + env: options.env, + extendEnv: options.extendEnv, + }); + return { stdout: "updated" }; + }), + ), + ), + ); + }); + it.effect( "runs update commands through Effect ChildProcess when no test runner is injected", () => { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 3c114dd83d8..1f8215cf491 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -23,7 +23,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { makeProviderMaintenanceCommandCoordinator } from "./providerMaintenanceCommandCoordinator.ts"; -import { enrichProviderSnapshotWithVersionAdvisory } from "./providerMaintenance.ts"; +import { + canRunProviderMaintenanceUpdate, + enrichProviderSnapshotWithVersionAdvisory, +} from "./providerMaintenance.ts"; import type { ProviderMaintenanceCapabilities } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); @@ -73,6 +76,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly command: string; readonly args: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { @@ -81,9 +85,18 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR // which a bare ChildProcess.spawn cannot launch (spawn npm ENOENT); // resolveSpawnCommand finds the real `.cmd` and routes it through the // shell. On Linux/macOS (incl. the WSL backend) this is a no-op. - const resolved = yield* resolveSpawnCommand(input.command, input.args); + const resolved = yield* resolveSpawnCommand( + input.command, + input.args, + input.env ? { env: input.env, extendEnv: true } : {}, + ); const child = yield* input.spawner - .spawn(ChildProcess.make(resolved.command, resolved.args, { shell: resolved.shell })) + .spawn( + ChildProcess.make(resolved.command, resolved.args, { + ...(input.env ? { env: input.env, extendEnv: true } : {}), + shell: resolved.shell, + }), + ) .pipe( Effect.mapError( (cause) => @@ -201,11 +214,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?: NodeJS.ProcessEnv, + ) => runProviderMaintenanceCommandWithSpawner({ spawner, command, args, + ...(env ? { env } : {}), }); const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ makeAlreadyRunningError: () => @@ -304,6 +322,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { reason: "This provider does not support one-click updates.", }); } + const currentProvider = (yield* providerRegistry.getProviders).find( + (candidate) => candidate.driver === provider && candidate.instanceId === instanceId, + ); + if (!canRunProviderMaintenanceUpdate(capabilities, currentProvider?.version)) { + return yield* new ServerProviderUpdateError({ + provider, + reason: "This installed provider version must be updated manually.", + }); + } const setUpdateState = (state: ServerProviderUpdateState | null) => providerRegistry.setProviderMaintenanceActionState({ @@ -339,7 +366,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(