mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
3807eb9c58
Preserve Codex native tools when conversation policies deny only audited OpenClaw capabilities. Fail closed for computer-control denies and retain structured plan progress. Co-authored-by: Ayaan Zaidi <hi@obviy.us> Co-authored-by: Kelaw - Keshav's Agent <keshavbotagent@gmail.com>
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
/**
|
|
* Codex app-server agent harness registration and lazy runtime boundaries.
|
|
*/
|
|
import type {
|
|
AgentHarnessV2,
|
|
AgentHarnessCompactParams,
|
|
AgentHarnessCompactResult,
|
|
ContextEngineHostCapability,
|
|
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
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 { CodexSessionCatalogControl } 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
|
|
// target OpenClaw-owned capabilities with no Codex-native equivalent. 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",
|
|
"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 & {
|
|
compactAfterContextEngine?(
|
|
params: AgentHarnessCompactParams,
|
|
): Promise<AgentHarnessCompactResult | undefined>;
|
|
};
|
|
|
|
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: {
|
|
id?: string;
|
|
label?: string;
|
|
providerIds?: Iterable<string>;
|
|
pluginConfig?: unknown;
|
|
resolvePluginConfig?: () => unknown;
|
|
resolveConfig?: () => OpenClawConfig | undefined;
|
|
runtime?: PluginRuntime;
|
|
bindingStore: CodexAppServerBindingStore;
|
|
sessionCatalogControl?: CodexSessionCatalogControl;
|
|
}): 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 sessionCatalogControl = options.sessionCatalogControl;
|
|
const sessionRuntime = options.runtime;
|
|
const harness: CodexAppServerAgentHarness = {
|
|
id: harnessRuntimeId,
|
|
label: options?.label ?? "Codex agent harness",
|
|
autoSelection: { providerIds: [...providerIds] },
|
|
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",
|
|
...(sessionCatalogControl && 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,
|
|
control: sessionCatalogControl,
|
|
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,
|
|
});
|
|
},
|
|
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",
|
|
};
|
|
}
|
|
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: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
|
|
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,
|
|
});
|
|
},
|
|
compactAfterContextEngine: async (params) => {
|
|
const { maybeCompactCodexAppServerSession } = await import("./src/app-server/compact.js");
|
|
return maybeCompactCodexAppServerSession(params, {
|
|
bindingStore: options.bindingStore,
|
|
pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig,
|
|
allowNonManualNativeRequest: true,
|
|
});
|
|
},
|
|
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;
|
|
}
|