mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-22 02:15:26 -06:00
34c3d15a6b
* 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>
361 lines
14 KiB
TypeScript
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,
|
|
});
|
|
};
|
|
}
|