Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/binCli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -83,6 +84,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
uninstallCommand,
serviceLauncherCommand,
claudeHistoryCommand,
codexTokenCommand,
sshHelperCommand,

servicePreflightCommand,
Expand Down
84 changes: 84 additions & 0 deletions apps/server/src/cli/codexToken.test.ts
Original file line number Diff line number Diff line change
@@ -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),
),
),
),
);
53 changes: 53 additions & 0 deletions apps/server/src/cli/codexToken.ts
Original file line number Diff line number Diff line change
@@ -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<string>) =>
Command.runWith(codexTokenCommand, { version: "0.0.0" })(args).pipe(
Effect.scoped,
Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer)),
NodeRuntime.runMain,
);
9 changes: 9 additions & 0 deletions apps/server/src/provider/CodexChatGptAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 13 additions & 2 deletions apps/server/src/provider/CodexManagedRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
Expand All @@ -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);
Expand Down
37 changes: 28 additions & 9 deletions apps/server/src/provider/CodexManagedRuntime.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"',
Expand All @@ -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;
Expand All @@ -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,
Comment on lines +44 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High provider/CodexManagedRuntime.ts:44

When the server uses an implicit dev home, this child invocation selects <baseDir>/userdata instead of the parent’s <baseDir>/dev, so auth.access looks in the wrong secret store and managed Codex authentication fails. Forwarding --base-dir makes the home explicit even though --dev-url is also forwarded; remove --base-dir so the child preserves the parent’s implicit state-directory selection.

Suggested change
const tokenArguments = selfInvocationArgs(invocation, [
"__codex-token",
"--base-dir",
config.baseDir,
...(config.devUrl ? ["--dev-url", config.devUrl.toString()] : []),
"--instance-id",
options.instanceId,
const tokenArguments = selfInvocationArgs(invocation, [
"__codex-token",
...(config.devUrl ? ["--dev-url", config.devUrl.toString()] : []),
"--instance-id",
options.instanceId,
]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/CodexManagedRuntime.ts around lines 44-50:

When the server uses an implicit dev home, this child invocation selects `<baseDir>/userdata` instead of the parent’s `<baseDir>/dev`, so `auth.access` looks in the wrong secret store and managed Codex authentication fails. Forwarding `--base-dir` makes the home explicit even though `--dev-url` is also forwarded; remove `--base-dir` so the child preserves the parent’s implicit state-directory selection.

]);
const auth = yield* makeCodexChatGptAuth({
instanceId: options.instanceId,
defaultReturnUrl: new URL(
Expand All @@ -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),
Expand All @@ -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 };
Expand Down
62 changes: 61 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading