diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 063093874503d..3796866f1ba66 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -5,7 +5,8 @@ * 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. + * the real CLI in ./binCli.ts. Managed Codex re-runs `t3 codex-managed-token` + * on a short interval to renew its bearer, so it dispatches here as well. */ import { isEntrypoint } from "./entrypoint.ts"; @@ -20,6 +21,9 @@ if ( if (command === "acp-mcp-bridge" || command === "acp-mcp-call") { const { runAcpMcpCliFastPath } = await import("./mcp/AcpMcpStdioBridge.ts"); await runAcpMcpCliFastPath(command, process.argv.slice(3)); + } else if (command === "codex-managed-token") { + const { runCodexManagedTokenCommand } = await import("./provider/codexManagedTokenCommand.ts"); + await runCodexManagedTokenCommand(); } else { const { runCli } = await import("./binCli.ts"); runCli(); diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 76035ded632b6..c957bd176a6ff 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -8,6 +8,7 @@ import * as CliError from "effect/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { acpMcpBridgeCommand, acpMcpCallCommand } from "./cli/acpMcpBridge.ts"; +import { codexManagedTokenCommand } from "./cli/codexManagedToken.ts"; import { authCommand } from "./cli/auth.ts"; import { appCommand } from "./cli/app.ts"; import { browserCommand } from "./cli/browser.ts"; @@ -67,6 +68,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => ), acpMcpBridgeCommand, acpMcpCallCommand, + codexManagedTokenCommand, startCommand, serveCommand, appCommand, diff --git a/apps/server/src/cli/codexManagedToken.ts b/apps/server/src/cli/codexManagedToken.ts new file mode 100644 index 0000000000000..b2034cd1af624 --- /dev/null +++ b/apps/server/src/cli/codexManagedToken.ts @@ -0,0 +1,21 @@ +import * as Effect from "effect/Effect"; +import { Command } from "effect/cli"; + +import { + codexManagedTokenCommandName, + runCodexManagedTokenCommand, +} from "../provider/codexManagedTokenCommand.ts"; + +/** + * `t3 codex-managed-token` — internal credential command managed Codex runs to + * renew its bearer from the provider's loopback bridge. + * + * Real invocations dispatch through the bin.ts fast path before the CLI graph + * loads; this definition keeps the command wired for anything that drives the + * full CLI programmatically. + */ +export const codexManagedTokenCommand = Command.make(codexManagedTokenCommandName).pipe( + Command.withDescription("Print the current managed Codex credential for Codex."), + Command.unlisted, + Command.withHandler(() => Effect.promise(() => runCodexManagedTokenCommand())), +); diff --git a/apps/server/src/provider/CodexChatGptAuth.test.ts b/apps/server/src/provider/CodexChatGptAuth.test.ts index 96465947b651e..39c4985a40f8c 100644 --- a/apps/server/src/provider/CodexChatGptAuth.test.ts +++ b/apps/server/src/provider/CodexChatGptAuth.test.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Fiber from "effect/Fiber"; import * as Deferred from "effect/Deferred"; +import * as ScopedCache from "effect/ScopedCache"; import * as TestClock from "effect/testing/TestClock"; import { subscribeChatGptHandoff } from "./CodexChatGptHandoff.ts"; import { FetchHttpClient } from "effect/http"; @@ -20,6 +21,7 @@ import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; +import { makeCodexManagedTokenSource } from "./CodexManagedTokenSource.ts"; const assertSameCallback = (actual: string | null, expected: string | null) => { const left = new URL(actual!); @@ -221,10 +223,16 @@ const makeHarnessFor = Effect.fnUntraced(function* ( }), flush: Effect.void, }); + const tokenSources = yield* ScopedCache.make({ + capacity: 1, + lookup: (clientId: string): ReturnType => + makeCodexManagedTokenSource(instanceId, clientId, auth.access), + }); const auth = yield* makeCodexChatGptAuth({ instanceId, discoveryUrl: `${origin}/discovery`, resource: `${origin}/v1`, + onCredentialsReplaced: ScopedCache.invalidateAll(tokenSources), }).pipe( Effect.provideService(AnalyticsService.AnalyticsService, analytics), Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), @@ -278,6 +286,10 @@ const makeHarnessFor = Effect.fnUntraced(function* ( }); return { auth, + tokenSource: Effect.gen(function* () { + const credentials = Option.getOrThrow(yield* auth.read); + return yield* ScopedCache.get(tokenSources, credentials.clientId); + }), analyticsEvents, secrets, environmentId, @@ -386,6 +398,16 @@ const makeHarnessFor = Effect.fnUntraced(function* ( }; }); const makeHarness = makeHarnessFor(instanceId); +const readManagedToken = (source: { url: string; secret: string }, clientId: string) => + Effect.promise(async () => { + const response = await fetch(source.url, { + headers: { + authorization: `Bearer ${source.secret}`, + "x-t3-codex-account": clientId, + }, + }); + return { status: response.status, token: await response.text() }; + }); const provision = (effect: Effect.Effect) => effect.pipe( Effect.scoped, @@ -1359,6 +1381,68 @@ it.effect( ), ); +it.effect.each(["cancel", "expired code", "wrong subject", "invalid nonce"])( + "preserves the live credential bridge after reconnect fails: %s", + (failure) => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const before = Option.getOrThrow(yield* h.auth.read); + const source = yield* h.tokenSource; + assert.deepEqual(yield* readManagedToken(source, before.clientId), { + status: 200, + token: before.accessToken, + }); + if (failure === "cancel") { + const { waiting } = yield* h.startRemote(); + yield* h.auth.controller.cancel("owner", waiting.flowId!); + yield* h.phase("cancelled"); + } else { + if (failure === "expired code") h.setCodeError("invalid_grant"); + if (failure === "wrong subject") h.setIdentity("another-user", "another@example.test"); + if (failure === "invalid nonce") h.setInvalidNonce(); + yield* h.signIn; + yield* h.phase("failed"); + } + assert.deepEqual(Option.getOrThrow(yield* h.auth.read), before); + assert.deepEqual(yield* readManagedToken(source, before.clientId), { + status: 200, + token: before.accessToken, + }); + }), + ), +); + +it.effect( + "successful same-account reconnect retires the old bridge and issues a fresh capability", + () => + provision( + Effect.gen(function* () { + const h = yield* makeHarness; + yield* h.signIn; + yield* h.phase("succeeded"); + const source = yield* h.tokenSource; + const { waiting, callbackUrl } = yield* h.startRemote(); + // Token reads wait for the auth lock while sign-in is pending, but the + // listener itself must remain available until credentials are saved. + const pending = yield* Effect.promise(() => fetch(source.url)); + assert.strictEqual(pending.status, 401); + yield* h.auth.controller.complete("owner", { flowId: waiting.flowId!, callbackUrl }); + yield* h.phase("succeeded"); + const old = yield* Effect.tryPromise(() => fetch(source.url)).pipe(Effect.result); + assert.strictEqual(old._tag, "Failure"); + const replacement = yield* h.tokenSource; + assert.notStrictEqual(replacement.secret, source.secret); + assert.deepEqual(yield* readManagedToken(replacement, "oaiapp_test"), { + status: 200, + token: Option.getOrThrow(yield* h.auth.read).accessToken, + }); + }), + ), +); + it.effect("declined sharing on an older profile does not replace the active account", () => provision( Effect.gen(function* () { @@ -1370,6 +1454,7 @@ it.effect("declined sharing on an older profile does not replace the active acco yield* h.changeAccount; yield* h.phase("succeeded"); const before = h.storedRecords(); + const source = yield* h.tokenSource; h.setIdentity("user-test", "hidden@example.test"); h.setCallbackClientId("oaiapp_test"); h.declineSharing(); @@ -1382,6 +1467,10 @@ it.effect("declined sharing on an older profile does not replace the active acco Option.getOrThrow(yield* h.auth.read), before.find((record) => record.clientId === "oaiapp_other_account"), ); + assert.deepEqual(yield* readManagedToken(source, "oaiapp_other_account"), { + status: 200, + token: Option.getOrThrow(yield* h.auth.read).accessToken, + }); }), ), ); diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 2eda1e31533de..89298aec2c14b 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -106,6 +106,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* readonly defaultReturnUrl?: string; readonly reconnectProfile?: ChatGptReconnectProfile | null; readonly telemetryFlow?: "direct" | "primary_handoff"; + /** Retire issued runtime credentials on sign-in, logout or credential replacement, not renewal. */ + readonly onCredentialsReplaced?: Effect.Effect; }) { const analytics = yield* Effect.serviceOption(AnalyticsService.AnalyticsService); const settings = yield* Effect.serviceOption(ServerSettings.ServerSettingsService); @@ -664,6 +666,12 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* const scopes = tokens.scope.split(/\s+/).filter(Boolean); yield* withSessionLock( Effect.gen(function* () { + const active = yield* read; + const activate = scopes.includes(REQUIRED_SCOPE) || Option.isNone(active); + // Failed sign-in leaves the running connection intact. A declined + // grant for another profile also leaves the active credentials alone. + if (activate || (Option.isSome(active) && active.value.clientId === clientId)) + yield* options.onCredentialsReplaced ?? Effect.void; yield* saveRegistration({ clientId, redirectUri, @@ -682,7 +690,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* scopes, ...identity, }, - scopes.includes(REQUIRED_SCOPE) || Option.isNone(yield* read), + activate, ); }), ); @@ -786,6 +794,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* ), ); const logout = Effect.gen(function* () { + yield* options.onCredentialsReplaced ?? Effect.void; const credentials = Option.getOrUndefined(yield* read); let confirmed = !credentials?.refreshToken; if (credentials?.refreshToken) { @@ -967,6 +976,7 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* "import", "The transferred identity conflicts with the saved ChatGPT connection.", ); + yield* options.onCredentialsReplaced ?? Effect.void; yield* saveRegistration(registration); yield* save(profile.credentials, true); }); @@ -985,6 +995,8 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* read, access, exportProfile, - revoke: lock.withPermit(withSessionLock(remove)), + revoke: lock.withPermit( + withSessionLock((options.onCredentialsReplaced ?? Effect.void).pipe(Effect.andThen(remove))), + ), }; }); diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 69d27f1377b6b..59ec8dc666efc 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -1,5 +1,8 @@ -// @effect-diagnostics nodeBuiltinImport:off - checks the host's default Codex directory without writing to it. +// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - checks the host's default Codex directory without writing to it. import * as NodeOS from "node:os"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; +import * as NodeURL from "node:url"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { CodexSettings, EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; @@ -11,17 +14,36 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { HttpClient, HttpClientResponse } from "effect/http"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as ServerConfig from "../config.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; import { makeCodexManagedRuntime } from "./CodexManagedRuntime.ts"; +import { makeCodexManagedTokenSource } from "./CodexManagedTokenSource.ts"; import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; import { codexAppServerArgs } from "./codexLaunchArgs.ts"; import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeSettings = Schema.decodeSync(CodexSettings); +const serverEntrypoint = NodeURL.fileURLToPath(new URL("../bin.ts", import.meta.url)); +it.effect("closes the managed credential bridge with its provider scope", () => + Effect.gen(function* () { + const source = yield* makeCodexManagedTokenSource( + ProviderInstanceId.make("closed-codex"), + "dummy-account", + Effect.succeed({ accessToken: "dummy-token", clientId: "dummy-account" }), + ).pipe(Effect.scoped); + const reached = yield* Effect.promise(() => + fetch(source.url).then( + () => true, + () => false, + ), + ); + assert.isFalse(reached); + }), +); it.effect("managed home defaults to the global Codex home and honors configured home paths", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -79,6 +101,7 @@ it.effect.each( getOrCreateRandom: () => Effect.die("unused"), }); let leases = 0; + let renewals = 0; const executable = { executablePath: source === "managed" ? "/isolated/tools/codex/0.156.1/bin/codex" : "/user/bin/codex", @@ -149,7 +172,143 @@ it.effect.each( ); assert.isFalse(yield* fs.exists(path.join(effective.config.homePath, "auth.json"))); } - assert.strictEqual(effective.environment.ACCESS_TOKEN, "dummy-owned-access"); + // Emulate the same app-server making later requests, without resolving + // or restarting its runtime. Run its configured credential command. + const authSetting = (name: string) => { + const prefix = `model_providers.openai_token_sharing.auth.${name}=`; + const value = codexAppServerArgs(effective.config.launchArgs).find((arg) => + arg.startsWith(prefix), + ); + assert.isDefined(value); + return JSON.parse(value!.slice(prefix.length)); + }; + // The command is T3's own hidden subcommand, not a Node `-e` script the + // packaged executable cannot evaluate. + assert.equal(authSetting("command"), process.execPath); + assert.deepEqual(authSetting("args"), [serverEntrypoint, "codex-managed-token"]); + const requestToken = Effect.promise(async () => { + const { stdout } = await NodeUtil.promisify(NodeChildProcess.execFile)( + authSetting("command"), + authSetting("args"), + { env: effective.environment, timeout: 25_000 }, + ); + return stdout; + }); + assert.equal(yield* requestToken, "dummy-owned-access"); + const expired = { ...JSON.parse(json), expiresAt: 0 }; + yield* store.set(new TextEncoder().encode(JSON.stringify(expired))); + // The credential commands themselves must renew an expired token, + // even when no provider status read runs first. + assert.deepEqual( + yield* Effect.all([requestToken, requestToken], { concurrency: "unbounded" }), + ["dummy-renewed-access", "dummy-renewed-access"], + ); + assert.strictEqual(renewals, 1); + assert.isUndefined(effective.environment.ACCESS_TOKEN); + assert.notInclude(JSON.stringify(effective.environment), "dummy-owned-access"); + const rejected = yield* Effect.promise(() => + fetch(effective.environment.T3CODE_MANAGED_CODEX_AUTH_URL!), + ); + assert.equal(rejected.status, 401); + yield* store.set( + new TextEncoder().encode( + JSON.stringify({ + ...expired, + clientId: "another-account", + accessToken: "dummy-other-account-access", + expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, + }), + ), + ); + const switched = yield* Effect.promise(() => + fetch(effective.environment.T3CODE_MANAGED_CODEX_AUTH_URL!, { + headers: { + Authorization: `Bearer ${effective.environment.T3CODE_MANAGED_CODEX_AUTH_SECRET}`, + "X-T3-Codex-Account": effective.environment.T3CODE_MANAGED_CODEX_AUTH_ACCOUNT!, + }, + }), + ); + assert.equal(switched.status, 409); + assert.equal(yield* Effect.promise(() => switched.text()), ""); + // A child holding the old bridge secret cannot expand its authority + // by naming the account that is now selected in T3. + const forged = yield* Effect.promise(() => + fetch(effective.environment.T3CODE_MANAGED_CODEX_AUTH_URL!, { + headers: { + Authorization: `Bearer ${effective.environment.T3CODE_MANAGED_CODEX_AUTH_SECRET}`, + "X-T3-Codex-Account": "another-account", + }, + }), + ); + assert.equal(forged.status, 409); + assert.equal(yield* Effect.promise(() => forged.text()), ""); + const replacement = yield* runtime.resolve; + assert.notEqual( + replacement.environment.T3CODE_MANAGED_CODEX_AUTH_SECRET, + effective.environment.T3CODE_MANAGED_CODEX_AUTH_SECRET, + ); + const replacementToken = yield* Effect.promise(async () => { + const { stdout } = await NodeUtil.promisify(NodeChildProcess.execFile)( + authSetting("command"), + authSetting("args"), + { env: replacement.environment, timeout: 25_000 }, + ); + return stdout; + }); + assert.equal(replacementToken, "dummy-other-account-access"); + const oldListenerReached = yield* Effect.promise(() => + fetch(effective.environment.T3CODE_MANAGED_CODEX_AUTH_URL!).then( + () => true, + () => false, + ), + ); + assert.isFalse(oldListenerReached); + yield* runtime.auth.controller.logout(Effect.void); + const signedOutListenerReached = yield* Effect.promise(() => + fetch(replacement.environment.T3CODE_MANAGED_CODEX_AUTH_URL!).then( + () => true, + () => false, + ), + ); + assert.isFalse(signedOutListenerReached); + // Model a later sign-in to the same registration without replacing + // the provider. Its old capability must not become usable again. + yield* store.set( + new TextEncoder().encode( + JSON.stringify({ + ...expired, + clientId: "another-account", + accessToken: "dummy-reconnected-access", + expiresAt: (yield* Clock.currentTimeMillis) + 3_600_000, + }), + ), + ); + const reconnected = yield* runtime.resolve; + assert.notEqual( + reconnected.environment.T3CODE_MANAGED_CODEX_AUTH_SECRET, + replacement.environment.T3CODE_MANAGED_CODEX_AUTH_SECRET, + ); + const oldCapabilityStatus = yield* Effect.promise(() => + fetch(replacement.environment.T3CODE_MANAGED_CODEX_AUTH_URL!, { + headers: { + Authorization: `Bearer ${replacement.environment.T3CODE_MANAGED_CODEX_AUTH_SECRET}`, + "X-T3-Codex-Account": "another-account", + }, + }).then( + (response) => response.status, + () => null, + ), + ); + assert.notEqual(oldCapabilityStatus, 200); + const reconnectedToken = yield* Effect.promise(async () => { + const { stdout } = await NodeUtil.promisify(NodeChildProcess.execFile)( + authSetting("command"), + authSetting("args"), + { env: reconnected.environment, timeout: 25_000 }, + ); + return stdout; + }); + assert.equal(reconnectedToken, "dummy-reconnected-access"); assert.isUndefined(effective.environment.OPENAI_API_KEY); assert.isUndefined(effective.environment.OPENAI_BASE_URL); assert.isUndefined(effective.environment.T3CODE_CODEX_LAUNCH_ARGS); @@ -194,19 +353,29 @@ 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, request.url.endsWith("/revoke") ? new Response(null, { status: 200 }) - : Response.json({ - issuer: "https://auth.openai.com", - authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", - token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", - jwks_uri: "https://auth.openai.com/jwks", - revocation_endpoint: "https://auth.openai.com/revoke", - }), + : request.url.endsWith("/token") + ? Response.json({ + access_token: "dummy-renewed-access", + refresh_token: "dummy-rotated-refresh", + expires_in: 3600, + token_type: "Bearer", + scope: "chatgpt.tokens.use.direct", + }) + : Response.json({ + issuer: "https://auth.openai.com", + authorization_endpoint: "https://auth.openai.com/api/accounts/authorize", + token_endpoint: "https://auth.openai.com/api/accounts/oauth/token", + jwks_uri: "https://auth.openai.com/jwks", + revocation_endpoint: "https://auth.openai.com/revoke", + }), ); }), ), @@ -214,6 +383,7 @@ it.effect.each( ); }).pipe( Effect.scoped, + Effect.provideService(HostProcess.Arguments, [process.execPath, serverEntrypoint]), Effect.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-managed-runtime-", diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts index fd8f3d7a9cd93..bba14464cc3b3 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -4,8 +4,16 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as ScopedCache from "effect/ScopedCache"; +import { + resolveSelfInvocation, + selfInvocationArgs, + type SelfInvocation, +} from "@t3tools/shared/nodeRuntime"; import * as ServerConfig from "../config.ts"; import * as CodexInstallation from "./CodexInstallation.ts"; +import { makeCodexManagedTokenSource } from "./CodexManagedTokenSource.ts"; +import { codexManagedTokenCommandName } from "./codexManagedTokenCommand.ts"; import { makeCodexChatGptAuth } from "./CodexChatGptAuth.ts"; import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts"; @@ -16,21 +24,27 @@ export interface CodexEffectiveRuntime { } const decodeSettings = Schema.decodeSync(CodexSettings); // Managed sign-in stores tokens in T3's credential store and never writes native auth.json. -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(" "); +// The credential command is this T3 install's own hidden subcommand: the packaged +// executable has no Node CLI, so `node -e` is not available to every host. +const managedCodexLaunchArgs = (invocation: SelfInvocation) => + [ + '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.auth.command=${JSON.stringify(invocation.command)}`, + `model_providers.openai_token_sharing.auth.args=${JSON.stringify(selfInvocationArgs(invocation, [codexManagedTokenCommandName]))}`, + "model_providers.openai_token_sharing.auth.timeout_ms=25000", + "model_providers.openai_token_sharing.auth.refresh_interval_ms=30000", + '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.replaceAll("'", "'\"'\"'")}'`) + .join(" "); export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(function* (options: { readonly instanceId: ProviderInstanceId; @@ -42,13 +56,22 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + // Only the selected account needs a bridge. The cache owns each listener's + // scope, so replacing an account closes its previous endpoint immediately. + const tokenSources = yield* ScopedCache.make({ + capacity: 1, + lookup: (clientId: string): ReturnType => + makeCodexManagedTokenSource(options.instanceId, clientId, auth.access), + }); const auth = yield* makeCodexChatGptAuth({ instanceId: options.instanceId, defaultReturnUrl: new URL( "/welcome", config.devUrl ?? `http://localhost:${config.port}`, ).toString(), + onCredentialsReplaced: ScopedCache.invalidateAll(tokenSources), }); + const launchArgs = managedCodexLaunchArgs(yield* resolveSelfInvocation()); const homeLayout = yield* resolveManagedCodexHomeLayout( config.stateDir, options.instanceId, @@ -67,6 +90,7 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func ), ); const credentials = yield* auth.access; + const source = yield* ScopedCache.get(tokenSources, credentials.clientId); yield* materializeCodexShadowHome(homeLayout).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -92,9 +116,13 @@ 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, + T3CODE_MANAGED_CODEX_AUTH_URL: source.url, + T3CODE_MANAGED_CODEX_AUTH_SECRET: source.secret, + T3CODE_MANAGED_CODEX_AUTH_ACCOUNT: credentials.clientId, + ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}), CODEX_HOME: homePath, }; + delete environment.ACCESS_TOKEN; delete environment.T3CODE_CODEX_LAUNCH_ARGS; delete environment.OPENAI_API_KEY; delete environment.OPENAI_BASE_URL; @@ -104,7 +132,7 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func setupMode: "managed", binaryPath: executable.executablePath, homePath, - launchArgs: managedCodexLaunchArgs, + launchArgs, }), environment, revision: credentials.accessToken, diff --git a/apps/server/src/provider/CodexManagedTokenSource.ts b/apps/server/src/provider/CodexManagedTokenSource.ts new file mode 100644 index 0000000000000..10da743f22026 --- /dev/null +++ b/apps/server/src/provider/CodexManagedTokenSource.ts @@ -0,0 +1,95 @@ +// @effect-diagnostics nodeBuiltinImport:off - scope-owned loopback bridge for Codex's credential command. +import * as NodeCrypto from "node:crypto"; +import * as NodeHttp from "node:http"; +import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +export const makeCodexManagedTokenSource = Effect.fn("makeCodexManagedTokenSource")(function* ( + instanceId: ProviderInstanceId, + clientId: string, + access: Effect.Effect< + { readonly accessToken: string; readonly clientId: string }, + ProviderSetupError + >, +) { + const context = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(context); + const secret = NodeCrypto.randomBytes(32).toString("base64url"); + const digest = (value: string) => NodeCrypto.createHash("sha256").update(value).digest(); + const expectedAuthorization = digest(`Bearer ${secret}`); + const server = yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const server = NodeHttp.createServer((request, response) => { + if (request.method !== "GET" || request.url !== "/token") { + response.writeHead(404).end(); + return; + } + // Fixed-length digests keep the comparison constant-time for any header length. + if ( + !NodeCrypto.timingSafeEqual( + digest(request.headers.authorization ?? ""), + expectedAuthorization, + ) + ) { + response.writeHead(401).end(); + return; + } + // The capability belongs to the launch account, not whichever + // account a holder of its secret names in a later request. + if (request.headers["x-t3-codex-account"] !== clientId) { + response.writeHead(409).end(); + return; + } + const abort = new AbortController(); + response.once("close", () => abort.abort()); + void runPromise(access, { signal: abort.signal }).then( + (credentials) => { + if (response.destroyed) return; + if (credentials.clientId !== clientId) { + response.writeHead(409).end(); + return; + } + if (!response.destroyed) + response + .writeHead(200, { + "content-type": "text/plain", + "cache-control": "no-store", + }) + .end(credentials.accessToken); + }, + () => { + if (!response.destroyed) response.writeHead(503).end(); + }, + ); + }); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + catch: (cause) => + new ProviderSetupError({ + instanceId, + operation: "runtime", + detail: "Could not start the managed Codex credential bridge.", + cause, + }), + }), + (server) => + Effect.promise( + () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + ), + ); + const address = server.address(); + if (address === null || typeof address === "string") + return yield* new ProviderSetupError({ + instanceId, + operation: "runtime", + detail: "Could not resolve the managed Codex credential bridge.", + }); + return { url: `http://127.0.0.1:${address.port}/token`, secret }; +}); diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 7ac25a2e6bc13..92446d16292e2 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -210,7 +210,8 @@ it.layer(layerTest)("CodexDriver", (it) => { `providers/codex/${instanceId}/shadow`, ); yield* Deferred.await(observedAccount); - // Sessions launch the T3-installed Codex with the account's token, not ambient credentials. + // Sessions launch the T3-installed Codex with a bridge to the account's token, not a + // fixed copy of it or ambient credentials. const threadId = ThreadId.make("managed-account-thread"); yield* instance.orchestrationAdapter .openSession({ @@ -228,7 +229,12 @@ 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.environment.T3CODE_MANAGED_CODEX_AUTH_URL).toMatch( + /^http:\/\/127\.0\.0\.1:\d+\/token$/, + ); + expect(launch.environment.T3CODE_MANAGED_CODEX_AUTH_ACCOUNT).toBe("oaiapp_test"); + expect(JSON.stringify(launch.environment)).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); diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 374f177fd9cae..79c001cce7bf4 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -128,8 +128,9 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu effective.environment, managedAuth, ); + // The launch environment carries only the bridge credential, never the token. const models = yield* chatGptModels( - effective.environment.ACCESS_TOKEN!, + (yield* runtime.auth.access).accessToken, draft.models, ).pipe(Effect.provideService(HttpClient.HttpClient, http)); return { ...draft, models }; diff --git a/apps/server/src/provider/codexManagedTokenCommand.ts b/apps/server/src/provider/codexManagedTokenCommand.ts new file mode 100644 index 0000000000000..bd048a02c45ea --- /dev/null +++ b/apps/server/src/provider/codexManagedTokenCommand.ts @@ -0,0 +1,27 @@ +// @effect-diagnostics globalFetch:off - import-free CLI fast path that runs before any Effect runtime exists. +/** + * `t3 codex-managed-token` — internal credential command for managed Codex. + * + * Codex runs it again when its cached bearer expires or receives a 401. It + * reads the provider's loopback bridge from the environment and prints the + * current token, so the child holds a bridge credential and never a fixed copy + * of the provider token. Kept free of imports: bin.ts dispatches it before the + * CLI module graph loads, and Codex re-runs it on a short interval. + */ +export const codexManagedTokenCommandName = "codex-managed-token"; + +export async function runCodexManagedTokenCommand(): Promise { + try { + const response = await fetch(process.env.T3CODE_MANAGED_CODEX_AUTH_URL ?? "", { + headers: { + Authorization: `Bearer ${process.env.T3CODE_MANAGED_CODEX_AUTH_SECRET}`, + "X-T3-Codex-Account": process.env.T3CODE_MANAGED_CODEX_AUTH_ACCOUNT ?? "", + }, + }); + if (!response.ok) throw new Error(); + process.stdout.write(await response.text()); + } catch { + process.stderr.write("Could not renew managed Codex credentials.\n"); + process.exitCode = 1; + } +}