diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 15ee69264c139..1563b48b1491c 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -35,11 +35,12 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { ProviderDriverError } from "../Errors.ts"; +import { ProviderDriverError, type ProviderAdapterError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { checkCodexProviderStatus, + makeCodexAccountSwitchTracker, makePendingCodexProvider, probeCodexSkillsForCwd, withCodexAppServerClient, @@ -188,6 +189,19 @@ export const CodexDriver: ProviderDriver = { ), ); + // Live sessions keep the account they spawned with. When a probe shows a + // different login, stop them so the next send respawns app-server with + // the current credentials (the same path as Stop or the idle reaper). + const trackAccountSwitch = yield* makeCodexAccountSwitchTracker( + Effect.logInfo("Codex account changed; stopping live sessions.", { instanceId }).pipe( + // `adapter` is created below; suspend so the stop resolves it lazily. + // The annotation breaks the inference cycle through `adapter`. + Effect.andThen( + Effect.suspend((): Effect.Effect => adapter.stopAll()), + ), + ), + ); + // Build a managed snapshot whose settings never change — mutations come // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits @@ -205,6 +219,16 @@ export const CodexDriver: ProviderDriver = { { concurrent: true }, ), ), + Effect.tap((probed) => + trackAccountSwitch(probed.auth).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to stop Codex sessions after account change.", { + instanceId, + cause, + }), + ), + ), + ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2c67b6f144dbb..e89797b255834 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,12 @@ import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + hasCodexAccountChanged, + makeCodexAccountSwitchTracker, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -171,3 +177,44 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("detects a switch between two signed-in Codex accounts and nothing else", () => { + const alice = { status: "authenticated", type: "chatgpt", email: "alice@example.com" } as const; + const bob = { status: "authenticated", type: "chatgpt", email: "bob@example.com" } as const; + assert.isTrue(hasCodexAccountChanged(alice, bob)); + assert.isFalse( + hasCodexAccountChanged(alice, { ...alice, label: "ChatGPT Pro 20x Subscription" }), + ); + assert.isFalse(hasCodexAccountChanged(null, alice)); + assert.isFalse(hasCodexAccountChanged(alice, { status: "unauthenticated" })); + assert.isFalse(hasCodexAccountChanged({ status: "unknown" }, alice)); +}); + +it.effect("stops live sessions on an account switch and retries a failed stop", () => + Effect.gen(function* () { + const alice = { status: "authenticated", type: "chatgpt", email: "alice@example.com" } as const; + const bob = { status: "authenticated", type: "chatgpt", email: "bob@example.com" } as const; + let stops = 0; + let failNextStop = false; + const track = yield* makeCodexAccountSwitchTracker( + Effect.suspend(() => { + stops++; + if (!failNextStop) return Effect.void; + failNextStop = false; + return Effect.fail("stop failed"); + }), + ); + + yield* track(alice); + yield* track(alice); + yield* track({ status: "unauthenticated" }); + assert.strictEqual(stops, 0); + + failNextStop = true; + assert.strictEqual(yield* Effect.flip(track(bob)), "stop failed"); + yield* track(bob); + assert.strictEqual(stops, 2); + yield* track(bob); + assert.strictEqual(stops, 2); + }), +); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 72ca3fd74f0d1..a1a9bf30ed18b 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -542,6 +543,46 @@ const makePendingCodexProvider = ( }); }); +/** + * A live app-server keeps the credentials it loaded at spawn, so after + * `codex login` to another account, threads with a live process keep using + * the old one. Only a change between two signed-in accounts counts; a + * signed-out or failed probe is not a switch, and neither is a plan change + * (`label`) on the same account. + */ +export function hasCodexAccountChanged( + previous: ServerProvider["auth"] | null, + next: ServerProvider["auth"], +): boolean { + if (!previous || previous.status !== "authenticated" || next.status !== "authenticated") { + return false; + } + return previous.type !== next.type || previous.email !== next.email; +} + +/** + * Runs `stopLiveSessions` when a probe reports a different signed-in account. + * Only signed-in probes move the baseline, and a switch moves it only after the + * stop succeeds, so a logout, failed probe, or failed stop in between does not + * hide the switch from the next probe. + */ +export const makeCodexAccountSwitchTracker = (stopLiveSessions: Effect.Effect) => + Ref.make(null).pipe( + Effect.map( + (lastProbedAuth) => + (auth: ServerProvider["auth"]): Effect.Effect => + auth.status !== "authenticated" + ? Effect.void + : Ref.get(lastProbedAuth).pipe( + Effect.flatMap((previous) => + hasCodexAccountChanged(previous, auth) + ? stopLiveSessions.pipe(Effect.andThen(Ref.set(lastProbedAuth, auth))) + : Ref.set(lastProbedAuth, auth), + ), + ), + ), + ); + function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { readonly status: Exclude; readonly auth: ServerProvider["auth"];