fix(qa): expose codex tools for runtime parity

This commit is contained in:
Vincent Koc
2026-05-17 06:46:27 +08:00
parent 2c9f68f42b
commit 37dcf385e5
15 changed files with 454 additions and 48 deletions

View File

@@ -1,4 +1,4 @@
import type { CodexPluginConfig } from "./config.js";
import type { CodexDynamicToolsLoading, CodexPluginConfig } from "./config.js";
export const CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES = [
"read",
@@ -19,18 +19,44 @@ const DYNAMIC_TOOL_NAME_ALIASES: Record<string, string> = {
"apply-patch": "apply_patch",
};
type CodexDynamicToolProfileEnv = {
OPENCLAW_BUILD_PRIVATE_QA?: string;
OPENCLAW_QA_FORCE_RUNTIME?: string;
};
export function normalizeCodexDynamicToolName(name: string): string {
const normalized = name.trim().toLowerCase();
return DYNAMIC_TOOL_NAME_ALIASES[normalized] ?? normalized;
}
export function isForcedPrivateQaCodexRuntime(
env: CodexDynamicToolProfileEnv = process.env,
): boolean {
return (
env.OPENCLAW_BUILD_PRIVATE_QA === "1" &&
env.OPENCLAW_QA_FORCE_RUNTIME?.trim().toLowerCase() === "codex"
);
}
export function resolveCodexDynamicToolsLoading(
config: Pick<CodexPluginConfig, "codexDynamicToolsLoading">,
env: CodexDynamicToolProfileEnv = process.env,
): CodexDynamicToolsLoading {
return isForcedPrivateQaCodexRuntime(env)
? "direct"
: (config.codexDynamicToolsLoading ?? "searchable");
}
export function filterCodexDynamicTools<T extends { name: string }>(
tools: T[],
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,
env: CodexDynamicToolProfileEnv = process.env,
): T[] {
const excludes = new Set<string>();
for (const name of CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES) {
excludes.add(name);
if (!isForcedPrivateQaCodexRuntime(env)) {
for (const name of CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES) {
excludes.add(name);
}
}
for (const name of config.codexDynamicToolsExclude ?? []) {
const trimmed = normalizeCodexDynamicToolName(name);

View File

@@ -646,6 +646,21 @@ describe("runCodexAppServerAttempt", () => {
).toEqual(["message"]);
});
it("exposes app-server-owned tools directly for forced private QA Codex runtime", () => {
const tools = ["read", "write", "image_generate", "message"].map((name) => ({ name }));
const privateQaCodexEnv = {
OPENCLAW_BUILD_PRIVATE_QA: "1",
OPENCLAW_QA_FORCE_RUNTIME: "codex",
};
expect(
__testing
.filterCodexDynamicTools(tools, {}, privateQaCodexEnv)
.map((tool) => tool.name),
).toEqual(["read", "write", "image_generate", "message"]);
expect(__testing.resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
});
it("starts Codex threads without duplicate OpenClaw workspace tools by default", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -897,6 +912,38 @@ describe("runCodexAppServerAttempt", () => {
expect((factoryOptions[0] as { modelApi?: unknown }).modelApi).toBe("openai-responses");
});
it("enables gateway subagent binding for forced private QA Codex runs", async () => {
vi.stubEnv("OPENCLAW_BUILD_PRIVATE_QA", "1");
vi.stubEnv("OPENCLAW_QA_FORCE_RUNTIME", "codex");
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
__testing.setOpenClawCodingToolsFactoryForTests((options) => {
factoryOptions.push(options);
return [createRuntimeDynamicTool("sessions_spawn")];
});
const tools = await __testing.buildDynamicTools({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
sandboxSessionKey: params.sessionKey!,
sandbox: null as never,
runAbortController: new AbortController(),
sessionAgentId: "main",
pluginConfig: {},
onYieldDetected: () => undefined,
});
expect(factoryOptions).toHaveLength(1);
const factoryOption = factoryOptions[0] as { allowGatewaySubagentBinding?: unknown };
expect(factoryOption.allowGatewaySubagentBinding).toBe(true);
expect(tools.map((tool) => tool.name)).toEqual(["sessions_spawn"]);
});
it("normalizes Codex dynamic toolsAllow entries before filtering", () => {
const tools = ["exec", "apply_patch", "read", "message"].map((name) => ({ name }));

View File

@@ -78,7 +78,12 @@ import {
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
} from "./context-engine-projection.js";
import { filterCodexDynamicTools, normalizeCodexDynamicToolName } from "./dynamic-tool-profile.js";
import {
filterCodexDynamicTools,
isForcedPrivateQaCodexRuntime,
normalizeCodexDynamicToolName,
resolveCodexDynamicToolsLoading,
} from "./dynamic-tool-profile.js";
import { createCodexDynamicToolBridge, type CodexDynamicToolBridge } from "./dynamic-tools.js";
import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js";
import { CodexAppServerEventProjector } from "./event-projector.js";
@@ -618,7 +623,7 @@ export async function runCodexAppServerAttempt(
const toolBridge = createCodexDynamicToolBridge({
tools,
signal: runAbortController.signal,
loading: pluginConfig.codexDynamicToolsLoading ?? "searchable",
loading: resolveCodexDynamicToolsLoading(pluginConfig),
directToolNames: shouldForceMessageTool(params) ? ["message"] : [],
hookContext: {
agentId: sessionAgentId,
@@ -2748,7 +2753,8 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
senderUsername: params.senderUsername,
senderE164: params.senderE164,
senderIsOwner: params.senderIsOwner,
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
allowGatewaySubagentBinding:
params.allowGatewaySubagentBinding || isForcedPrivateQaCodexRuntime(),
...sessionKeys,
sessionId: params.sessionId,
runId: params.runId,
@@ -3933,6 +3939,7 @@ export const __testing = {
isInvalidCodexImagePayloadError,
remapCodexContextFilePath,
resolveDynamicToolCallTimeoutMs,
resolveCodexDynamicToolsLoading,
restrictCodexAppServerSandboxForOpenClawSandbox,
resolveCodexAppServerForOpenClawToolPolicy,
resolveOpenClawCodingToolsSessionKeys,

View File

@@ -16,7 +16,10 @@ import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js";
import { refreshCodexAppServerAuthTokens } from "./auth-bridge.js";
import { isCodexAppServerApprovalRequest, type CodexAppServerClient } from "./client.js";
import { readCodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
import { filterCodexDynamicTools } from "./dynamic-tool-profile.js";
import {
filterCodexDynamicTools,
resolveCodexDynamicToolsLoading,
} from "./dynamic-tool-profile.js";
import { createCodexDynamicToolBridge, type CodexDynamicToolBridge } from "./dynamic-tools.js";
import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js";
import {
@@ -378,7 +381,7 @@ async function createCodexSideToolBridge(input: {
return createCodexDynamicToolBridge({
tools,
signal: input.signal,
loading: input.pluginConfig.codexDynamicToolsLoading ?? "searchable",
loading: resolveCodexDynamicToolsLoading(input.pluginConfig),
hookContext: {
agentId: input.sessionAgentId,
config: input.params.cfg,