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,