diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 7e2f2f8864b6..bac34db452fd 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -8,7 +8,10 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -17,6 +20,11 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; +import { + createProviderVersionAdvisory, + ProviderVersionCache, + resolveLatestProviderVersion, +} from "../providerMaintenance.ts"; import { CodexDriver } from "./CodexDriver.ts"; const testLayer = ServerConfig.layerTest(process.cwd(), { @@ -218,4 +226,147 @@ it.layer(testLayer)("CodexDriver", (it) => { ), ); } + + it.effect.each([ + { + name: "conventional shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "custom data directory", + dataRoot: "custom-tool-data", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "renamed configured command", + dataRoot: "mise", + commandName: "custom-codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "outdated provider", + dataRoot: "mise", + commandName: "codex", + version: "0.153.3", + nodeFirst: false, + }, + { + name: "npm before shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: true, + }, + ])( + "does not mistake Homebrew mise for Codex's installer: $name", + (fixture) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }); + const brewPrefix = NodePath.join(tempDir, "homebrew"); + const brewPath = NodePath.join(brewPrefix, "bin", "brew"); + const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise"); + const shimDir = NodePath.join(tempDir, fixture.dataRoot, "shims"); + const npmPrefix = NodePath.join(tempDir, "mise", "installs", "node", "24.13.0"); + const npmBin = NodePath.join(npmPrefix, "bin"); + const npmEntry = NodePath.join( + npmPrefix, + "lib", + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + for (const file of [brewPath, misePath, npmEntry]) { + yield* fs.makeDirectory(NodePath.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, "#!/bin/sh\n"); + yield* fs.chmod(file, 0o755); + } + yield* fs.makeDirectory(shimDir, { recursive: true }); + yield* fs.makeDirectory(npmBin, { recursive: true }); + yield* fs.symlink(misePath, NodePath.join(shimDir, fixture.commandName)); + yield* fs.symlink(npmEntry, NodePath.join(npmBin, fixture.commandName)); + const lookupPath = [ + ...(fixture.nodeFirst ? [npmBin, shimDir] : [shimDir, npmBin]), + NodePath.dirname(brewPath), + ].join(NodePath.delimiter); + const probes: Array> = []; + const metadataSpawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { + return Effect.die("Provider resolution must not execute a provider or updater"); + } + probes.push(command.args); + const stdout = + command.args[0] === "--prefix" + ? brewPrefix + : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-mise-shim"), + displayName: "Codex shim test", + enabled: false, + environment: [{ name: "PATH", value: lookupPath, sensitive: false }], + config: { + ...CodexDriver.defaultConfig(), + binaryPath: fixture.commandName, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); + const capabilities = yield* instance.snapshot.resolveMaintenance(); + const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + ["@openai/codex", { expiresAt: Number.MAX_SAFE_INTEGER, version: "0.153.4" }], + ]), + ), + ); + expect(probes).toEqual([]); + expect(latestVersion).toBe("0.153.4"); + expect( + createProviderVersionAdvisory({ + driver: CodexDriver.driverKind, + currentVersion: fixture.version, + latestVersion, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ + status: fixture.version === "0.153.4" ? "current" : "behind_latest", + currentVersion: fixture.version, + latestVersion: "0.153.4", + canUpdate: fixture.nodeFirst, + }); + if (fixture.nodeFirst) { + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", npmPrefix, "@openai/codex@latest"]), + }); + } else { + expect(capabilities.update).toBeNull(); + } + }).pipe(Effect.scoped), + { skip: windowsHost }, + ); }); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 94fc376f227d..2ceaf21996bf 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -595,25 +595,29 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - it.effect.skipIf(!symlinksSupported)( - "upgrades the Homebrew cask that owns the binary and compares against its version", - () => + it.effect.each([ + { directory: "Caskroom", name: "package-tool", kind: "cask" }, + { directory: "Cellar", name: "package-tool", kind: "formula" }, + { directory: "Cellar", name: "package-tool@latest", kind: "formula" }, + ] as const)( + "upgrades the owning Homebrew $kind $name through an executable alias", + (fixture) => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-homebrew-capabilities"); const brewBinDir = NodePath.join(tempDir, "brew-bin"); const brewPath = NodePath.join(brewBinDir, "brew"); writeExecutable(brewPath); - const caskBinary = NodePath.join( + const ownedBinary = NodePath.join( tempDir, - "Caskroom", - "package-tool", + fixture.directory, + fixture.name, "0.148.0", - "package-tool", + "package-tool-0.148.0", ); - writeExecutable(caskBinary); - const link = NodePath.join(tempDir, "bin", "package-tool"); + writeExecutable(ownedBinary); + const link = NodePath.join(tempDir, "bin", "custom-package-tool"); NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); - NodeFS.symlinkSync(caskBinary, link); + NodeFS.symlinkSync(ownedBinary, link); const spawned: Array> = []; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( @@ -630,27 +634,38 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { spawned.push([command, ...args]); return args[0] === "--prefix" ? `${tempDir}\n` - : JSON.stringify({ casks: [{ version: "0.148.0,42" }] }); + : JSON.stringify( + fixture.kind === "cask" + ? { casks: [{ version: "0.148.0,42" }] } + : { formulae: [{ versions: { stable: "0.148.0" } }] }, + ); }), ), ); expect(spawned).toEqual([ [brewPath, "--prefix"], - [brewPath, "info", "--json=v2", "package-tool"], + [brewPath, "info", "--json=v2", fixture.name], ]); expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", latestVersion: "0.148.0", update: { - command: "brew upgrade --cask package-tool", + command: + fixture.kind === "cask" + ? `brew upgrade --cask ${fixture.name}` + : `brew upgrade ${fixture.name}`, executable: brewPath, - args: ["upgrade", "--cask", "package-tool"], + args: + fixture.kind === "cask" + ? ["upgrade", "--cask", fixture.name] + : ["upgrade", fixture.name], lockKey: "homebrew", }, }); }), + { skip: !symlinksSupported }, ); it.effect.skipIf(windowsHost)( diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index d812f1ab7989..e8ff090a4ec9 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -432,6 +432,10 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if (homebrew) { + // Mise shims resolve to the version manager, not the provider. + if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { + return manual; + } const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), );