From 01ae27b4a7ccb35c21ed71127fd888f1c3c6d4e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:04:55 +0000 Subject: [PATCH 1/3] test(server): cover native Codex token helper and catalog races --- .../src/provider/CodexManagedRuntime.test.ts | 109 +++++++++++++++--- .../src/provider/Drivers/CodexDriver.test.ts | 66 +++++++++-- 2 files changed, 152 insertions(+), 23 deletions(-) diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 8034e910d67a..d7a09783c54d 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -1,4 +1,9 @@ // @effect-diagnostics nodeBuiltinImport:off - checks the host's default Codex directory without writing to it. +import * as NodeURL from "node:url"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; @@ -20,7 +25,10 @@ import { codexAppServerArgs } from "./codexLaunchArgs.ts"; const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeSettings = Schema.decodeSync(CodexSettings); -it.effect.each( +const decodeCommand = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Tuple([Schema.String, Schema.Array(Schema.String)])), +); +it.live.each( (["managed", "local"] as const).flatMap((source) => (["primary", "additional"] as const).map((account) => ({ source, account })), ), @@ -37,6 +45,7 @@ it.effect.each( yield* fs.writeFileString(path.join(sharedHome, "auth.json"), "native-auth-unchanged"); yield* fs.writeFileString(path.join(sharedHome, "config.toml"), "# shared config\n"); const data = new Map(); + let renewals = 0; const secrets = ServerSecretStore.ServerSecretStore.of({ get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), set: (key, value) => @@ -87,10 +96,13 @@ it.effect.each( yield* store.set(new TextEncoder().encode(json)); const ambient = { CODEX_HOME: "/user/.codex", + ACCESS_TOKEN: "ambient-bearer", + T3CODE_HOME: path.join(sharedHome, "wrong-server-home"), OPENAI_API_KEY: "dummy-global-key", OPENAI_BASE_URL: "https://user-proxy.test", T3CODE_CODEX_LAUNCH_ARGS: "--config model_provider=global-proxy", - PATH: "/usr/bin", + PATH: process.env.PATH, + SystemRoot: process.env.SystemRoot, }; const runtime = yield* makeCodexManagedRuntime({ instanceId, @@ -121,27 +133,77 @@ it.effect.each( ); assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); } - assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); + assert.isUndefined(effective.environment.ACCESS_TOKEN); assert.isUndefined(effective.environment.OPENAI_API_KEY); assert.isUndefined(effective.environment.OPENAI_BASE_URL); assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); const args = codexAppServerArgs(effective.config.launchArgs); - assert.include( - args, - 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', - ); - assert.include( - args, - 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', - ); + const provider = args.find((arg) => + arg.startsWith("model_providers.openai_token_sharing_"), + )!; + assert.include(provider, 'base_url="https://api.openai.com/v1"'); + assert.include(provider, 'model_catalog_url="https://api.openai.com/v1/models"'); + assert.notInclude(provider, "env_key"); + assert.include(provider, "refresh_interval_ms=0"); + // regression: #16132 Codex fetches each token through this helper, so a running + // session picks up renewals instead of keeping the token it launched with. + if (source === "managed" && account === "primary") { + const commandMatch = /auth=\{command=(.*),args=(\[.*\]),timeout_ms=/u.exec(provider); + assert.isNotNull(commandMatch); + const [command, helperArgs] = decodeCommand( + `[${commandMatch![1]},${commandMatch![2]}]`, + ); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runHelper = Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(command, helperArgs, { + extendEnv: false, + env: effective.environment, + }), + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + child.stdout.pipe(Stream.decodeText(), Stream.mkString), + child.stderr.pipe(Stream.decodeText(), Stream.mkString), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, code: Number(code) }; + }).pipe(Effect.scoped); + assert.deepEqual(yield* runHelper, { + stdout: "dummy-owned-access", + stderr: "", + code: 0, + }); + const saved = Option.getOrThrow(yield* runtime.auth.read); + const expired = yield* encodeJson({ ...saved, expiresAt: 0 }); + yield* store.set(new TextEncoder().encode(expired)); + assert.deepEqual(yield* runHelper, { stdout: "renewed-access", stderr: "", code: 0 }); + assert.equal(renewals, 1); + assert.equal( + Option.getOrThrow(yield* runtime.auth.read).refreshToken, + "renewed-refresh", + ); + assert.equal((yield* runtime.resolve).revision, effective.revision); + const replaced = yield* encodeJson({ ...saved, subject: "replacement-user" }); + yield* store.set(new TextEncoder().encode(replaced)); + const refused = yield* runHelper; + assert.equal(refused.code, 1); + assert.equal(refused.stdout, ""); + assert.isFalse(yield* fs.exists(ambient.T3CODE_HOME)); + } assert.include(args, "features.api_key_model_discovery=true"); assert.include(args, "features.multi_agent=false"); assert.include(args, "features.multi_agent_v2=false"); assert.notInclude(effective.config.launchArgs, "model_catalog_json"); assert.notInclude(effective.config.launchArgs, "x-openai-chatpass-test"); - assert.include(args, "model_providers.openai_token_sharing.supports_websockets=false"); - assert.include(args, "model_providers.openai_token_sharing.requires_openai_auth=false"); - assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); + assert.include(provider, "supports_websockets=false"); + assert.include(provider, "requires_openai_auth=false"); + for (const secret of ["dummy-owned-access", "dummy-refresh", "ambient-bearer"]) { + assert.notInclude(effective.config.launchArgs, secret); + assert.notInclude(JSON.stringify(effective.environment), secret); + } assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); }).pipe(Effect.scoped); assert.strictEqual(leases, 0); @@ -151,6 +213,11 @@ it.effect.each( "native-auth-unchanged", ); }).pipe( + Effect.provide(CodexManagedTokenBroker.layer), + Effect.provideService(HostProcess.Arguments, [ + process.execPath, + NodeURL.fileURLToPath(new URL("../bin.ts", import.meta.url)), + ]), Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), Effect.provideService(ServerEnvironment.ServerEnvironmentIdentity, { getEnvironmentId: Effect.succeed( @@ -166,8 +233,22 @@ it.effect.each( [ "https://auth.openai.com/.well-known/openid-configuration", "https://auth.openai.com/revoke", + "https://auth.openai.com/api/accounts/oauth/token", ].includes(request.url), ); + if (request.url.endsWith("/token")) { + renewals++; + return HttpClientResponse.fromWeb( + request, + Response.json({ + access_token: "renewed-access", + refresh_token: "renewed-refresh", + token_type: "Bearer", + expires_in: 3600, + scope: "chatgpt.tokens.use.direct", + }), + ); + } return HttpClientResponse.fromWeb( request, request.url.endsWith("/revoke") diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 2ccc555256f5..0be07317402f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -1,4 +1,5 @@ import * as CodexInstallation from "../CodexInstallation.ts"; +import * as CodexManagedTokenBroker from "../CodexManagedTokenBroker.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; // @effect-diagnostics nodeBuiltinImport:off @@ -58,6 +59,12 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { }), ), Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), + Layer.provideMerge( + Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({ + endpoint: Effect.succeed("unused-private-pipe"), + awaitIdle: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { getEnvironmentId: Effect.succeed(EnvironmentId.make("00000000-0000-4000-8000-000000000001")), @@ -96,10 +103,13 @@ const noSpawn = ChildProcessSpawner.make(() => const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.layer(layerTest)("CodexDriver", (it) => { - it.effect("disconnect refreshes a restored managed account while its auth flow is idle", () => + const checkManagedAccount = (replacementTiming: "unchanged" | "during-probe") => Effect.gen(function* () { const instanceId = ProviderInstanceId.make("restored-managed-account"); const credentials = new Map(); + const catalogTokens: Array = []; + const probeEntered = yield* Deferred.make(); + const resumeProbe = yield* Deferred.make(); const secrets = ServerSecretStore.ServerSecretStore.of({ get: (key) => Effect.sync(() => Option.fromUndefinedOr(credentials.get(key))), set: (key, value) => @@ -126,6 +136,17 @@ it.layer(layerTest)("CodexDriver", (it) => { email: "account@example.test", }); yield* store.set(new TextEncoder().encode(json)); + const replacement = yield* encodeCredentials({ + clientId: "oaiapp_replacement", + subject: "replacement-user", + email: "replacement@example.test", + accessToken: "replacement-access", + refreshToken: "replacement-refresh", + expiresAt: Number.MAX_SAFE_INTEGER, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + }); + const replaceAccount = store.set(new TextEncoder().encode(replacement)); const executable = { executablePath: "/user/bin/codex", managedVersionDirectory: null, @@ -187,13 +208,17 @@ it.layer(layerTest)("CodexDriver", (it) => { Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make(() => - Effect.fail( - PlatformError.badArgument({ + Effect.gen(function* () { + if (replacementTiming === "during-probe") { + yield* Deferred.succeed(probeEntered, undefined); + yield* Deferred.await(resumeProbe); + } + return yield* PlatformError.badArgument({ module: "ChildProcessSpawner", method: "spawn", description: "The fixture app-server is unavailable", - }), - ), + }); + }), ), ), ); @@ -209,7 +234,21 @@ it.layer(layerTest)("CodexDriver", (it) => { Effect.forkScoped, ); yield* Deferred.await(startupChecksInterrupted); - const restored = yield* instance.snapshot.refresh; + const refresh = yield* instance.snapshot.refresh.pipe(Effect.forkChild); + if (replacementTiming === "during-probe") { + yield* Deferred.await(probeEntered); + yield* replaceAccount; + yield* Deferred.succeed(resumeProbe, undefined); + } + const restored = yield* Fiber.join(refresh); + if (replacementTiming === "during-probe") { + expect(catalogTokens).toEqual([]); + expect(restored.models).toEqual([]); + expect(restored.auth.email).toBe("replacement@example.test"); + expect(restored.auth.profileId).toBe("oaiapp_replacement"); + return; + } + expect(catalogTokens).toEqual(["Bearer dummy-owned-access"]); expect(restored.auth.email).toBe("account@example.test"); expect(restored.runtimePaths?.homePath).toBe(sharedHome); expect(restored.runtimePaths?.shadowHomePath).toContain( @@ -246,7 +285,9 @@ it.layer(layerTest)("CodexDriver", (it) => { const launch = launches[0]!; expect(launch.settings.binaryPath).toBe(executable.executablePath); expect(launch.settings.launchArgs).toContain("openai_token_sharing"); - expect(launch.environment.ACCESS_TOKEN).toBe("dummy-owned-access"); + expect(launch.environment.ACCESS_TOKEN).toBeUndefined(); + expect(launch.settings.launchArgs).toContain("__managed-chatgpt-token"); + expect(launch.settings.launchArgs).not.toContain("dummy-owned-access"); expect(launch.environment.OPENAI_API_KEY).toBeUndefined(); expect(launch.environment.CODEX_HOME).toBe(launch.settings.homePath); const before = yield* instance.auth!.subscribe("test-owner").pipe(Stream.runHead); @@ -273,8 +314,10 @@ it.layer(layerTest)("CodexDriver", (it) => { "https://auth.openai.com/revoke", "https://api.openai.com/v1/models", ]).toContain(request.url); - if (request.url.endsWith("/models")) + if (request.url.endsWith("/models")) { + catalogTokens.push(request.headers.authorization); return HttpClientResponse.fromWeb(request, Response.json({ models: [] })); + } return HttpClientResponse.fromWeb( request, request.url.endsWith("/revoke") @@ -291,7 +334,12 @@ it.layer(layerTest)("CodexDriver", (it) => { ), ), ); - }).pipe(Effect.scoped), + }).pipe(Effect.scoped); + // regression: a sign-in that replaces the account mid-probe must not label the old + // account's catalog with the new identity. + it.effect.each(["unchanged", "during-probe"] as const)( + "managed catalog keeps its account label and token together: %s", + checkManagedAccount, ); it.effect.skipIf(windowsHost)( From 281f81ed8a4217a3cb1ec15af6c0d24feae40c2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:18:46 +0000 Subject: [PATCH 2/3] fix(server): renew active managed Codex sessions through native auth --- apps/server/src/bin.ts | 9 ++- apps/server/src/cli/managedChatGptToken.ts | 48 ++++++++++++++ .../src/provider/CodexManagedRuntime.ts | 62 +++++++++++++------ .../src/provider/Drivers/CodexDriver.test.ts | 2 +- .../src/provider/Drivers/CodexDriver.ts | 4 +- .../provider/Drivers/CodexManagedProvider.ts | 28 +++++++-- .../provider/ProviderInstanceRegistry.test.ts | 6 ++ .../src/provider/ProviderRegistry.test.ts | 4 ++ apps/server/src/server.ts | 2 + 9 files changed, 136 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/cli/managedChatGptToken.ts diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 03b282a38384..8138631ed026 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -1,8 +1,8 @@ /** * Every ACP agent spawns `t3 acp-mcp-bridge` while opening its session, and terminal-fallback * agents run `t3 acp-mcp-call` per tool call, so their startup sits on first-message latency. - * Both dispatch here before the full CLI module graph (seconds of evaluation) loads; everything - * else defers to the real CLI in ./binCli.ts. + * They and Codex's managed-token helper dispatch before the full CLI module graph (seconds + * of evaluation) loads; everything else defers to the real CLI in ./binCli.ts. */ import { isEntrypoint } from "./entrypoint.ts"; @@ -17,7 +17,10 @@ if ( // Guard sockets before either dispatch path can load outbound-capable modules. const { ignoreMacosTypeOfServiceEinval } = await import("./outboundHttp.ts"); ignoreMacosTypeOfServiceEinval(); - if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { + if (command === "__managed-chatgpt-token") { + const { runManagedChatGptTokenCli } = await import("./cli/managedChatGptToken.ts"); + await runManagedChatGptTokenCli(process.argv.slice(3)); + } else if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts"); await runAcpMcpCliFastPath(command, process.argv.slice(3)); } else { diff --git a/apps/server/src/cli/managedChatGptToken.ts b/apps/server/src/cli/managedChatGptToken.ts new file mode 100644 index 000000000000..84ed18fb5228 --- /dev/null +++ b/apps/server/src/cli/managedChatGptToken.ts @@ -0,0 +1,48 @@ +// @effect-diagnostics nodeBuiltinImport:off - Lean self-invoked local IPC client; no auth/store graph. +import * as NodeNet from "node:net"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as Protocol from "../provider/CodexManagedTokenProtocol.ts"; + +const decodeArguments = Schema.decodeUnknownEffect( + Schema.Tuple([ + Schema.NonEmptyString, + Protocol.Request.fields.instanceId, + Protocol.Request.fields.environmentId, + Protocol.Request.fields.profileKey, + ]), +); +const encodeRequest = Schema.encodeSync(Schema.fromJsonString(Protocol.Request)); +const decodeResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(Protocol.Response), { + onExcessProperty: "error", +}); + +/** Codex's auth.command protocol is a bare token on stdout, never a CLI diagnostic. */ +export async function runManagedChatGptTokenCli(args: ReadonlyArray): Promise { + const program = Effect.gen(function* () { + const [endpoint, instanceId, environmentId, profileKey] = yield* decodeArguments(args); + const socket = yield* Effect.acquireRelease( + Effect.sync(() => NodeNet.createConnection(endpoint)), + (socket) => Effect.sync(() => socket.destroy()), + ); + socket.on("error", () => {}); + socket.once("connect", () => + socket.write(encodeRequest({ version: 1, instanceId, environmentId, profileKey }) + "\n"), + ); + return yield* Protocol.readFrame( + socket, + Protocol.RESPONSE_BYTES, + Protocol.COMMAND_TIMEOUT_MS, + ).pipe(Effect.flatMap(decodeResponse)); + }).pipe(Effect.scoped); + const result = await Effect.runPromiseExit(program); + if (Exit.isFailure(result)) { + process.stderr.write( + "Could not access the managed ChatGPT connection. Reconnect in T3 Code.\n", + ); + process.exitCode = 1; + return; + } + process.stdout.write(result.value.token); +} diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts index 826d777f1bd7..32a854e028fd 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -5,8 +5,12 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; +import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; import * as CodexInstallation from "./CodexInstallation.ts"; import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; +import { COMMAND_TIMEOUT_MS } from "./CodexManagedTokenProtocol.ts"; import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts"; export interface CodexEffectiveRuntime { @@ -15,21 +19,18 @@ export interface CodexEffectiveRuntime { readonly revision: string; } const decodeSettings = Schema.decodeSync(CodexSettings); -const managedCodexLaunchArgs = [ - 'model_provider="openai_token_sharing"', - 'model_providers.openai_token_sharing.name="OpenAI Token Sharing"', - 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', - 'model_providers.openai_token_sharing.model_catalog_url="https://api.openai.com/v1/models"', - "features.api_key_model_discovery=true", - "features.multi_agent=false", - "features.multi_agent_v2=false", - 'model_providers.openai_token_sharing.env_key="ACCESS_TOKEN"', - 'model_providers.openai_token_sharing.wire_api="responses"', - "model_providers.openai_token_sharing.requires_openai_auth=false", - "model_providers.openai_token_sharing.supports_websockets=false", -] - .map((value) => `-c '${value}'`) - .join(" "); +const managedCodexLaunchArgs = (profileKey: string, command: string, args: ReadonlyArray) => + [ + `model_provider="openai_token_sharing_${profileKey}"`, + // Codex recursively merges -c tables. A profile-specific key avoids inheriting + // auth fields or headers from a shared openai_token_sharing provider. + `model_providers.openai_token_sharing_${profileKey}={name="OpenAI Token Sharing",base_url="https://api.openai.com/v1",model_catalog_url="https://api.openai.com/v1/models",wire_api="responses",requires_openai_auth=false,supports_websockets=false,auth={command=${JSON.stringify(command)},args=${JSON.stringify(args)},timeout_ms=${COMMAND_TIMEOUT_MS},refresh_interval_ms=0}}`, + "features.api_key_model_discovery=true", + "features.multi_agent=false", + "features.multi_agent_v2=false", + ] + .map((value) => `-c ${JSON.stringify(value)}`) + .join(" "); export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(function* (options: { readonly instanceId: ProviderInstanceId; @@ -41,6 +42,10 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const broker = yield* CodexManagedTokenBroker.CodexManagedTokenBroker; + const environmentIdentity = yield* ServerEnvironment.ServerEnvironmentIdentity; + const environmentId = yield* environmentIdentity.getEnvironmentId; + const invocation = yield* resolveSelfInvocation(); const auth = yield* makeCodexChatGptAuth({ instanceId: options.instanceId, defaultReturnUrl: new URL( @@ -55,6 +60,19 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func ); const homePath = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; const resolve = Effect.gen(function* () { + // Establish renewal availability before auth.access can consume a refresh token. + const endpoint = yield* broker.endpoint.pipe( + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "runtime", + detail: + "Could not prepare the private managed Codex token listener. Restart T3 and retry.", + cause, + }), + ), + ); const executable = yield* installation.acquire().pipe( Effect.mapError( () => @@ -66,6 +84,14 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func ), ); const credentials = yield* auth.access; + const profileKey = yield* auth.profileKey(credentials); + const authArgs = selfInvocationArgs(invocation, [ + "__managed-chatgpt-token", + endpoint, + options.instanceId, + environmentId, + profileKey, + ]); yield* materializeCodexShadowHome(homeLayout).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -91,9 +117,9 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func // Ambient CLI overrides cannot redirect a T3-owned token to a different provider. const environment: NodeJS.ProcessEnv = { ...options.environment, - ACCESS_TOKEN: credentials.accessToken, CODEX_HOME: homePath, }; + delete environment.ACCESS_TOKEN; delete environment.T3CODE_CODEX_LAUNCH_ARGS; delete environment.OPENAI_API_KEY; delete environment.OPENAI_BASE_URL; @@ -103,10 +129,10 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func setupMode: "managed", binaryPath: executable.executablePath, homePath, - launchArgs: managedCodexLaunchArgs, + launchArgs: managedCodexLaunchArgs(profileKey, invocation.command, authArgs), }), environment, - revision: credentials.accessToken, + revision: profileKey, } satisfies CodexEffectiveRuntime; }); return { auth, resolve, installation, homePath, homeLayout }; diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 0be07317402f..db8f42faea7f 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -146,7 +146,7 @@ it.layer(layerTest)("CodexDriver", (it) => { earliestRefreshAt: null, scopes: ["chatgpt.tokens.use.direct"], }); - const replaceAccount = store.set(new TextEncoder().encode(replacement)); + const replaceAccount = store.set(new TextEncoder().encode(replacement)).pipe(Effect.orDie); const executable = { executablePath: "/user/bin/codex", managedVersionDirectory: null, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 5452560d2891..5462b13867b7 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -53,6 +53,7 @@ import { import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; import { codexUsageReader } from "./codexUsage.ts"; import * as CodexInstallation from "../CodexInstallation.ts"; +import * as CodexManagedTokenBroker from "../CodexManagedTokenBroker.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -97,7 +98,8 @@ export type CodexDriverEnv = | ServerConfig.ServerConfig | ServerSecretStore.ServerSecretStore | ServerEnvironment.ServerEnvironmentIdentity - | CodexInstallation.CodexInstallation; + | CodexInstallation.CodexInstallation + | CodexManagedTokenBroker.CodexManagedTokenBroker; export const CodexDriver: ProviderDriver< CodexSettings, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index ad41a94b4823..1f4a238fa34c 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -1,4 +1,9 @@ -import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderSetupError, + TextGenerationError, + type CodexSettings, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -128,10 +133,21 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu effective.environment, managedAuth, ); - const models = yield* chatGptModels( - effective.environment.ACCESS_TOKEN!, - draft.models, - ).pipe(Effect.provideService(HttpClient.HttpClient, http)); + const credentials = yield* runtime.auth.access; + // Another server can replace the saved profile while the native probe runs. + // Never send that account's token with the launch profile's catalog labels. + if ( + (yield* runtime.auth.profileKey(credentials)) !== effective.revision || + (yield* runtime.auth.profileKey(saved.value)) !== effective.revision + ) + return yield* new ProviderSetupError({ + instanceId, + operation: "models", + detail: "The ChatGPT connection changed while checking Codex. Retry.", + }); + const models = yield* chatGptModels(credentials.accessToken, draft.models).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); return { ...draft, models }; }), ), @@ -225,7 +241,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu Effect.forkScoped, ); const resolveRuntime = runtime.auth.controller.withAccess!(runtime.resolve); - // Launch settings resolve per session from the signed-in token. The registry + // Launch settings bind the refresh command to the signed-in profile. The registry // already wraps openSession in withAccess, so resolve without re-entering it. const orchestrationAdapter = yield* createCodexAdapterV2(input, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 01a3bdc63b93..48711552b43c 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -1,5 +1,6 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; +import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, it } from "@effect/vitest"; @@ -154,6 +155,11 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { const layerBaseDeps = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-test", }).pipe( + Layer.provideMerge( + Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({ + endpoint: Effect.succeed("unused-private-pipe"), + }), + ), Layer.provideMerge(NodeServices.layer), Layer.provideMerge( Layer.mock(CodexInstallation.CodexInstallation)({ diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index a278470c286d..8bcde5f9420c 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -1,6 +1,7 @@ import { vi } from "vite-plus/test"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; +import * as CodexManagedTokenBroker from "./CodexManagedTokenBroker.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert, expect } from "@effect/vitest"; @@ -307,6 +308,9 @@ const awaitPersistedProvider = ( const layerTestNodeServices = Layer.mergeAll( ProviderLatestVersions.layer, NodeServices.layer, + Layer.mock(CodexManagedTokenBroker.CodexManagedTokenBroker)({ + endpoint: Effect.succeed("unused-private-pipe"), + }), Layer.mock(CodexInstallation.CodexInstallation)({ managedDirectory: "unused-managed-installation", }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 48d701103c07..403b30af1c71 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -123,6 +123,7 @@ import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as AuthHttp from "./auth/http.ts"; import * as ReplayMarkers from "./auth/replayMarkers.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as CodexManagedTokenBroker from "./provider/CodexManagedTokenBroker.ts"; import * as WebhookRoute from "./scheduledTasks/webhookRoute.ts"; import * as RelayDeliveryProof from "./scheduledTasks/RelayDeliveryProof.ts"; import * as HeldHooksWaker from "./relay/HeldHooksWaker.ts"; @@ -653,6 +654,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), Layer.provideMerge(layerRepositoryIdentityResolver), + Layer.provideMerge(CodexManagedTokenBroker.layer), Layer.provideMerge(layerServerEnvironment), Layer.provideMerge(layerAuth), Layer.provideMerge(ServerSecretStore.layer), From 47819dae181ec3a11ea4e04a6b3c1bd7a84eae66 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:42:57 +0000 Subject: [PATCH 3/3] fix(server): preserve managed Codex provider identity across sign-ins --- apps/server/src/provider/CodexManagedRuntime.test.ts | 3 ++- apps/server/src/provider/CodexManagedRuntime.ts | 11 +++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index d7a09783c54d..8f8408bdaee1 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -138,8 +138,9 @@ it.live.each( assert.isUndefined(effective.environment.OPENAI_BASE_URL); assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); const args = codexAppServerArgs(effective.config.launchArgs); + assert.include(args, 'model_provider="openai_token_sharing"'); const provider = args.find((arg) => - arg.startsWith("model_providers.openai_token_sharing_"), + arg.startsWith("model_providers.openai_token_sharing="), )!; assert.include(provider, 'base_url="https://api.openai.com/v1"'); assert.include(provider, 'model_catalog_url="https://api.openai.com/v1/models"'); diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts index 32a854e028fd..3704c919c279 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -19,12 +19,11 @@ export interface CodexEffectiveRuntime { readonly revision: string; } const decodeSettings = Schema.decodeSync(CodexSettings); -const managedCodexLaunchArgs = (profileKey: string, command: string, args: ReadonlyArray) => +const managedCodexLaunchArgs = (command: string, args: ReadonlyArray) => [ - `model_provider="openai_token_sharing_${profileKey}"`, - // Codex recursively merges -c tables. A profile-specific key avoids inheriting - // auth fields or headers from a shared openai_token_sharing provider. - `model_providers.openai_token_sharing_${profileKey}={name="OpenAI Token Sharing",base_url="https://api.openai.com/v1",model_catalog_url="https://api.openai.com/v1/models",wire_api="responses",requires_openai_auth=false,supports_websockets=false,auth={command=${JSON.stringify(command)},args=${JSON.stringify(args)},timeout_ms=${COMMAND_TIMEOUT_MS},refresh_interval_ms=0}}`, + 'model_provider="openai_token_sharing"', + // Codex persists this provider ID in rollouts and needs it unchanged to resume. + `model_providers.openai_token_sharing={name="OpenAI Token Sharing",base_url="https://api.openai.com/v1",model_catalog_url="https://api.openai.com/v1/models",wire_api="responses",requires_openai_auth=false,supports_websockets=false,auth={command=${JSON.stringify(command)},args=${JSON.stringify(args)},timeout_ms=${COMMAND_TIMEOUT_MS},refresh_interval_ms=0}}`, "features.api_key_model_discovery=true", "features.multi_agent=false", "features.multi_agent_v2=false", @@ -129,7 +128,7 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func setupMode: "managed", binaryPath: executable.executablePath, homePath, - launchArgs: managedCodexLaunchArgs(profileKey, invocation.command, authArgs), + launchArgs: managedCodexLaunchArgs(invocation.command, authArgs), }), environment, revision: profileKey,