Files
openclaw/extensions/codex/harness.ts
T
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

361 lines
14 KiB
TypeScript

/**
* Codex app-server agent harness registration and lazy runtime boundaries.
*/
import type {
AgentHarnessV2,
AgentHarnessNativeCompaction,
ContextEngineHostCapability,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import { completeWithPreparedSimpleCompletionModel } from "openclaw/plugin-sdk/simple-completion-runtime";
import type { CodexAppServerBindingStore } from "./src/app-server/session-binding.js";
import type { CodexSessionCatalogControlFactory } from "./src/session-catalog-types.js";
// `codex` is legacy input only until Part 2 doctor migration rewrites stored refs.
// New runtime identity uses the `openai` provider.
const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex", "openai"]);
const SHARED_CODEX_APP_SERVER_CLIENT_DISPOSER = Symbol.for("openclaw.codexAppServerClientDisposer");
// Audited against @openai/codex 0.147.0 (rust-v0.147.0). These exact denies
// either have no Codex-native equivalent or are enforced by the harness. Keep
// the list positive and conservative: an omitted tool isolates the native surface.
const CODEX_TOOL_POLICY_SAFE_DENY_NAMES = [
"web_fetch",
"x_search",
"memory_search",
"memory_get",
"dashboard",
"canvas",
"show_widget",
"message",
"heartbeat_respond",
"automations",
"gateway",
"skill_workshop",
"image_generate",
"music_generate",
"video_generate",
"tts",
] as const;
const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [
"bootstrap",
"assemble-before-prompt",
"after-turn",
"maintain",
"compact",
"runtime-llm-complete",
"thread-bootstrap-projection",
] as const satisfies readonly ContextEngineHostCapability[];
type CodexAppServerAgentHarness = AgentHarnessV2 & {
cloudPlacement?: { mode: "remote-exec" };
};
type CodexAppServerAgentHarnessOptions = {
id?: string;
label?: string;
providerIds?: Iterable<string>;
pluginConfig?: unknown;
resolvePluginConfig?: () => unknown;
resolveConfig?: () => OpenClawConfig | undefined;
runtime?: PluginRuntime;
bindingStore: CodexAppServerBindingStore;
sessionCatalogControlFactory?: CodexSessionCatalogControlFactory;
};
type CodexHostPreparedIsolatedCompletionParams = Parameters<
NonNullable<AgentHarnessV2["runIsolatedCompletion"]>
>[0];
async function runCodexHostPreparedIsolatedCompletion(
params: CodexHostPreparedIsolatedCompletionParams,
) {
const timeoutSignal = AbortSignal.timeout(params.timeoutMs);
const signal = params.abortSignal
? AbortSignal.any([params.abortSignal, timeoutSignal])
: timeoutSignal;
const assistant = await completeWithPreparedSimpleCompletionModel({
model: params.model,
auth: params.auth,
cfg: params.config,
context: {
systemPrompt: params.systemPrompt,
messages: [{ role: "user", content: params.prompt, timestamp: Date.now() }],
tools: [],
},
options: {
maxTokens: params.streamParams?.maxTokens,
temperature: params.streamParams?.temperature,
reasoning: params.thinkLevel,
signal,
},
});
return { assistant };
}
async function disposeSharedCodexAppServerClients(): Promise<void> {
const dispose = (
globalThis as typeof globalThis & {
[SHARED_CODEX_APP_SERVER_CLIENT_DISPOSER]?: () => Promise<void>;
}
)[SHARED_CODEX_APP_SERVER_CLIENT_DISPOSER];
await dispose?.();
}
/**
* Creates the Codex app-server harness used for attempts, side questions,
* compaction, reset, and disposal.
*/
export function createCodexAppServerAgentHarness(
options: CodexAppServerAgentHarnessOptions,
): AgentHarnessV2 {
const harnessRuntimeId = options?.id ?? "codex";
const normalizedHarnessRuntimeId = harnessRuntimeId.trim().toLowerCase();
const providerIds = new Set(
[...(options?.providerIds ?? DEFAULT_CODEX_HARNESS_PROVIDER_IDS)].map((id) =>
id.trim().toLowerCase(),
),
);
const sessionCatalogControlFactory = options.sessionCatalogControlFactory;
const sessionRuntime = options.runtime;
const resolveAttemptPluginConfig = (config: OpenClawConfig | undefined) =>
resolvePluginConfigObject(config, "codex") ??
options.resolvePluginConfig?.() ??
options.pluginConfig;
const harness: CodexAppServerAgentHarness = {
id: harnessRuntimeId,
label: options?.label ?? "Codex agent harness",
autoSelection: { providerIds: [...providerIds] },
cloudPlacement: { mode: "remote-exec" },
delegatedExecutionPluginIds: ["voice-call"],
contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES,
conversationToolPolicySupport: "exact",
conversationToolPolicySafeDenyTools: CODEX_TOOL_POLICY_SAFE_DENY_NAMES,
deliveryDefaults: {
visibleReplies: "message_tool",
},
authBootstrap: "harness",
...(sessionCatalogControlFactory && sessionRuntime
? {
sessionFork: {
upstreamKinds: ["codex-app-server"] as const,
fork: async (params) => {
const { forkCodexUpstreamSession } =
await import("./src/app-server/upstream-session-fork.js");
return await forkCodexUpstreamSession(params, {
bindingStore: options.bindingStore,
controlFactory: sessionCatalogControlFactory,
harnessRuntimeId,
resolveConfig: options.resolveConfig,
runtime: sessionRuntime,
});
},
},
}
: {}),
authBinding: {
fingerprint: async (params) => {
const { fingerprintCodexAppServerAuthBinding } =
await import("./src/app-server/auth-binding.js");
return fingerprintCodexAppServerAuthBinding(params);
},
},
runtimeArtifact: {
validate: async (binding) => {
const { validateCodexAppServerRuntimeArtifact } =
await import("./src/app-server/runtime-artifact.js");
return validateCodexAppServerRuntimeArtifact(binding);
},
},
fetchUsageSnapshot: async (ctx) => {
const { fetchCodexAppServerUsageSnapshot } = await import("./src/app-server/usage.js");
return await fetchCodexAppServerUsageSnapshot(ctx, {
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
});
},
loadModelCatalog: async (params) => {
const { loadCodexAppServerModelCatalog } = await import("./src/app-server/model-catalog.js");
return await loadCodexAppServerModelCatalog(
params,
resolveAttemptPluginConfig(params.config),
);
},
loadMcpToolCatalog: async (params) => {
const { loadCodexEffectiveMcpCatalog } =
await import("./src/app-server/effective-mcp-catalog.js");
return await loadCodexEffectiveMcpCatalog(params, { bindingStore: options.bindingStore });
},
supports: (ctx) => {
const provider = ctx.provider.trim().toLowerCase();
if (!providerIds.has(provider)) {
return {
supported: false,
reason: `provider is not one of: ${[...providerIds].toSorted().join(", ")}`,
};
}
if (ctx.modelProvider?.requestTransportOverrides === "present") {
return {
supported: false,
reason: "Codex cannot reproduce authored request transport overrides",
fallbackRuntime: "openclaw",
};
}
const preparedAuth = ctx.modelProvider?.preparedAuth;
const runtimePolicy = ctx.modelProvider?.runtimePolicy;
if (runtimePolicy) {
const compatible = runtimePolicy.compatibleIds.some(
(id) => id.trim().toLowerCase() === normalizedHarnessRuntimeId,
);
if (!compatible) {
return {
supported: false,
reason: "Codex cannot reproduce the prepared provider route",
};
}
} else if (ctx.modelProvider && provider !== "codex") {
return {
supported: false,
reason: "provider route compatibility with Codex is not declared",
};
}
if (preparedAuth?.requirement === "subscription") {
const reproducibleSubscription =
preparedAuth.source === "profile" &&
(preparedAuth.mode === "oauth" || preparedAuth.mode === "token");
if (!reproducibleSubscription) {
return {
supported: false,
reason: "Codex subscription auth requires a prepared OAuth or token profile",
};
}
} else if (preparedAuth?.requirement === "api-key") {
const reproducibleApiKey =
preparedAuth.source !== "none" &&
preparedAuth.source !== "harness" &&
(preparedAuth.mode === "api-key" || preparedAuth.mode === "api_key");
if (!reproducibleApiKey) {
return {
supported: false,
reason: "Codex Platform auth requires a prepared API key",
};
}
}
return { supported: true, priority: 100 };
},
runAttempt: async (params) => {
// Keep app-server runtime code behind lazy imports so plugin discovery and
// cold provider catalog reads do not pull in the whole Codex runtime.
const { runCodexAppServerAttempt } = await import("./src/app-server/run-attempt.js");
return runCodexAppServerAttempt(params, {
bindingStore: options.bindingStore,
pluginConfig: resolveAttemptPluginConfig(params.config),
nativeHookRelay: { enabled: true },
});
},
runIsolatedCompletionV2: async (params) => {
if (params.authorization.owner === "host") {
const { authorization, ...commonParams } = params;
return runCodexHostPreparedIsolatedCompletion({
...commonParams,
model: authorization.model,
auth: authorization.auth,
...(authorization.sourceAuthFingerprint
? { sourceAuthFingerprint: authorization.sourceAuthFingerprint }
: {}),
});
}
const { runCodexIsolatedCompletion } =
await import("./src/app-server/isolated-completion.js");
return runCodexIsolatedCompletion(params, {
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
});
},
runIsolatedCompletion: async (params) => {
// Keep the deprecated V1 contract on its exact host-prepared transport.
// V2 owns native Codex auth and zero-tool attestation above.
return runCodexHostPreparedIsolatedCompletion(params);
},
finalizeSettledTurn: async (params) => {
const { runCodexSettledTurnFinalization } =
await import("./src/app-server/settled-turn-finalizer.js");
return runCodexSettledTurnFinalization(params, {
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
});
},
runSideQuestion: async (params) => {
const { runCodexAppServerSideQuestion } = await import("./src/app-server/side-question.js");
return runCodexAppServerSideQuestion(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
nativeHookRelay: { enabled: true },
});
},
compact: async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, {
bindingStore: options.bindingStore,
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
});
},
reset: async (params) => {
if (params.sessionId) {
const [
{ reclaimCurrentCodexSessionGeneration, sessionBindingIdentity },
{ retireCodexAppServerSessionGeneration },
] = await Promise.all([
import("./src/app-server/session-binding.js"),
import("./src/app-server/session-retirement.js"),
]);
const identity = sessionBindingIdentity({
agentId: params.agentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
});
const resetGeneration = () =>
retireCodexAppServerSessionGeneration({
bindingStore: options.bindingStore,
identity,
mode: params.reason === "deleted" ? "retire" : "reset",
});
let reset = await resetGeneration();
if (reset === "conflict") {
const reclaimed = await reclaimCurrentCodexSessionGeneration({
bindingStore: options.bindingStore,
identity,
config: options.resolveConfig?.(),
});
if (reclaimed) {
reset = await resetGeneration();
}
}
if (reset === "conflict") {
throw new Error(
`Codex binding generation changed before session ${params.sessionId} could reset`,
);
}
}
},
dispose: disposeSharedCodexAppServerClients,
};
return harness;
}
/** Creates the private native-compaction bridge registered in host-owned capability state. */
export function createCodexAppServerNativeCompaction(
options: Pick<
CodexAppServerAgentHarnessOptions,
"bindingStore" | "pluginConfig" | "resolvePluginConfig"
>,
): AgentHarnessNativeCompaction {
return async (params) => {
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
return maybeCompactCodexAppServerSession(params, {
bindingStore: options.bindingStore,
pluginConfig: options.resolvePluginConfig?.() ?? options.pluginConfig,
allowNonManualNativeRequest: true,
nativeCompactionRequest: params.nativeCompactionRequest,
});
};
}