Repository navigation
fix(server): Codex sessions switch accounts after codex login
#35068
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { assert, it } from "@effect/vitest"; | ||
| import { | ||
| ProviderDriverKind, | ||
| ProviderInstanceId, | ||
| type ServerProvider, | ||
| type ServerProviderAuth, | ||
| } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Queue from "effect/Queue"; | ||
| import * as Stream from "effect/Stream"; | ||
|
|
||
| import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; | ||
| import * as CodexAccountSwitch from "./CodexAccountSwitch.ts"; | ||
| import * as ProviderRegistry from "./ProviderRegistry.ts"; | ||
|
|
||
| const codex = ProviderInstanceId.make("codex"); | ||
| const codexWork = ProviderInstanceId.make("codex-work"); | ||
| const claude = ProviderInstanceId.make("claude"); | ||
|
|
||
| const provider = ( | ||
| instanceId: ProviderInstanceId, | ||
| auth: ServerProviderAuth, | ||
| driver = ProviderDriverKind.make("codex"), | ||
| ): ServerProvider => ({ | ||
| instanceId, | ||
| driver, | ||
| enabled: true, | ||
| installed: true, | ||
| version: "1.0.0", | ||
| status: "ready", | ||
| auth, | ||
| checkedAt: "2026-11-12T00:00:00.000Z", | ||
| models: [], | ||
| slashCommands: [], | ||
| skills: [], | ||
| }); | ||
|
|
||
| const signedIn = (email: string, workspaceId?: string): ServerProviderAuth => ({ | ||
| status: "authenticated", | ||
| email, | ||
| ...(workspaceId === undefined ? {} : { workspaceId }), | ||
| }); | ||
|
|
||
| it.effect("closes a Codex instance's sessions only when its signed-in account changes", () => | ||
| Effect.gen(function* () { | ||
| const changes = yield* Queue.unbounded<ReadonlyArray<ServerProvider>>(); | ||
| const closed = yield* Queue.unbounded<ProviderInstanceId>(); | ||
| const claudeDriver = ProviderDriverKind.make("claude"); | ||
| yield* Layer.build( | ||
| CodexAccountSwitch.layer.pipe( | ||
| Layer.provide( | ||
| Layer.mergeAll( | ||
| Layer.mock(ProviderRegistry.ProviderRegistry)({ | ||
| getProviders: Effect.succeed([ | ||
| provider(codex, signedIn("alice@example.com", "ws-alice")), | ||
| provider(codexWork, signedIn("team@example.com", "ws-personal")), | ||
| ]), | ||
| streamChanges: Stream.fromQueue(changes), | ||
| }), | ||
| Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ | ||
| closeInstance: (instanceId) => Queue.offer(closed, instanceId).pipe(Effect.asVoid), | ||
| }), | ||
| ), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| yield* Queue.offerAll(changes, [ | ||
| // A token refresh rewrites auth.json without changing the account. | ||
| [provider(codex, signedIn("alice@example.com", "ws-alice"))], | ||
| [provider(codex, signedIn("alice@example.com"))], | ||
| [provider(codex, { status: "unknown" })], | ||
| [provider(claude, signedIn("one@example.com"), claudeDriver)], | ||
| [provider(claude, signedIn("two@example.com"), claudeDriver)], | ||
| [ | ||
| provider(codex, signedIn("bob@example.com")), | ||
| provider(codexWork, signedIn("team@example.com", "ws-team")), | ||
| ], | ||
| ]); | ||
|
|
||
| assert.deepStrictEqual( | ||
| [yield* Queue.take(closed), yield* Queue.take(closed)], | ||
| [codex, codexWork], | ||
| ); | ||
| assert.strictEqual(yield* Queue.size(closed), 0); | ||
| }).pipe(Effect.scoped), | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { | ||
| ProviderDriverKind, | ||
| type ProviderInstanceId, | ||
| type ServerProvider, | ||
| } from "@t3tools/contracts"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Stream from "effect/Stream"; | ||
|
|
||
| import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; | ||
| import * as ProviderRegistry from "./ProviderRegistry.ts"; | ||
|
|
||
| const CODEX_DRIVER = ProviderDriverKind.make("codex"); | ||
|
|
||
| interface CodexAccount { | ||
| readonly email: string; | ||
| readonly workspaceId: string | undefined; | ||
| } | ||
|
|
||
| const signedInAccount = (provider: ServerProvider): CodexAccount | undefined => | ||
| provider.driver === CODEX_DRIVER && | ||
| provider.auth.status === "authenticated" && | ||
| provider.auth.email !== undefined | ||
| ? { email: provider.auth.email, workspaceId: provider.auth.workspaceId } | ||
| : undefined; | ||
|
|
||
| // The workspace id comes from an optional rate-limit read, so compare it only when both have one. | ||
| const isSameAccount = (previous: CodexAccount, next: CodexAccount) => | ||
| previous.email === next.email && | ||
| (previous.workspaceId === undefined || | ||
| next.workspaceId === undefined || | ||
| previous.workspaceId === next.workspaceId); | ||
|
|
||
| const recordAccounts = ( | ||
| accounts: Map<ProviderInstanceId, CodexAccount>, | ||
| providers: ReadonlyArray<ServerProvider>, | ||
| ): ReadonlyArray<ProviderInstanceId> => { | ||
| const switched: Array<ProviderInstanceId> = []; | ||
| for (const provider of providers) { | ||
| const next = signedInAccount(provider); | ||
| if (next === undefined) continue; | ||
| const previous = accounts.get(provider.instanceId); | ||
| if (previous !== undefined && !isSameAccount(previous, next)) { | ||
| switched.push(provider.instanceId); | ||
| } | ||
| accounts.set(provider.instanceId, { | ||
| email: next.email, | ||
| workspaceId: | ||
| next.workspaceId ?? (previous?.email === next.email ? previous.workspaceId : undefined), | ||
| }); | ||
| } | ||
| return switched; | ||
| }; | ||
|
|
||
| // A Codex app-server reads auth.json once, so after `codex login` switches accounts outside T3 | ||
| // its live sessions keep the old account until they are closed. | ||
| export const layer = Layer.effectDiscard( | ||
| Effect.gen(function* () { | ||
| const providers = yield* ProviderRegistry.ProviderRegistry; | ||
| const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; | ||
| const accounts = new Map<ProviderInstanceId, CodexAccount>(); | ||
| recordAccounts(accounts, yield* providers.getProviders); | ||
| yield* providers.streamChanges.pipe( | ||
| Stream.runForEach((snapshot) => | ||
| Effect.forEach( | ||
| recordAccounts(accounts, snapshot), | ||
| (instanceId) => | ||
| sessions.closeInstance(instanceId).pipe(Effect.ignoreCause({ log: true })), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Closure can miss sessions that are still opening.
Make the account invalidation and the session insert share one per-instance barrier. Either have
|
||
| { discard: true }, | ||
| ), | ||
| ), | ||
| Effect.forkScoped, | ||
| ); | ||
| }), | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Subscribe before reading the initial account snapshot.
streamChangesisStream.fromPubSub(changesPubSub). It does not replay the current snapshot. The initialgetProvidersread runs before the subscription starts. A snapshot published in that gap is not delivered toCodexAccountSwitch. If it changes the account, the stale account stays inaccounts, and sessions on that account are not closed until a later snapshot changes the account again. The window is narrow, and the next account change clears the stale state.ProviderRegistry.ts:894already uses subscribe-before-read for the same race. Start the subscription before reading the initial snapshot, so both run in the same scope with no publish between them.