Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
binaryPath: effectiveConfig.binaryPath,
maintenanceBinaryPath: config.maintenanceBinaryPath
? expandHomePath(config.maintenanceBinaryPath, yield* HostProcess.HomeDirectory)
: null,
env: processEnv,
}).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Expand Down Expand Up @@ -305,6 +308,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv, Path.
Effect.flatMap((maintenanceCapabilities) =>
enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
useMaintenanceVersion: true,
}),
),
Effect.provideService(HttpClient.HttpClient, httpClient),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -249,6 +252,7 @@ export const CodexDriver: ProviderDriver<
Effect.flatMap((maintenanceCapabilities) =>
enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, {
enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
useMaintenanceVersion: true,
}),
),
Effect.provideService(HttpClient.HttpClient, httpClient),
Expand Down
33 changes: 29 additions & 4 deletions apps/server/src/provider/providerMaintenanceRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> }> = [];
const refreshedInstanceIds: Array<ProviderInstanceId> = [];
return Effect.gen(function* () {
Expand Down Expand Up @@ -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.");
Expand All @@ -390,6 +414,7 @@ describe("providerMaintenanceRunner", () => {
layerLatestVersionHttpClient("2.1.310"),
layerMockSpawner((command, args) => {
calls.push({ command, args });
maintenanceVersion = "2.1.310";
return { stdout: "updated" };
}),
),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/provider/providerMaintenanceRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 5 additions & 0 deletions docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,15 @@ export function makeProviderSettingsSchema<const Fields extends Schema.Struct.Fi
);
}

const MaintenanceBinaryPath = Schema.optionalKey(TrimmedString).pipe(
Schema.annotateKey({
title: "Maintenance binary path",
description:
"Underlying CLI to update when Binary path is a launcher. Leave empty to update the session binary.",
providerSettingsForm: { clearWhenEmpty: "omit" },
}),
);

export const CodexSettings = makeProviderSettingsSchema(
{
setupMode: Schema.optionalKey(Schema.Literals(["managed", "existing"])).pipe(
Expand All @@ -642,6 +651,7 @@ export const CodexSettings = makeProviderSettingsSchema(
providerSettingsForm: { placeholder: "codex", clearWhenEmpty: "omit" },
}),
),
maintenanceBinaryPath: MaintenanceBinaryPath,
homePath: TrimmedString.pipe(
Schema.withDecodingDefault(Effect.succeed("")),
Schema.annotateKey({
Expand Down Expand Up @@ -678,7 +688,7 @@ export const CodexSettings = makeProviderSettingsSchema(
),
},
{
order: ["binaryPath", "homePath", "shadowHomePath", "launchArgs"],
order: ["binaryPath", "maintenanceBinaryPath", "homePath", "shadowHomePath", "launchArgs"],
},
);
export type CodexSettings = typeof CodexSettings.Type;
Expand Down Expand Up @@ -718,6 +728,7 @@ export const ClaudeSettings = makeProviderSettingsSchema(
providerSettingsForm: { placeholder: "claude", clearWhenEmpty: "omit" },
}),
),
maintenanceBinaryPath: MaintenanceBinaryPath,
homePath: TrimmedString.pipe(
Schema.withDecodingDefault(Effect.succeed("")),
Schema.annotateKey({
Expand Down Expand Up @@ -771,7 +782,14 @@ export const ClaudeSettings = makeProviderSettingsSchema(
),
},
{
order: ["binaryPath", "homePath", "autoCompactWindow", "idleCompaction", "launchArgs"],
order: [
"binaryPath",
"maintenanceBinaryPath",
"homePath",
"autoCompactWindow",
"idleCompaction",
"launchArgs",
],
},
);
export type ClaudeSettings = typeof ClaudeSettings.Type;
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-core/src/server/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export interface ProviderMaintenanceCapabilities {
readonly provider: ProviderDriverKind;
readonly packageName: string | null;
readonly update: ProviderMaintenanceCommandAction | null;
/** Selected maintenance CLI version; never replaces the session snapshot version. */
readonly lookupMaintenanceVersion?: Effect.Effect<string | null>;
/** undefined defers to npm; null means the installer could not determine the latest version. */
readonly latestVersion?: string | null;
/**
Expand Down
169 changes: 154 additions & 15 deletions packages/provider-core/src/server/maintenanceResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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);
}),
);

Expand Down
Loading
Loading