From 72fb98cfd39e16e21a0ed9dc343559c05821efb3 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 c2d4b63861074..db67b8ae5ead4 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -123,6 +123,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 dee8a04aee9fa..9bebf64cf3432 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -195,6 +195,9 @@ export const CodexDriver: ProviderDriver< makeCodexMaintenanceResolver(homeLayout.sharedHomePath), { binaryPath: effectiveConfig.binaryPath, + maintenanceBinaryPath: config.maintenanceBinaryPath + ? expandHomePath(config.maintenanceBinaryPath, yield* HostProcess.HomeDirectory) + : null, env: processEnv, }, ).pipe( @@ -262,6 +265,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 77a44c7fa29a5..458dae1047943 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -324,7 +324,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* () { @@ -359,20 +374,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."); @@ -391,6 +415,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 d3fe7fdf86739..63a9ba0b1e33c 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -351,6 +351,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 4689a2965889b..6769ec4b10230 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -213,6 +213,11 @@ Cursor and Antigravity update with T3 Code. Homebrew and 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 6af8888b253dd..8cab97d65eec7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -676,6 +676,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 e5298def7f0da..504afe3969da8 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -307,6 +307,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" }, @@ -1159,29 +1227,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 bb60e8e865714..42964650644d7 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -172,7 +172,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, ) ) @@ -714,13 +714,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)) { @@ -998,18 +1014,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; @@ -1017,15 +1044,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( @@ -1184,17 +1220,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,