Files
Hannes Rudolph 34c3d15a6b fix(macos): complete Codex onboarding and model picker (#124829)
* fix(setup): refresh Codex registry with staged install

* fix(macos): verify inference before onboarding handoff

* fix(setup): use native Codex home for subscription auth

* fix(codex): honor attempt-scoped setup config

* fix(macos): align onboarding handoff with reopen

* fix(setup): await prepared model convergence

* fix(ui): avoid false auth state for empty catalog

* fix(setup): scope catalog convergence to Codex gateway

* fix(setup): publish the committed runtime catalog

* fix(models): project configured static runtime models

* fix(codex): expose app-server model catalog

* fix(models): preserve Codex auth across reloads

* fix(ci): align Codex onboarding checks

* test(ui): stabilize dock suppression environment

* fix(codex): honor discovery config in app-server model catalog

The manifest documents discovery.enabled (bundled fallback list) and
discovery.timeoutMs (default 2500ms) for model discovery; the new catalog
path used the generic 60s request timeout and ignored the enable gate.
Also drop the test-only listModels injection seam in favor of vi.mock.

* fix(setup): refuse prepared Codex auth over an explicit remote transport

configureCodexCliPreparedAuth silently rewrote an explicitly configured
websocket/unix app-server to local stdio (keeping a dangling url), moving
the credential boundary onto this host. Fail setup with actionable
guidance instead; also surface the root cause when the prepared model
catalog refresh fails after activation.

* refactor(agents): one canonical model-catalog identity key

Three near-identical key helpers existed (models-list-result,
models-list-configured-static, harness/model-catalog). Export
resolveModelCatalogIdentityKey from the route-policy owner, collapse the
duplicate dedupe loops into dedupeByKey, make donor enrichment Map-based,
and inline the one-off harness-augment wrapper.

* fix(macos): restore custodian handoff for fresh activations

Landing every finish on the plain dashboard stranded the custodian
first-run flow (memory import, channels, permissions, hatch). Fresh
activations now hand off to custodian onboarding; live-verified
pre-existing setups reopen the normal dashboard, matching the removed
already-configured shortcut. Tests pin the destination per path.

Also isolate the post-startup Codex login test from developer machines:
ambient OPENAI_API_KEY and a real Codex login made it assert-fail.

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-17 23:31:12 -07:00

184 lines
6.4 KiB
TypeScript

/**
* Lists and normalizes models exposed by the Codex app-server `model/list`
* endpoint, including pagination and shared-client lease handling.
*/
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
CodexAppServerAuthRequirement,
resolveCodexAppServerAuthProfileIdForAgent,
} from "./auth-bridge.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import { assertCodexModelListResponse } from "./protocol-validators.js";
import type { CodexModel, CodexReasoningEffortOption } from "./protocol.js";
/** Normalized model metadata returned by the Codex app-server model listing helper. */
export type CodexAppServerModel = {
id: string;
model: string;
displayName?: string;
description?: string;
hidden?: boolean;
isDefault?: boolean;
inputModalities: string[];
supportedReasoningEfforts: string[];
defaultReasoningEffort?: string;
};
/** One page of Codex app-server model metadata plus optional pagination state. */
export type CodexAppServerModelListResult = {
models: CodexAppServerModel[];
nextCursor?: string;
truncated?: boolean;
};
/** Options for querying Codex app-server models through a shared or isolated client. */
type CodexAppServerListModelsOptions = {
limit?: number;
cursor?: string;
includeHidden?: boolean;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
authRequirement?: CodexAppServerAuthRequirement;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sharedClient?: boolean;
};
/** Lists one Codex app-server model page using the configured auth/client options. */
export async function listCodexAppServerModels(
options: CodexAppServerListModelsOptions = {},
): Promise<CodexAppServerModelListResult> {
return await withCodexAppServerModelClient(options, async ({ client, timeoutMs }) =>
requestModelListPage(client, { ...options, timeoutMs }),
);
}
/** Walks Codex app-server model pages until exhaustion or the max-page guard. */
export async function listAllCodexAppServerModels(
options: CodexAppServerListModelsOptions & { maxPages?: number } = {},
): Promise<CodexAppServerModelListResult> {
const maxPages = normalizeMaxPages(options.maxPages);
return await withCodexAppServerModelClient(options, async ({ client, timeoutMs }) => {
const models: CodexAppServerModel[] = [];
let cursor = options.cursor;
let nextCursor: string | undefined;
for (let page = 0; page < maxPages; page += 1) {
const result = await requestModelListPage(client, {
...options,
timeoutMs,
cursor,
});
models.push(...result.models);
nextCursor = result.nextCursor;
if (!nextCursor) {
return { models };
}
cursor = nextCursor;
}
return { models, nextCursor, truncated: true };
});
}
async function withCodexAppServerModelClient<T>(
options: CodexAppServerListModelsOptions,
run: (params: { client: CodexAppServerClient; timeoutMs: number }) => Promise<T>,
): Promise<T> {
const timeoutMs = options.timeoutMs ?? 2500;
const useSharedClient = options.sharedClient !== false;
const {
createIsolatedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} = await import("./shared-client.js");
const client = useSharedClient
? await getLeasedSharedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
authRequirement: options.authRequirement,
agentDir: options.agentDir,
config: options.config,
})
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
authRequirement: options.authRequirement,
agentDir: options.agentDir,
config: options.config,
});
try {
return await run({ client, timeoutMs });
} finally {
if (useSharedClient) {
releaseLeasedSharedCodexAppServerClient(client);
} else {
client.close();
}
}
}
async function requestModelListPage(
client: CodexAppServerClient,
options: CodexAppServerListModelsOptions & { timeoutMs: number },
): Promise<CodexAppServerModelListResult> {
const response = await client.request(
"model/list",
{
limit: options.limit ?? null,
cursor: options.cursor ?? null,
includeHidden: options.includeHidden ?? null,
},
{ timeoutMs: options.timeoutMs },
);
return readModelListResult(response);
}
/** Parses a raw Codex app-server model/list response into OpenClaw's normalized shape. */
export function readModelListResult(value: unknown): CodexAppServerModelListResult {
const response = assertCodexModelListResponse(value);
const models = response.data.map((entry) => readCodexModel(entry));
const nextCursor = response.nextCursor ?? undefined;
return { models, ...(nextCursor ? { nextCursor } : {}) };
}
function readCodexModel(value: CodexModel): CodexAppServerModel {
const id = normalizeOptionalString(value.id);
const model = normalizeOptionalString(value.model);
if (!id || !model) {
throw new Error(
"Invalid Codex app-server model/list response: model id and name must be non-empty strings",
);
}
return {
id,
model,
...(normalizeOptionalString(value.displayName)
? { displayName: normalizeOptionalString(value.displayName) }
: {}),
...(normalizeOptionalString(value.description)
? { description: normalizeOptionalString(value.description) }
: {}),
hidden: value.hidden,
isDefault: value.isDefault,
inputModalities: value.inputModalities,
supportedReasoningEfforts: readReasoningEfforts(value.supportedReasoningEfforts),
...(normalizeOptionalString(value.defaultReasoningEffort)
? { defaultReasoningEffort: normalizeOptionalString(value.defaultReasoningEffort) }
: {}),
};
}
function readReasoningEfforts(value: CodexReasoningEffortOption[]): string[] {
const efforts = value
.map((entry) => normalizeOptionalString(entry.reasoningEffort))
.filter((entry): entry is string => entry !== undefined);
return uniqueStrings(efforts);
}
function normalizeMaxPages(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 20;
}