fix(codex): honor app-server auth order

This commit is contained in:
Vincent Koc
2026-05-03 18:24:25 -07:00
parent 3a8ea14fe3
commit eb1a0aa574
13 changed files with 244 additions and 43 deletions

View File

@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
- Channels/CLI: keep `openclaw channels list --json` usable when provider usage fetching fails, and report per-provider usage errors without aborting the channel list. Refs #67595.
- Agents/messaging: deliver distinct final commentary after same-target `message` tool sends while still deduping text/media already sent by the tool, so short closing remarks are no longer silently dropped. Fixes #76915. Thanks @hclsys.
- Agents/messaging: preserve string thread IDs when matching message-tool reply dedupe routes, avoiding precision loss on numeric-looking topic IDs before channel plugin comparison. Thanks @vincentkoc.
- OpenAI Codex: honor `auth.order.openai-codex` when starting app-server clients without an explicit auth profile, so status/model probes and implicit startup use the configured Codex account instead of falling back to the default profile. Thanks @vincentkoc.
- OpenAI Codex: let SSRF-guarded provider requests inherit OpenClaw's undici IPv4/IPv6 fallback policy, so ChatGPT-backed Codex runs recover on IPv4-working hosts when DNS still returns unreachable IPv6 addresses. Fixes #76857. Thanks @jplavoiemtl and @SymbolStar.
- Gateway/systemd: preserve operator-added secrets in the Gateway env file across re-stage while clearing OpenClaw-managed keys (such as `OPENCLAW_GATEWAY_TOKEN`) so a fresh staging value is never shadowed by a stale env-file copy; operator secrets are also retained when the state-dir `.env` is empty. Fixes #76860. Thanks @hclsys.
- Plugin updates: do not short-circuit trusted official npm updates as unchanged when the default/latest spec still resolves to an already-installed prerelease that the installer should replace with a stable fallback. Thanks @vincentkoc.

View File

@@ -421,6 +421,58 @@ describe("bridgeCodexAppServerStartOptions", () => {
}
});
it("honors config auth order when selecting an implicit Codex profile", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));
try {
upsertAuthProfile({
agentDir,
profileId: "openai-codex:default",
credential: {
type: "oauth",
provider: "openai-codex",
access: "default-access-token",
refresh: "default-refresh-token",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "account-default",
},
});
upsertAuthProfile({
agentDir,
profileId: "openai-codex:work",
credential: {
type: "oauth",
provider: "openai-codex",
access: "work-access-token",
refresh: "work-refresh-token",
expires: Date.now() + 24 * 60 * 60_000,
accountId: "account-work",
},
});
await applyCodexAppServerAuthProfile({
client: { request } as never,
agentDir,
config: {
auth: {
order: {
"openai-codex": ["openai-codex:work", "openai-codex:default"],
},
},
},
});
expect(request).toHaveBeenCalledWith("account/login/start", {
type: "chatgptAuthTokens",
accessToken: "work-access-token",
chatgptAccountId: "account-work",
chatgptPlanType: null,
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("refreshes an expired OpenAI Codex OAuth profile before app-server login", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
const request = vi.fn(async () => ({ type: "chatgptAuthTokens" }));

View File

@@ -36,6 +36,7 @@ export async function bridgeCodexAppServerStartOptions(params: {
startOptions: CodexAppServerStartOptions;
agentDir: string;
authProfileId?: string;
config?: AuthProfileOrderConfig;
}): Promise<CodexAppServerStartOptions> {
if (params.startOptions.transport !== "stdio") {
return params.startOptions;
@@ -48,10 +49,12 @@ export async function bridgeCodexAppServerStartOptions(params: {
const authProfileId = resolveCodexAppServerAuthProfileId({
authProfileId: params.authProfileId,
store,
config: params.config,
});
const shouldClearInheritedOpenAiApiKey = shouldClearOpenAiApiKeyForCodexAuthProfile({
store,
authProfileId,
config: params.config,
});
return shouldClearInheritedOpenAiApiKey
? withClearedEnvironmentVariables(isolatedStartOptions, CODEX_APP_SERVER_API_KEY_ENV_VARS)
@@ -139,10 +142,12 @@ export async function applyCodexAppServerAuthProfile(params: {
agentDir: string;
authProfileId?: string;
startOptions?: CodexAppServerStartOptions;
config?: AuthProfileOrderConfig;
}): Promise<void> {
const loginParams = await resolveCodexAppServerAuthProfileLoginParams({
agentDir: params.agentDir,
authProfileId: params.authProfileId,
config: params.config,
});
if (!loginParams) {
if (params.startOptions?.transport !== "stdio") {
@@ -164,6 +169,7 @@ export async function applyCodexAppServerAuthProfile(params: {
function resolveCodexAppServerAuthProfileLoginParams(params: {
agentDir: string;
authProfileId?: string;
config?: AuthProfileOrderConfig;
}): Promise<LoginAccountParams | undefined> {
return resolveCodexAppServerAuthProfileLoginParamsInternal(params);
}
@@ -171,6 +177,7 @@ function resolveCodexAppServerAuthProfileLoginParams(params: {
export async function refreshCodexAppServerAuthTokens(params: {
agentDir: string;
authProfileId?: string;
config?: AuthProfileOrderConfig;
}): Promise<ChatgptAuthTokensRefreshResponse> {
const loginParams = await resolveCodexAppServerAuthProfileLoginParamsInternal({
...params,
@@ -190,11 +197,13 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
agentDir: string;
authProfileId?: string;
forceOAuthRefresh?: boolean;
config?: AuthProfileOrderConfig;
}): Promise<LoginAccountParams | undefined> {
const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false });
const profileId = resolveCodexAppServerAuthProfileId({
authProfileId: params.authProfileId,
store,
config: params.config,
});
if (!profileId) {
return undefined;
@@ -203,7 +212,7 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
if (!credential) {
throw new Error(`Codex app-server auth profile "${profileId}" was not found.`);
}
if (!isCodexAppServerAuthProvider(credential.provider)) {
if (!isCodexAppServerAuthProvider(credential.provider, params.config)) {
throw new Error(
`Codex app-server auth profile "${profileId}" must belong to provider "openai-codex" or a supported alias.`,
);
@@ -211,6 +220,7 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: {
const loginParams = await resolveLoginParamsForCredential(profileId, credential, {
agentDir: params.agentDir,
forceOAuthRefresh: params.forceOAuthRefresh === true,
config: params.config,
});
if (!loginParams) {
throw new Error(
@@ -240,7 +250,7 @@ async function resolveCodexAppServerEnvApiKeyLoginParams(params: {
async function resolveLoginParamsForCredential(
profileId: string,
credential: AuthProfileCredential,
params: { agentDir: string; forceOAuthRefresh: boolean },
params: { agentDir: string; forceOAuthRefresh: boolean; config?: AuthProfileOrderConfig },
): Promise<LoginAccountParams | undefined> {
if (credential.type === "api_key") {
const resolved = await resolveApiKeyForProfile({
@@ -265,6 +275,7 @@ async function resolveLoginParamsForCredential(
const resolvedCredential = await resolveOAuthCredentialForCodexAppServer(profileId, credential, {
agentDir: params.agentDir,
forceRefresh: params.forceOAuthRefresh,
config: params.config,
});
const accessToken = resolvedCredential.access?.trim();
return accessToken
@@ -275,7 +286,7 @@ async function resolveLoginParamsForCredential(
async function resolveOAuthCredentialForCodexAppServer(
profileId: string,
credential: OAuthCredential,
params: { agentDir: string; forceRefresh: boolean },
params: { agentDir: string; forceRefresh: boolean; config?: AuthProfileOrderConfig },
): Promise<OAuthCredential> {
const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({
agentDir: params.agentDir,
@@ -284,7 +295,8 @@ async function resolveOAuthCredentialForCodexAppServer(
const store = ensureAuthProfileStore(ownerAgentDir, { allowKeychainPrompt: false });
const ownerCredential = store.profiles[profileId];
const credentialForOwner =
ownerCredential?.type === "oauth" && isCodexAppServerAuthProvider(ownerCredential.provider)
ownerCredential?.type === "oauth" &&
isCodexAppServerAuthProvider(ownerCredential.provider, params.config)
? ownerCredential
: credential;
if (params.forceRefresh) {
@@ -299,32 +311,36 @@ async function resolveOAuthCredentialForCodexAppServer(
const refreshed = loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId];
const storedCredential = store.profiles[profileId];
const candidate =
refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider)
refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider, params.config)
? refreshed
: storedCredential?.type === "oauth" &&
isCodexAppServerAuthProvider(storedCredential.provider)
isCodexAppServerAuthProvider(storedCredential.provider, params.config)
? storedCredential
: credential;
return resolved?.apiKey ? { ...candidate, access: resolved.apiKey } : candidate;
}
function isCodexAppServerAuthProvider(provider: string): boolean {
return resolveProviderIdForAuth(provider) === CODEX_APP_SERVER_AUTH_PROVIDER;
function isCodexAppServerAuthProvider(provider: string, config?: AuthProfileOrderConfig): boolean {
return resolveProviderIdForAuth(provider, { config }) === CODEX_APP_SERVER_AUTH_PROVIDER;
}
function shouldClearOpenAiApiKeyForCodexAuthProfile(params: {
store: ReturnType<typeof ensureAuthProfileStore>;
authProfileId?: string;
config?: AuthProfileOrderConfig;
}): boolean {
const profileId = params.authProfileId?.trim();
const credential = profileId
? params.store.profiles[profileId]
: params.store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID];
return isCodexSubscriptionCredential(credential);
return isCodexSubscriptionCredential(credential, params.config);
}
function isCodexSubscriptionCredential(credential: AuthProfileCredential | undefined): boolean {
if (!credential || !isCodexAppServerAuthProvider(credential.provider)) {
function isCodexSubscriptionCredential(
credential: AuthProfileCredential | undefined,
config?: AuthProfileOrderConfig,
): boolean {
if (!credential || !isCodexAppServerAuthProvider(credential.provider, config)) {
return false;
}
return credential.type === "oauth" || credential.type === "token";

View File

@@ -1,19 +1,26 @@
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
type AuthProfileOrderConfig = Parameters<
typeof resolveCodexAppServerAuthProfileIdForAgent
>[0]["config"];
export type CodexAppServerClientFactory = (
startOptions?: CodexAppServerStartOptions,
authProfileId?: string,
agentDir?: string,
config?: AuthProfileOrderConfig,
) => Promise<CodexAppServerClient>;
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
) =>
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir }),
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
);
export function createCodexAppServerClientFactoryTestHooks(

View File

@@ -110,7 +110,7 @@ async function compactCodexNativeThread(
options: { pluginConfig?: unknown } = {},
): Promise<EmbeddedPiCompactResult | undefined> {
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const binding = await readCodexAppServerBinding(params.sessionFile);
const binding = await readCodexAppServerBinding(params.sessionFile, { config: params.config });
if (!binding?.threadId) {
return { ok: false, compacted: false, reason: "no codex app-server thread binding" };
}
@@ -127,6 +127,7 @@ async function compactCodexNativeThread(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
params.config,
);
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
let completion: CodexNativeCompactionCompletion;

View File

@@ -1,3 +1,4 @@
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type { v2 } from "./protocol-generated/typescript/index.js";
@@ -29,6 +30,7 @@ export type CodexAppServerListModelsOptions = {
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sharedClient?: boolean;
};
@@ -79,12 +81,14 @@ async function withCodexAppServerModelClient<T>(
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
config: options.config,
})
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
config: options.config,
});
try {
return await run({ client, timeoutMs });

View File

@@ -1,3 +1,4 @@
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type {
CodexAppServerRequestMethod,
@@ -14,6 +15,7 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
}): Promise<CodexAppServerRequestResult<M>>;
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
method: string;
@@ -21,6 +23,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
}): Promise<T>;
export async function requestCodexAppServerJson<T = JsonValue | undefined>(params: {
method: string;
@@ -28,6 +31,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
}): Promise<T> {
const timeoutMs = params.timeoutMs ?? 60_000;
return await withTimeout(
@@ -36,6 +40,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
startOptions: params.startOptions,
timeoutMs,
authProfileId: params.authProfileId,
config: params.config,
});
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
})(),

View File

@@ -561,6 +561,7 @@ export async function runCodexAppServerAttempt(
appServer.start,
startupAuthProfileId,
agentDir,
params.config,
);
attemptedClient = startupClient;
startupClientForCleanup = startupClient;

View File

@@ -6,6 +6,9 @@ import { createClientHarness } from "./test-support.js";
const mocks = vi.hoisted(() => ({
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
applyCodexAppServerAuthProfile: vi.fn(async () => undefined),
resolveCodexAppServerAuthProfileIdForAgent: vi.fn(
(params?: { authProfileId?: string }) => params?.authProfileId,
),
resolveManagedCodexAppServerStartOptions: vi.fn(async (startOptions) => startOptions),
embeddedAgentLog: { debug: vi.fn(), warn: vi.fn() },
resolveOpenClawAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
@@ -14,6 +17,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("./auth-bridge.js", () => ({
applyCodexAppServerAuthProfile: mocks.applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent,
}));
vi.mock("./managed-binary.js", () => ({
@@ -67,6 +71,10 @@ describe("shared Codex app-server client", () => {
vi.restoreAllMocks();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.applyCodexAppServerAuthProfile.mockClear();
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockClear();
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
(params?: { authProfileId?: string }) => params?.authProfileId,
);
mocks.resolveManagedCodexAppServerStartOptions.mockClear();
mocks.resolveManagedCodexAppServerStartOptions.mockImplementation(
async (startOptions) => startOptions,
@@ -147,6 +155,37 @@ describe("shared Codex app-server client", () => {
);
});
it("resolves the configured implicit auth profile before sharing a client", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const config = { auth: { order: { "openai-codex": ["openai-codex:work"] } } };
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockReturnValue("openai-codex:work");
const listPromise = listCodexAppServerModels({
timeoutMs: 1000,
config,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(harness);
await expect(listPromise).resolves.toEqual({ models: [] });
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).toHaveBeenCalledWith(
expect.objectContaining({ config }),
);
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId: "openai-codex:work",
config,
}),
);
expect(mocks.applyCodexAppServerAuthProfile).toHaveBeenCalledWith(
expect.objectContaining({
authProfileId: "openai-codex:work",
config,
}),
);
});
it("uses the selected agent dir for shared app-server auth bridging", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);

View File

@@ -1,5 +1,9 @@
import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth";
import { applyCodexAppServerAuthProfile, bridgeCodexAppServerStartOptions } from "./auth-bridge.js";
import {
applyCodexAppServerAuthProfile,
bridgeCodexAppServerStartOptions,
resolveCodexAppServerAuthProfileIdForAgent,
} from "./auth-bridge.js";
import { CodexAppServerClient } from "./client.js";
import {
codexAppServerStartOptionsKey,
@@ -30,19 +34,26 @@ export async function getSharedCodexAppServerClient(options?: {
timeoutMs?: number;
authProfileId?: string;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
}): Promise<CodexAppServerClient> {
const state = getSharedCodexAppServerClientState();
const agentDir = options?.agentDir ?? resolveOpenClawAgentDir();
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: options?.authProfileId,
agentDir,
config: options?.config,
});
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: managedStartOptions,
agentDir,
authProfileId: options?.authProfileId,
authProfileId,
config: options?.config,
});
const key = codexAppServerStartOptionsKey(startOptions, {
authProfileId: options?.authProfileId,
authProfileId,
agentDir,
});
if (state.key && state.key !== key) {
@@ -60,8 +71,9 @@ export async function getSharedCodexAppServerClient(options?: {
await applyCodexAppServerAuthProfile({
client,
agentDir,
authProfileId: options?.authProfileId,
authProfileId,
startOptions,
config: options?.config,
});
return client;
} catch (error) {
@@ -90,15 +102,22 @@ export async function createIsolatedCodexAppServerClient(options?: {
timeoutMs?: number;
authProfileId?: string;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
}): Promise<CodexAppServerClient> {
const agentDir = options?.agentDir ?? resolveOpenClawAgentDir();
const authProfileId = resolveCodexAppServerAuthProfileIdForAgent({
authProfileId: options?.authProfileId,
agentDir,
config: options?.config,
});
const requestedStartOptions =
options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start;
const managedStartOptions = await resolveManagedCodexAppServerStartOptions(requestedStartOptions);
const startOptions = await bridgeCodexAppServerStartOptions({
startOptions: managedStartOptions,
agentDir,
authProfileId: options?.authProfileId,
authProfileId,
config: options?.config,
});
const client = CodexAppServerClient.start(startOptions);
const initialize = client.initialize();
@@ -107,8 +126,9 @@ export async function createIsolatedCodexAppServerClient(options?: {
await applyCodexAppServerAuthProfile({
client,
agentDir,
authProfileId: options?.authProfileId,
authProfileId,
startOptions,
config: options?.config,
});
return client;
} catch (error) {

View File

@@ -185,12 +185,16 @@ export async function handleCodexSubcommand(
return { text: buildHelp() };
}
if (normalized === "status") {
return { text: formatCodexStatus(await deps.readCodexStatusProbes(options.pluginConfig)) };
return {
text: formatCodexStatus(await deps.readCodexStatusProbes(options.pluginConfig, ctx.config)),
};
}
if (normalized === "models") {
return {
text: formatModels(
await deps.listCodexAppServerModels(deps.requestOptions(options.pluginConfig, 100)),
await deps.listCodexAppServerModels(
deps.requestOptions(options.pluginConfig, 100, ctx.config),
),
),
};
}

View File

@@ -1,3 +1,4 @@
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js";
import {
CODEX_CONTROL_METHODS,
describeControlFailure,
@@ -15,12 +16,21 @@ import { requestCodexAppServerJson } from "./app-server/request.js";
export type SafeValue<T> = { ok: true; value: T } | { ok: false; error: string };
export function requestOptions(pluginConfig: unknown, limit: number) {
type AuthProfileOrderConfig = Parameters<
typeof resolveCodexAppServerAuthProfileIdForAgent
>[0]["config"];
export function requestOptions(
pluginConfig: unknown,
limit: number,
config?: AuthProfileOrderConfig,
) {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
return {
limit,
timeoutMs: runtime.requestTimeoutMs,
startOptions: runtime.start,
config,
};
}
@@ -30,16 +40,19 @@ export function codexControlRequest<M extends CodexControlRequestMethod>(
pluginConfig: unknown,
method: M,
requestParams: CodexAppServerRequestParams<M>,
options?: { config?: AuthProfileOrderConfig },
): Promise<CodexAppServerRequestResult<M>>;
export function codexControlRequest(
pluginConfig: unknown,
method: CodexControlMethod,
requestParams?: JsonValue,
options?: { config?: AuthProfileOrderConfig },
): Promise<JsonValue | undefined>;
export async function codexControlRequest(
pluginConfig: unknown,
method: CodexControlMethod,
requestParams?: unknown,
options: { config?: AuthProfileOrderConfig } = {},
) {
const runtime = resolveCodexAppServerRuntimeOptions({ pluginConfig });
return await requestCodexAppServerJson({
@@ -47,6 +60,7 @@ export async function codexControlRequest(
requestParams,
timeoutMs: runtime.requestTimeoutMs,
startOptions: runtime.start,
config: options.config,
});
}
@@ -54,35 +68,56 @@ export function safeCodexControlRequest<M extends CodexControlRequestMethod>(
pluginConfig: unknown,
method: M,
requestParams: CodexAppServerRequestParams<M>,
options?: { config?: AuthProfileOrderConfig },
): Promise<SafeValue<CodexAppServerRequestResult<M>>>;
export function safeCodexControlRequest(
pluginConfig: unknown,
method: CodexControlMethod,
requestParams?: JsonValue,
options?: { config?: AuthProfileOrderConfig },
): Promise<SafeValue<JsonValue | undefined>>;
export async function safeCodexControlRequest(
pluginConfig: unknown,
method: CodexControlMethod,
requestParams?: unknown,
options: { config?: AuthProfileOrderConfig } = {},
) {
return await safeValue(
async () => await codexControlRequest(pluginConfig, method, requestParams as JsonValue),
async () =>
await codexControlRequest(pluginConfig, method, requestParams as JsonValue, options),
);
}
async function safeCodexModelList(pluginConfig: unknown, limit: number) {
async function safeCodexModelList(
pluginConfig: unknown,
limit: number,
config?: AuthProfileOrderConfig,
) {
return await safeValue(
async () => await listCodexAppServerModels(requestOptions(pluginConfig, limit)),
async () => await listCodexAppServerModels(requestOptions(pluginConfig, limit, config)),
);
}
export async function readCodexStatusProbes(pluginConfig: unknown) {
export async function readCodexStatusProbes(
pluginConfig: unknown,
config?: AuthProfileOrderConfig,
) {
const [models, account, limits, mcps, skills] = await Promise.all([
safeCodexModelList(pluginConfig, 20),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.account, { refreshToken: false }),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.rateLimits, undefined),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listMcpServers, { limit: 100 }),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}),
safeCodexModelList(pluginConfig, 20, config),
safeCodexControlRequest(
pluginConfig,
CODEX_CONTROL_METHODS.account,
{ refreshToken: false },
{ config },
),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.rateLimits, undefined, { config }),
safeCodexControlRequest(
pluginConfig,
CODEX_CONTROL_METHODS.listMcpServers,
{ limit: 100 },
{ config },
),
safeCodexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}, { config }),
]);
return { models, account, limits, mcps, skills };

View File

@@ -41,16 +41,23 @@ function createDeps(overrides: Partial<CodexCommandDeps> = {}): Partial<CodexCom
codexControlRequest: vi.fn(),
listCodexAppServerModels: vi.fn(),
readCodexStatusProbes: vi.fn(),
requestOptions: vi.fn((_pluginConfig: unknown, limit: number) => ({
limit,
timeoutMs: 1000,
startOptions: {
transport: "stdio",
command: "codex",
args: ["app-server", "--listen", "stdio://"],
headers: {},
} satisfies CodexAppServerStartOptions,
})),
requestOptions: vi.fn(
(
_pluginConfig: unknown,
limit: number,
config?: Parameters<NonNullable<CodexCommandDeps["requestOptions"]>>[2],
) => ({
limit,
timeoutMs: 1000,
startOptions: {
transport: "stdio",
command: "codex",
args: ["app-server", "--listen", "stdio://"],
headers: {},
} satisfies CodexAppServerStartOptions,
config,
}),
),
safeCodexControlRequest: vi.fn(),
...overrides,
};
@@ -132,6 +139,7 @@ describe("codex command", () => {
});
it("shows model ids from Codex app-server", async () => {
const config = { auth: { order: { "openai-codex": ["openai-codex:work"] } } };
const deps = createDeps({
listCodexAppServerModels: vi.fn(async () => ({
models: [
@@ -145,9 +153,13 @@ describe("codex command", () => {
})),
});
await expect(handleCodexCommand(createContext("models"), { deps })).resolves.toEqual({
await expect(
handleCodexCommand(createContext("models", undefined, { config }), { deps }),
).resolves.toEqual({
text: "Codex models:\n- gpt-5.4",
});
expect(deps.requestOptions).toHaveBeenCalledWith(undefined, 100, config);
expect(deps.listCodexAppServerModels).toHaveBeenCalledWith(expect.objectContaining({ config }));
});
it("shows when Codex app-server model output is truncated", async () => {
@@ -172,6 +184,7 @@ describe("codex command", () => {
});
it("reports status unavailable when every Codex probe fails", async () => {
const config = { auth: { order: { "openai-codex": ["openai-codex:work"] } } };
const offline = { ok: false as const, error: "offline" };
const deps = createDeps({
readCodexStatusProbes: vi.fn(async () => ({
@@ -183,7 +196,9 @@ describe("codex command", () => {
})),
});
await expect(handleCodexCommand(createContext("status"), { deps })).resolves.toEqual({
await expect(
handleCodexCommand(createContext("status", undefined, { config }), { deps }),
).resolves.toEqual({
text: [
"Codex app-server: unavailable",
"Models: offline",
@@ -193,6 +208,7 @@ describe("codex command", () => {
"Skills: offline",
].join("\n"),
});
expect(deps.readCodexStatusProbes).toHaveBeenCalledWith(undefined, config);
});
it("formats generated account/read responses", async () => {