diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 03b282a38384..9b9c6d8fd45c 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -20,6 +20,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-token") { + const { runCodexTokenCli } = await import("./cli/codexToken.ts"); + runCodexTokenCli(process.argv.slice(3)); } else { // Before anything reads stdin: the desktop app's Windows launcher starts // the CLI without one. Never for the fast paths, whose stdin is a pipe. diff --git a/apps/server/src/binCli.ts b/apps/server/src/binCli.ts index 2f7c24536caa..ab3caa419162 100644 --- a/apps/server/src/binCli.ts +++ b/apps/server/src/binCli.ts @@ -1,4 +1,5 @@ import { doctorCommand } from "./cli/doctor.ts"; +import { codexTokenCommand } from "./cli/codexToken.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -83,6 +84,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => uninstallCommand, serviceLauncherCommand, claudeHistoryCommand, + codexTokenCommand, sshHelperCommand, servicePreflightCommand, diff --git a/apps/server/src/cli/codexToken.test.ts b/apps/server/src/cli/codexToken.test.ts new file mode 100644 index 000000000000..e49cd705a861 --- /dev/null +++ b/apps/server/src/cli/codexToken.test.ts @@ -0,0 +1,84 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/cli"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ProviderCredentialStore from "../provider/ProviderCredentialStore.ts"; +import { codexTokenCommand } from "./codexToken.ts"; + +it.effect( + "the Codex token helper reads rotated credentials, rejects profile replacement, and stops after sign-out", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const instanceId = ProviderInstanceId.make("helper-account"); + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); + const run = Command.runWith(codexTokenCommand, { version: "0.0.0" })([ + "--base-dir", + config.baseDir, + "--instance-id", + instanceId, + "--profile-id", + "oaiapp_helper", + ]); + for (const accessToken of ["initial-access", "renewed-access"]) { + yield* store.set( + new TextEncoder().encode( + JSON.stringify({ + clientId: "oaiapp_helper", + accessToken, + refreshToken: "dummy-refresh", + expiresAt: Number.MAX_SAFE_INTEGER, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + subject: "test-user", + email: null, + }), + ), + ); + yield* run; + } + yield* store.set( + new TextEncoder().encode( + JSON.stringify({ + clientId: "oaiapp_replacement", + accessToken: "replacement-account-access", + refreshToken: null, + expiresAt: 0, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + subject: "another-user", + email: null, + }), + ), + ); + assert.isTrue(Exit.isFailure(yield* Effect.exit(run))); + assert.isTrue((yield* store.get)._tag === "Some"); + yield* store.remove; + assert.isTrue(Exit.isFailure(yield* Effect.exit(run))); + assert.deepEqual(yield* TestConsole.logLines, ["initial-access", "renewed-access"]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + ServerSecretStore.layer, + NetService.layer, + TestConsole.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + ).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-token-helper-" }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), +); diff --git a/apps/server/src/cli/codexToken.ts b/apps/server/src/cli/codexToken.ts new file mode 100644 index 000000000000..f9fe753ffed0 --- /dev/null +++ b/apps/server/src/cli/codexToken.ts @@ -0,0 +1,53 @@ +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import { Command, Flag } from "effect/cli"; +import { FetchHttpClient } from "effect/http"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { makeCodexChatGptAuth } from "../provider/CodexChatGptAuth.ts"; +import { authLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +export const codexTokenCommand = Command.make("__codex-token", { + ...authLocationFlags, + profileId: Flag.String("profile-id"), + instanceId: Flag.String("instance-id").pipe(Flag.withSchema(ProviderInstanceId)), +}).pipe( + Command.unlisted, + Command.withHandler((flags) => + Effect.gen(function* () { + const config = yield* resolveCliAuthConfig(flags, Option.some("None")); + yield* Effect.gen(function* () { + const auth = yield* makeCodexChatGptAuth({ + instanceId: flags.instanceId, + expectedProfileId: flags.profileId, + }); + const credentials = yield* auth.access; + yield* Console.log(credentials.accessToken); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSecretStore.layer, + ServerEnvironment.layerIdentity, + FetchHttpClient.layer, + ).pipe(Layer.provide(ServerConfig.layer(config))), + ), + ); + }).pipe(Effect.provideService(References.MinimumLogLevel, "None")), + ), +); + +export const runCodexTokenCli = (args: ReadonlyArray) => + Command.runWith(codexTokenCommand, { version: "0.0.0" })(args).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)), + NodeRuntime.runMain, + ); diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 8b9190c40d18..7738ad88a536 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -103,6 +103,7 @@ const decodeOAuthError = Schema.decodeUnknownSync(OAuthError); export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* (options: { readonly instanceId: ProviderInstanceId; + readonly expectedProfileId?: string; readonly discoveryUrl?: string; readonly resource?: string; readonly defaultReturnUrl?: string; @@ -734,6 +735,14 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* if (Option.isNone(saved)) return yield* failure("access", "Sign in with ChatGPT to use managed Codex."); let record = saved.value; + if ( + options.expectedProfileId !== undefined && + record.clientId !== options.expectedProfileId + ) + return yield* failure( + "access", + "This Codex session belongs to a replaced ChatGPT connection. Restart the session.", + ); if (!record.scopes.includes(REQUIRED_SCOPE)) return yield* failure( "sharing", diff --git a/apps/server/src/provider/CodexManagedRuntime.test.ts b/apps/server/src/provider/CodexManagedRuntime.test.ts index 8034e910d67a..eba08357fab0 100644 --- a/apps/server/src/provider/CodexManagedRuntime.test.ts +++ b/apps/server/src/provider/CodexManagedRuntime.test.ts @@ -121,11 +121,19 @@ 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.strictEqual(effective.accessToken, "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.isTrue( + args.some((arg) => + arg.startsWith("model_providers.openai_token_sharing.auth.command="), + ), + ); + assert.notInclude(args, 'model_providers.openai_token_sharing.env_key="ACCESS_TOKEN"'); + assert.include( args, 'model_providers.openai_token_sharing.base_url="https://api.openai.com/v1"', @@ -140,7 +148,10 @@ it.effect.each( 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( + args, + "model_providers.openai_token_sharing.requires_openai_auth=false", + ); assert.notInclude(effective.config.launchArgs, "dummy-owned-access"); assert.strictEqual(ambient.CODEX_HOME, "/user/.codex"); }).pipe(Effect.scoped); diff --git a/apps/server/src/provider/CodexManagedRuntime.ts b/apps/server/src/provider/CodexManagedRuntime.ts index 826d777f1bd7..e56e0a3d7360 100644 --- a/apps/server/src/provider/CodexManagedRuntime.ts +++ b/apps/server/src/provider/CodexManagedRuntime.ts @@ -1,5 +1,6 @@ import { resolveManagedCodexHomeLayout } from "./CodexManagedHome.ts"; import { CodexSettings, ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import { resolveSelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -12,9 +13,11 @@ import { materializeCodexShadowHome } from "./Drivers/CodexHomeLayout.ts"; export interface CodexEffectiveRuntime { readonly config: CodexSettings; readonly environment: NodeJS.ProcessEnv; - readonly revision: string; + readonly accessToken: string; } const decodeSettings = Schema.decodeSync(CodexSettings); +const encodeString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); +const encodeArguments = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); const managedCodexLaunchArgs = [ 'model_provider="openai_token_sharing"', 'model_providers.openai_token_sharing.name="OpenAI Token Sharing"', @@ -23,13 +26,9 @@ const managedCodexLaunchArgs = [ "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(" "); +]; export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(function* (options: { readonly instanceId: ProviderInstanceId; @@ -41,6 +40,15 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const invocation = yield* resolveSelfInvocation(); + const tokenArguments = selfInvocationArgs(invocation, [ + "__codex-token", + "--base-dir", + config.baseDir, + ...(config.devUrl ? ["--dev-url", config.devUrl.toString()] : []), + "--instance-id", + options.instanceId, + ]); const auth = yield* makeCodexChatGptAuth({ instanceId: options.instanceId, defaultReturnUrl: new URL( @@ -66,6 +74,16 @@ export const makeCodexManagedRuntime = Effect.fn("makeCodexManagedRuntime")(func ), ); const credentials = yield* auth.access; + + const launchArgs = [ + ...managedCodexLaunchArgs, + `model_providers.openai_token_sharing.auth.command=${encodeString(invocation.command)}`, + `model_providers.openai_token_sharing.auth.args=${encodeArguments([...tokenArguments, "--profile-id", credentials.clientId])}`, + "model_providers.openai_token_sharing.auth.timeout_ms=90000", + "model_providers.openai_token_sharing.auth.refresh_interval_ms=60000", + ] + .map((value) => `-c ${encodeString(value)}`) + .join(" "); yield* materializeCodexShadowHome(homeLayout).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), @@ -91,22 +109,23 @@ 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, + ELECTRON_RUN_AS_NODE: "1", }; delete environment.T3CODE_CODEX_LAUNCH_ARGS; delete environment.OPENAI_API_KEY; delete environment.OPENAI_BASE_URL; + delete environment.ACCESS_TOKEN; return { config: decodeSettings({ enabled: options.enabled, setupMode: "managed", binaryPath: executable.executablePath, homePath, - launchArgs: managedCodexLaunchArgs, + launchArgs, }), environment, - revision: credentials.accessToken, + accessToken: credentials.accessToken, } 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 2ccc555256f5..ea62bbd15a38 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -96,6 +96,65 @@ const noSpawn = ChildProcessSpawner.make(() => const encodeCredentials = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.layer(layerTest)("CodexDriver", (it) => { + it.effect( + "does not report an expired saved ChatGPT token as authenticated after a failed check", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("expired-managed-account"); + const store = yield* ProviderCredentialStore.make("codex-chatgpt", instanceId); + yield* store.set( + new TextEncoder().encode( + yield* encodeCredentials({ + clientId: "oaiapp_expired", + accessToken: "expired-access", + refreshToken: "renewable-refresh", + expiresAt: 0, + earliestRefreshAt: null, + scopes: ["chatgpt.tokens.use.direct"], + subject: "test-user", + email: "account@example.test", + }), + ), + ); + const instance = yield* CodexDriver.create({ + instanceId, + enabled: true, + displayName: "Codex", + environment: [], + config: { ...CodexDriver.defaultConfig(), setupMode: "managed" }, + }); + const snapshot = yield* instance.snapshot.refresh; + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.auth.email).toBe("account@example.test"); + expect(Option.isSome(yield* store.get)).toBe(true); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + ServerSecretStore.layer, + Layer.mock(CodexInstallation.CodexInstallation)({ + managedDirectory: "unused-managed-installation", + resolve: () => + Effect.succeed({ + executablePath: "/fixture/codex", + managedVersionDirectory: null, + source: "local", + version: "0.156.1", + }), + acquire: () => + Effect.fail( + new CodexInstallation.CodexInstallationError({ + operation: "acquire", + detail: "Runtime temporarily unavailable", + }), + ), + }), + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + ), + ), + ), + ); + it.effect("disconnect refreshes a restored managed account while its auth flow is idle", () => Effect.gen(function* () { const instanceId = ProviderInstanceId.make("restored-managed-account"); @@ -246,7 +305,8 @@ 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("__codex-token"); 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 ad41a94b4823..37b73250d7d2 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -1,5 +1,6 @@ import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; @@ -128,10 +129,9 @@ 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 models = yield* chatGptModels(effective.accessToken, draft.models).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); return { ...draft, models }; }), ), @@ -166,30 +166,39 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu Effect.catch(() => runtime.auth.read.pipe( Effect.orElseSucceed(() => Option.none()), - Effect.map((current) => ({ - ...base, - installed: true, - version: executable.value.version, - models: [], - auth: { - status: - Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct") - ? ("authenticated" as const) - : ("unauthenticated" as const), - label: "ChatGPT", - ...(Option.isSome(current) && - current.value.scopes.includes("chatgpt.tokens.use.direct") - ? { subscriptionSharing: true, profileId: current.value.clientId } - : {}), - ...(Option.isSome(current) && current.value.email?.trim() - ? { email: current.value.email.trim() } - : {}), - }, - ...(Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct") - ? { usageLimits } - : {}), - message: "Could not check Codex right now. Retry, or reconnect in provider settings.", - })), + Effect.flatMap((current) => + Clock.currentTimeMillis.pipe( + Effect.map((now) => ({ + ...base, + installed: true, + version: executable.value.version, + models: [], + auth: { + status: + Option.isSome(current) && + current.value.scopes.includes("chatgpt.tokens.use.direct") + ? current.value.expiresAt > now + ? ("authenticated" as const) + : ("unknown" as const) + : ("unauthenticated" as const), + label: "ChatGPT", + ...(Option.isSome(current) && + current.value.scopes.includes("chatgpt.tokens.use.direct") + ? { subscriptionSharing: true, profileId: current.value.clientId } + : {}), + ...(Option.isSome(current) && current.value.email?.trim() + ? { email: current.value.email.trim() } + : {}), + }, + ...(Option.isSome(current) && + current.value.scopes.includes("chatgpt.tokens.use.direct") + ? { usageLimits } + : {}), + message: + "Could not check Codex right now. Retry, or reconnect in provider settings.", + })), + ), + ), ), ), Effect.scoped, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index b57ed29988c7..83da03db4036 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -177,7 +177,7 @@ function withFakeCodexEnv( ? Effect.succeed({ config, environment: input.environment ?? process.env, - revision: "test", + accessToken: "test", }) : undefined, );