From c5fb88f70c405061afdc71c15ec108f1b494bbce Mon Sep 17 00:00:00 2001 From: Karl Von Richter Date: Sun, 1 Nov 2026 09:21:16 +1000 Subject: [PATCH] fix(providers): allow a separate CLI path for updates Codex and Claude launchers can configure routing or accounts without owning the underlying CLI installation. Resolving maintenance from the launcher can disable updates or invoke the wrong executable. Add an optional maintenance binary path and resolve installation ownership and update advisories from that CLI. Keep session launch paths, snapshot versions and post-update verification tied to the session executable. Clearing the override restores the existing behavior. Bind Bun global updates to the proven package and binary directories, with an installation-specific lock. Leave unproven installations manual-only. --- .../src/provider/Drivers/ClaudeDriver.ts | 4 + .../src/provider/Drivers/CodexDriver.ts | 4 + .../providerMaintenanceRunner.test.ts | 33 +++- .../src/provider/providerMaintenanceRunner.ts | 3 + docs/user/install.md | 5 + packages/contracts/src/settings.ts | 22 ++- .../provider-core/src/server/maintenance.ts | 2 + .../src/server/maintenanceResolver.test.ts | 169 ++++++++++++++++-- .../src/server/maintenanceResolver.ts | 67 +++++-- 9 files changed, 274 insertions(+), 35 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e1fbcd30080fd..5e378184caf5b 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -147,6 +147,9 @@ export const ClaudeDriver: ProviderDriver enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + useMaintenanceVersion: true, }), ), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 97ec4954ed840..f4999f94d4ee9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -182,6 +182,9 @@ export const CodexDriver: ProviderDriver< makeCodexMaintenanceResolver(homeLayout.sharedHomePath), { binaryPath: effectiveConfig.binaryPath, + maintenanceBinaryPath: config.maintenanceBinaryPath + ? expandHomePath(config.maintenanceBinaryPath, yield* HostProcess.HomeDirectory) + : null, env: processEnv, }, ).pipe( @@ -249,6 +252,7 @@ export const CodexDriver: ProviderDriver< Effect.flatMap((maintenanceCapabilities) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + useMaintenanceVersion: true, }), ), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 6f3e49e4d4c46..4f7c4416b069d 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -323,7 +323,22 @@ describe("providerMaintenanceRunner", () => { }, { name: "installed behind", probe: { installedVersion: "2.1.309" }, skip: false }, { name: "installed unknown", probe: { installedVersion: null }, skip: false }, - ] as const)("only skips a successful fresh current probe: $name", ({ probe, skip }) => { + { name: "maintenance behind", probe: {}, maintenanceVersion: "2.1.309", skip: false }, + { + name: "maintenance current", + probe: { version: "2.1.309" }, + maintenanceVersion: "2.1.310", + skip: true, + }, + { + name: "maintenance updated but session behind", + probe: { version: "2.1.309" }, + maintenanceVersion: "2.1.309", + skip: false, + }, + ] as const)("only skips a successful fresh current probe: $name", (fixture) => { + const { probe, skip } = fixture; + let maintenanceVersion = "maintenanceVersion" in fixture ? fixture.maintenanceVersion : null; const calls: Array<{ command: string; args: ReadonlyArray }> = []; const refreshedInstanceIds: Array = []; return Effect.gen(function* () { @@ -358,20 +373,29 @@ describe("providerMaintenanceRunner", () => { ); }, getProviderMaintenanceCapabilitiesForInstance: () => - Effect.succeed( - makeProviderMaintenanceCapabilities({ + Effect.succeed({ + ...makeProviderMaintenanceCapabilities({ provider: CODEX_DRIVER, packageName: "@anthropic-ai/claude-code", updateExecutable: "claude", updateArgs: ["update"], updateLockKey: "claudeAgent-native", }), - ), + ...("maintenanceVersion" in fixture + ? { lookupMaintenanceVersion: Effect.sync(() => maintenanceVersion) } + : {}), + }), }); const result = yield* updater.updateProvider(CODEX_DRIVER); assert.deepStrictEqual(refreshedInstanceIds, [CODEX_INSTANCE_ID]); assert.deepStrictEqual(calls, skip ? [] : [{ command: "claude", args: ["update"] }]); + if ("maintenanceVersion" in fixture && !skip) { + assert.strictEqual( + result.providers[0]?.updateState?.status, + fixture.name === "maintenance updated but session behind" ? "unchanged" : "succeeded", + ); + } if (!skip) return; assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); assert.strictEqual(result.providers[0]?.updateState?.message, "Already up to date."); @@ -390,6 +414,7 @@ describe("providerMaintenanceRunner", () => { layerLatestVersionHttpClient("2.1.310"), layerMockSpawner((command, args) => { calls.push({ command, args }); + maintenanceVersion = "2.1.310"; return { stdout: "updated" }; }), ), diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index ee7709e710eda..c9b64e8caa541 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -349,6 +349,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { enrichProviderSnapshotWithVersionAdvisory( refreshedProvider, maintenanceCapabilities, + phase === "pre-update" + ? { enableProviderUpdateChecks: undefined, useMaintenanceVersion: true } + : undefined, ).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), diff --git a/docs/user/install.md b/docs/user/install.md index 6f3f63330e08b..e9946be28171f 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -191,6 +191,11 @@ Chocolatey installs compare against the version their package manager offers, which can trail the npm release; update Claude installed with Chocolatey through Chocolatey. +If Codex or Claude uses a launcher as its **Binary path**, set **Maintenance binary path** +to the underlying CLI installation in web or desktop Settings. Sessions keep using the +launcher; Update now uses the maintenance installation and still verifies the session +binary's version afterward. Clear the maintenance path to update the session binary again. + Add another provider instance for a separate account or configuration. Each instance can have its own environment variables, such as API keys or a custom base URL. Mark secret values as sensitive; after saving, T3 Code does not display diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index fff25149b2e3d..a9357170f9974 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -626,6 +626,15 @@ export function makeProviderSettingsSchema; /** undefined defers to npm; null means the installer could not determine the latest version. */ readonly latestVersion?: string | null; /** diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 35ccaebf21e0f..ba31fc19563c3 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -181,6 +181,74 @@ it.layer( }), ); + it.effect.each([ + { output: "codex-cli 2.0.0", exitCode: 0, expected: "2.0.0" }, + { output: "2.0.0 (Claude Code)", exitCode: 0, expected: "2.0.0" }, + { output: "unavailable", exitCode: 0, expected: null }, + { output: "codex-cli 2.0.0", exitCode: 1, expected: null }, + { output: "missing executable", exitCode: 0, expected: null, missing: true }, + ])( + "uses the selected CLI version only for advisories ($output, exit $exitCode)", + (fixture) => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-maintenance-advisory"); + const maintenancePath = NodePath.join(tempDir, ".local", "bin", "native-package-tool"); + if (!fixture.missing) writeExecutable(maintenancePath); + let probes = 0; + const capabilities = { + ...(yield* resolveProviderMaintenanceCapabilitiesEffect(nativePackageToolUpdate, { + binaryPath: NodePath.join(tempDir, "runtime-launcher"), + maintenanceBinaryPath: maintenancePath, + env: { PATH: "", SELECTED_INSTALL: "fixture" }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner( + (command, args) => { + expect(command).toBe(maintenancePath); + expect(args).toEqual(["--version"]); + probes++; + return fixture.output; + }, + () => fixture.exitCode, + ), + ), + )), + latestVersion: "3.0.0", + }; + const snapshot = { + ...installedPackageToolProvider, + version: "3.0.0", + installedVersion: "3.0.0", + }; + const enrich = (useMaintenanceVersion: boolean, enableProviderUpdateChecks = true) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, capabilities, { + enableProviderUpdateChecks, + useMaintenanceVersion, + }); + yield* enrich(true, false); + expect(probes).toBe(0); + const verification = yield* enrich(false); + expect(verification.versionAdvisory?.currentVersion).toBe("3.0.0"); + expect(probes).toBe(0); + const advisory = yield* enrich(true); + expect(advisory.version).toBe("3.0.0"); + expect(advisory.installedVersion).toBe("3.0.0"); + expect(advisory.versionAdvisory).toMatchObject({ + currentVersion: fixture.expected, + status: fixture.expected ? "behind_latest" : "unknown", + }); + expect(probes).toBe(fixture.missing ? 0 : 1); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("fixture latest version must not make an HTTP request")), + ), + ), + { skip: windowsHost }, + ); + it.effect("does not fetch latest provider versions when update checks are disabled", () => enrichProviderSnapshotWithVersionAdvisory( { ...installedPackageToolProvider, installedVersion: "1.1.0" }, @@ -1033,29 +1101,100 @@ it.layer( { skip: !symlinksSupported || windowsHost }, ); - it.effect.skipIf(windowsHost)( - "switches to bun updates when the resolved binary lives in bun's global bin", + it.effect.skipIf(windowsHost || !symlinksSupported)( + "binds bun updates to the selected global installation and rejects unproven ownership", () => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-bun-capabilities"); - const bunBinDir = NodePath.join(tempDir, ".bun", "bin"); - writeExecutable(NodePath.join(bunBinDir, "package-tool")); - - const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( - packageToolUpdate, - { - binaryPath: "package-tool", - env: { PATH: bunBinDir }, - }, - ).pipe( - Effect.provideService(HostProcess.Platform, "darwin"), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + const bunBinDir = NodePath.join(tempDir, "selected", ".bun", "bin"); + const globalDir = NodePath.join(tempDir, "selected", ".bun", "install", "global"); + const packagePath = NodePath.join( + globalDir, + "node_modules", + "@example", + "package-tool", + "bin", + "package-tool.js", ); + writeExecutable(packagePath); + const maintenancePath = NodePath.join(bunBinDir, "package-tool"); + NodeFS.mkdirSync(bunBinDir, { recursive: true }); + NodeFS.symlinkSync(packagePath, maintenancePath); + const runtimePath = NodePath.join(tempDir, "default", ".bun", "bin", "package-tool"); + writeExecutable(runtimePath); + const env = { + PATH: NodePath.dirname(runtimePath), + BUN_INSTALL_GLOBAL_DIR: NodePath.join(tempDir, "default", ".bun", "install", "global"), + BUN_INSTALL_BIN: NodePath.dirname(runtimePath), + }; + + const resolve = () => + resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath: runtimePath, + maintenanceBinaryPath: maintenancePath, + env, + }).pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HostProcess.Environment, env), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ); + const capabilities = yield* resolve(); expect(capabilities.update).toMatchObject({ command: "bun i -g @example/package-tool@latest", - lockKey: "bun-global", + lockKey: `bun-global:${normalizeCommandPath(globalDir)}`, + env: { ...env, BUN_INSTALL_GLOBAL_DIR: globalDir, BUN_INSTALL_BIN: bunBinDir }, + }); + expect(makeTargetedProviderUpdateAction(capabilities, "2.0.0")).toMatchObject({ + args: ["i", "-g", "@example/package-tool@2.0.0"], + env: capabilities.update?.env, + }); + + for (const target of [ + NodePath.join(globalDir, "node_modules", "@example", "other-package", "bin", "tool"), + NodePath.join( + tempDir, + "project", + "node_modules", + "@example", + "package-tool", + "bin", + "tool", + ), + maintenancePath, + ]) { + NodeFS.unlinkSync(maintenancePath); + writeExecutable(target); + if (target !== maintenancePath) NodeFS.symlinkSync(target, maintenancePath); + expect(yield* resolve()).toMatchObject({ update: null, installPath: target }); + } + }), + ); + + it.effect.skipIf(windowsHost)( + "updates the explicit maintenance installation while keeping the runtime wrapper separate", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-wrapper-maintenance"); + const wrapper = NodePath.join(tempDir, "agentfleet-launcher"); + const nativePath = NodePath.join(tempDir, ".local", "bin", "native-package-tool"); + writeExecutable(wrapper); + writeExecutable(nativePath); + const resolve = (maintenanceBinaryPath: string) => + resolveProviderMaintenanceCapabilitiesEffect(nativePackageToolUpdate, { + binaryPath: wrapper, + maintenanceBinaryPath, + env: { PATH: "" }, + }).pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ); + expect((yield* resolve(nativePath)).update).toMatchObject({ + executable: nativePath, + args: ["update"], }); + expect((yield* resolve(NodePath.join(tempDir, "missing"))).update).toBeNull(); + expect((yield* resolve(" ")).update?.executable).toBe(wrapper); }), ); diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 635fa37a227fa..3d906a90ea965 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -162,7 +162,7 @@ export function makeTargetedProviderUpdateAction( const packageName = capabilities.packageName; if (!update || !packageName) return null; if ( - !/^(?:npm-global:|bun-global$|pnpm-global$|vite-plus-global$|yarn-global$|volta$)/.test( + !/^(?:npm-global:|bun-global:|pnpm-global$|vite-plus-global$|yarn-global$|volta$)/.test( update.lockKey, ) ) @@ -551,13 +551,29 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( }); } if (commandPaths.some(isBunGlobalCommandPath)) { + const path = yield* Path.Path; + const binDir = path.dirname(context.resolvedCommandPath); + if (!normalizeCommandPath(binDir).endsWith("/.bun/bin")) return manual; + const fileSystem = yield* FileSystem.FileSystem; + const globalDir = yield* fileSystem + .realPath(path.join(binDir, "..", "install", "global")) + .pipe(Effect.orElseSucceed(() => null)); + // A bin entry alone may be a wrapper or a link into a local project. + if ( + !globalDir || + !path + .relative(globalDir, context.realCommandPath) + .replaceAll("\\", "/") + .startsWith(`node_modules/${packageName}/`) + ) + return manual; return makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, updateExecutable: "bun", updateArgs: ["i", "-g", `${packageName}@latest`], - updateLockKey: "bun-global", - env: context.env, + updateLockKey: `bun-global:${normalizeCommandPath(globalDir)}`, + env: { ...context.env, BUN_INSTALL_GLOBAL_DIR: globalDir, BUN_INSTALL_BIN: binDir }, }); } if (commandPaths.some(isPnpmGlobalCommandPath)) { @@ -843,18 +859,29 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( resolver: ProviderMaintenanceCapabilitiesResolver, options?: { readonly binaryPath?: string | null; + readonly maintenanceBinaryPath?: string | null; readonly env?: NodeJS.ProcessEnv; }, ) { - const binaryPath = nonEmptyString(options?.binaryPath); + const maintenanceBinaryPath = nonEmptyString(options?.maintenanceBinaryPath); + const binaryPath = maintenanceBinaryPath ?? nonEmptyString(options?.binaryPath); + const unresolved = resolver + .resolve(null) + .pipe( + Effect.map((capabilities) => + maintenanceBinaryPath + ? { ...capabilities, lookupMaintenanceVersion: Effect.succeed(null) } + : capabilities, + ), + ); if (!binaryPath) { - return yield* resolver.resolve(null); + return yield* unresolved; } const env = options?.env ?? (yield* readCommandLookupEnv); const resolvedCommandPath = yield* resolveCommandPath(binaryPath, { env }); if (!resolvedCommandPath) { - return yield* resolver.resolve(null); + return yield* unresolved; } const fileSystem = yield* FileSystem.FileSystem; @@ -862,15 +889,24 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( .realPath(resolvedCommandPath) .pipe(Effect.orElseSucceed(() => null)); if (!realCommandPath) { - return yield* resolver.resolve(null); + return yield* unresolved; } - return yield* resolver.resolve({ + const capabilities = yield* resolver.resolve({ binaryPath, resolvedCommandPath, realCommandPath, env, platform: yield* HostProcess.Platform, }); + if (!maintenanceBinaryPath) return capabilities; + const services = yield* Effect.context(); + return { + ...capabilities, + lookupMaintenanceVersion: runProbe(resolvedCommandPath, ["--version"], env).pipe( + Effect.map((output) => output?.match(/\bv?(\d+\.\d+\.\d+)\b/)?.[1] ?? null), + Effect.provideContext(services), + ), + }; }, Effect.flatMap(withConfiguredNpmVersionLookup)); export const makeCachedProviderMaintenanceResolution = Effect.fn( @@ -1028,17 +1064,20 @@ export const enrichProviderSnapshotWithVersionAdvisory = Effect.fn( maintenanceCapabilities?: ProviderMaintenanceCapabilities, options?: { readonly enableProviderUpdateChecks: boolean | undefined; + readonly useMaintenanceVersion?: boolean; }, ) { const capabilities = maintenanceCapabilities ?? makeManualProviderMaintenanceCapabilities(snapshot.driver); + const shouldCheck = + options?.enableProviderUpdateChecks !== false && snapshot.enabled && snapshot.installed; const currentVersion = - snapshot.installedVersion !== undefined ? snapshot.installedVersion : snapshot.version; - const shouldResolveLatestVersion = - options?.enableProviderUpdateChecks !== false && - snapshot.enabled && - snapshot.installed && - Boolean(currentVersion); + shouldCheck && options?.useMaintenanceVersion && capabilities.lookupMaintenanceVersion + ? yield* capabilities.lookupMaintenanceVersion + : snapshot.installedVersion !== undefined + ? snapshot.installedVersion + : snapshot.version; + const shouldResolveLatestVersion = shouldCheck && Boolean(currentVersion); if (!shouldResolveLatestVersion) { return { ...snapshot,