diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 3a8f6991663c..6b70867fb26d 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -149,6 +149,21 @@ desktop-first rule applies when an isolated agent home's effective Codex config enables native Computer Use. If no desktop app bundle is installed, OpenClaw falls back to the pinned package binary. +Before cutting over a staged OpenClaw package, run the opt-in managed-binary +check against the candidate installation: + +```bash +openclaw doctor --lint --only codex/managed-app-server --json +``` + +The check is read-only. For every configured Codex agent it applies the same +final command selection as a live harness turn, then verifies that a selected +package-owned native binary exists and reports the plugin's exact pinned +version. A selected Codex Desktop binary, an explicit custom command, and a +remote app-server are outside this package check. The command exits nonzero on +an error-level finding, so a deployer can reject the candidate before cutover +without changing Codex state or app-server settings. + Executable handoff and native-config fencing coordinate clients inside one running Gateway process. Restart the Gateway after another process changes the native Codex plugin config. diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 4eefa874eb8a..e24d3777e24b 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -462,6 +462,18 @@ Then check Codex app-server state: /codex binding ``` +After installing or updating OpenClaw, explicitly verify the managed package +binary before cutover: + +```bash +openclaw doctor --lint --only codex/managed-app-server --json +``` + +For an effective Codex route using the managed stdio app-server, this +default-disabled check resolves the platform-native executable and requires the +exact Codex version pinned by OpenClaw. It does not execute custom, remote, or +macOS desktop-owned app-servers. + `/status` reports the resolved OpenClaw Fast policy (`on`, `off`, or `auto`) and the selected runtime. It does not report the upstream service tier actually honored or returned for a completed request. `/codex binding` reports the diff --git a/extensions/codex/api.ts b/extensions/codex/api.ts new file mode 100644 index 000000000000..dc6b5b647674 --- /dev/null +++ b/extensions/codex/api.ts @@ -0,0 +1,17 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { + CODEX_MANAGED_APP_SERVER_CHECK_ID, + registerCodexManagedAppServerDoctorChecks as registerChecks, +} from "./src/doctor.js"; + +const CODEX_PLUGIN_ROOT = path.dirname(fileURLToPath(import.meta.url)); + +export { CODEX_MANAGED_APP_SERVER_CHECK_ID }; + +export function registerCodexManagedAppServerDoctorChecks(host: { + registerHealthCheck(check: HealthCheck): void; +}): void { + registerChecks({ ...host, pluginRoot: CODEX_PLUGIN_ROOT }); +} diff --git a/extensions/codex/src/app-server/managed-binary.ts b/extensions/codex/src/app-server/managed-binary.ts index 4bca35c4b692..389a7951a091 100644 --- a/extensions/codex/src/app-server/managed-binary.ts +++ b/extensions/codex/src/app-server/managed-binary.ts @@ -76,10 +76,7 @@ export function resolveManagedCodexNativeCommand( options: ResolveManagedCodexNativeCommandOptions = {}, ): string | undefined { const platform = options.platform ?? process.platform; - if ( - platform === "darwin" && - MACOS_DESKTOP_CODEX_APP_SERVER_COMMANDS.some((candidate) => candidate === command) - ) { + if (isManagedCodexDesktopCommand(command, platform)) { return command; } const target = resolveCodexNativeTarget(platform, options.arch ?? process.arch); @@ -111,6 +108,17 @@ export function resolveManagedCodexNativeCommand( return undefined; } +/** Returns whether a resolved managed command is owned by the macOS desktop app. */ +export function isManagedCodexDesktopCommand( + command: string, + platform: NodeJS.Platform = process.platform, +): boolean { + return ( + platform === "darwin" && + MACOS_DESKTOP_CODEX_APP_SERVER_COMMANDS.some((candidate) => candidate === command) + ); +} + function resolveManagedCodexPackageRootForCommand( command: string, platform: NodeJS.Platform, diff --git a/extensions/codex/src/doctor.test.ts b/extensions/codex/src/doctor.test.ts new file mode 100644 index 000000000000..678819a999dc --- /dev/null +++ b/extensions/codex/src/doctor.test.ts @@ -0,0 +1,319 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { HealthCheck, OpenClawConfig } from "openclaw/plugin-sdk/health"; +import { describe, expect, it, vi } from "vitest"; +import { CODEX_APP_SERVER_VERSION } from "./app-server/version.js"; +import { + CODEX_MANAGED_APP_SERVER_CHECK_ID, + registerCodexManagedAppServerDoctorChecks, +} from "./doctor.js"; + +function config(appServer: Record = {}): OpenClawConfig { + return { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-sol" }, + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + }, + }, + }, + plugins: { + entries: { + codex: { + enabled: true, + config: { + appServer: { + args: [ + "app-server", + "--listen", + "stdio://", + "-c", + "model_context_window=1000000", + "-c", + "model_auto_compact_token_limit=700000", + "-c", + "model_auto_compact_token_limit_scope=total", + ], + ...appServer, + }, + }, + }, + }, + }, + }; +} + +function context(cfg: OpenClawConfig) { + return { + mode: "lint" as const, + runtime: {} as never, + cfg, + env: {} as NodeJS.ProcessEnv, + }; +} + +function managedDeps(version = CODEX_APP_SERVER_VERSION) { + const resolveNativeCommand = vi.fn( + (_command: string): string | undefined => "/candidate/plugin/codex-native", + ); + return { + resolveStartOptions: vi.fn(async (start) => ({ + ...start, + command: + start.managedCommandOrder === "desktop-first" + ? "/Applications/ChatGPT.app/Contents/Resources/codex" + : "/candidate/plugin/codex", + commandSource: "resolved-managed" as const, + })), + isDesktopCommand: vi.fn((command: string) => command.startsWith("/Applications/")), + resolveNativeCommand, + runVersionCommand: vi.fn(async () => ({ stdout: `codex-cli ${version}\n`, stderr: "" })), + }; +} + +function createCheck(deps: ReturnType) { + let check: HealthCheck | undefined; + registerCodexManagedAppServerDoctorChecks( + { + pluginRoot: "/candidate/plugin", + registerHealthCheck(value) { + check = value; + }, + }, + deps, + ); + if (!check) { + throw new Error("Codex managed health check was not registered"); + } + return check; +} + +describe("managed Codex doctor check", () => { + it("accepts the exact pinned native binary without changing explicit long-context config", async () => { + const cfg = config(); + const before = structuredClone(cfg); + const deps = managedDeps(); + const check = createCheck(deps); + + await expect(check.detect(context(cfg))).resolves.toEqual([]); + expect(deps.runVersionCommand).toHaveBeenCalledWith("/candidate/plugin/codex-native"); + expect(cfg).toEqual(before); + }); + + it("reports the exact expected and detected versions", async () => { + const deps = managedDeps("0.146.0"); + const check = createCheck(deps); + + await expect(check.detect(context(config()))).resolves.toEqual([ + expect.objectContaining({ + checkId: CODEX_MANAGED_APP_SERVER_CHECK_ID, + severity: "error", + path: "/candidate/plugin/codex-native", + message: `Managed Codex app-server version mismatch: expected ${CODEX_APP_SERVER_VERSION}, detected 0.146.0.`, + }), + ]); + }); + + it("reports a missing managed launcher before execution", async () => { + const deps = managedDeps(); + deps.resolveStartOptions.mockRejectedValueOnce(new Error("managed launcher missing")); + const check = createCheck(deps); + + await expect(check.detect(context(config()))).resolves.toEqual([ + expect.objectContaining({ + checkId: CODEX_MANAGED_APP_SERVER_CHECK_ID, + message: "Managed Codex app-server could not be resolved: managed launcher missing", + }), + ]); + expect(deps.runVersionCommand).not.toHaveBeenCalled(); + }); + + it("reports a launcher whose platform-native artifact is absent", async () => { + const deps = managedDeps(); + deps.resolveNativeCommand.mockReturnValueOnce(undefined); + const check = createCheck(deps); + + await expect(check.detect(context(config()))).resolves.toEqual([ + expect.objectContaining({ + checkId: CODEX_MANAGED_APP_SERVER_CHECK_ID, + path: "/candidate/plugin/codex", + message: "Managed Codex app-server resolved a launcher without a native artifact.", + }), + ]); + expect(deps.runVersionCommand).not.toHaveBeenCalled(); + }); + + it("reports a bounded version command failure", async () => { + const deps = managedDeps(); + deps.runVersionCommand.mockRejectedValueOnce(new Error("timed out after 5000 ms")); + const check = createCheck(deps); + + await expect(check.detect(context(config()))).resolves.toEqual([ + expect.objectContaining({ + checkId: CODEX_MANAGED_APP_SERVER_CHECK_ID, + path: "/candidate/plugin/codex-native", + message: "Managed Codex app-server version check failed: timed out after 5000 ms", + requirement: `Codex ${CODEX_APP_SERVER_VERSION} must report its version within 5000 ms`, + }), + ]); + }); + + it.each([ + ["custom command", { command: "/operator/codex" }], + ["websocket transport", { transport: "websocket", url: "ws://127.0.0.1:4500" }], + ["unix transport", { transport: "unix", url: "unix:///tmp/codex.sock", homeScope: "user" }], + ])("does not probe a %s", async (_label, appServer) => { + const deps = managedDeps(); + const check = createCheck(deps); + + await expect(check.detect(context(config(appServer)))).resolves.toEqual([]); + expect(deps.resolveStartOptions).not.toHaveBeenCalled(); + expect(deps.runVersionCommand).not.toHaveBeenCalled(); + }); + + it("does not enforce the package pin on a selected desktop-owned command", async () => { + const deps = managedDeps("0.146.0"); + const check = createCheck(deps); + + await expect(check.detect(context(config({ homeScope: "user" })))).resolves.toEqual([]); + expect(deps.resolveStartOptions).toHaveBeenCalledWith( + expect.objectContaining({ managedCommandOrder: "desktop-first" }), + { pluginRoot: "/candidate/plugin" }, + ); + expect(deps.resolveNativeCommand).not.toHaveBeenCalled(); + expect(deps.runVersionCommand).not.toHaveBeenCalled(); + }); + + it("uses persisted per-agent Computer Use state before selecting the managed command", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-doctor-agent-")); + try { + await fs.mkdir(path.join(agentDir, "codex-home")); + await fs.writeFile( + path.join(agentDir, "codex-home", "config.toml"), + '[plugins."computer-use@openai-bundled"]\nenabled = true\n', + ); + const cfg = config(); + cfg.agents = { + ...cfg.agents, + list: [{ id: "main", agentDir }], + }; + const deps = managedDeps("0.146.0"); + const check = createCheck(deps); + + await expect(check.detect(context(cfg))).resolves.toEqual([]); + expect(deps.resolveStartOptions).toHaveBeenCalledWith( + expect.objectContaining({ managedCommandOrder: "desktop-first" }), + { pluginRoot: "/candidate/plugin" }, + ); + expect(deps.resolveNativeCommand).not.toHaveBeenCalled(); + expect(deps.runVersionCommand).not.toHaveBeenCalled(); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("still validates the package when any configured agent can select it", async () => { + const desktopAgentDir = await fs.mkdtemp( + path.join(os.tmpdir(), "openclaw-codex-doctor-desktop-agent-"), + ); + const packageAgentDir = await fs.mkdtemp( + path.join(os.tmpdir(), "openclaw-codex-doctor-package-agent-"), + ); + try { + await fs.mkdir(path.join(desktopAgentDir, "codex-home")); + await fs.writeFile( + path.join(desktopAgentDir, "codex-home", "config.toml"), + '[plugins."computer-use@openai-bundled"]\nenabled = true\n', + ); + const cfg = config(); + cfg.agents = { + ...cfg.agents, + list: [ + { id: "desktop", agentDir: desktopAgentDir }, + { id: "package", agentDir: packageAgentDir }, + ], + }; + const deps = managedDeps("0.146.0"); + const check = createCheck(deps); + + await expect(check.detect(context(cfg))).resolves.toEqual([ + expect.objectContaining({ + checkId: CODEX_MANAGED_APP_SERVER_CHECK_ID, + message: `Managed Codex app-server version mismatch: expected ${CODEX_APP_SERVER_VERSION}, detected 0.146.0.`, + }), + ]); + expect(deps.resolveStartOptions).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ managedCommandOrder: "desktop-first" }), + { pluginRoot: "/candidate/plugin" }, + ); + expect(deps.resolveStartOptions).toHaveBeenNthCalledWith( + 2, + expect.not.objectContaining({ managedCommandOrder: expect.anything() }), + { pluginRoot: "/candidate/plugin" }, + ); + expect(deps.runVersionCommand).toHaveBeenCalledWith("/candidate/plugin/codex-native"); + } finally { + await Promise.all( + [desktopAgentDir, packageAgentDir].map((agentDir) => + fs.rm(agentDir, { recursive: true, force: true }), + ), + ); + } + }); + + it("ignores managed commands for agents whose effective runtime is not Codex", async () => { + const desktopAgentDir = await fs.mkdtemp( + path.join(os.tmpdir(), "openclaw-codex-doctor-desktop-agent-"), + ); + try { + await fs.mkdir(path.join(desktopAgentDir, "codex-home")); + await fs.writeFile( + path.join(desktopAgentDir, "codex-home", "config.toml"), + '[plugins."computer-use@openai-bundled"]\nenabled = true\n', + ); + const cfg = config(); + cfg.agents = { + ...cfg.agents, + list: [ + { id: "desktop", agentDir: desktopAgentDir }, + { + id: "openclaw", + model: "anthropic/claude-opus-4-7", + models: { + "anthropic/claude-opus-4-7": { agentRuntime: { id: "openclaw" } }, + }, + }, + ], + }; + const deps = managedDeps("0.146.0"); + const check = createCheck(deps); + + await expect(check.detect(context(cfg))).resolves.toEqual([]); + expect(deps.resolveStartOptions).toHaveBeenCalledTimes(1); + expect(deps.runVersionCommand).not.toHaveBeenCalled(); + } finally { + await fs.rm(desktopAgentDir, { recursive: true, force: true }); + } + }); + + it("still validates a package fallback selected after desktop-first resolution", async () => { + const deps = managedDeps("0.146.0"); + deps.resolveStartOptions.mockImplementationOnce(async (start) => ({ + ...start, + command: "/candidate/plugin/codex", + commandSource: "resolved-managed" as const, + })); + const check = createCheck(deps); + + await expect(check.detect(context(config({ homeScope: "user" })))).resolves.toEqual([ + expect.objectContaining({ + checkId: CODEX_MANAGED_APP_SERVER_CHECK_ID, + message: `Managed Codex app-server version mismatch: expected ${CODEX_APP_SERVER_VERSION}, detected 0.146.0.`, + }), + ]); + expect(deps.runVersionCommand).toHaveBeenCalledWith("/candidate/plugin/codex-native"); + }); +}); diff --git a/extensions/codex/src/doctor.ts b/extensions/codex/src/doctor.ts new file mode 100644 index 000000000000..87b651352240 --- /dev/null +++ b/extensions/codex/src/doctor.ts @@ -0,0 +1,216 @@ +import { execFile } from "node:child_process"; +import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; +import { listAgentIds, resolveAgentDir } from "openclaw/plugin-sdk/agent-scope-runtime"; +import { resolveEffectiveAgentRuntime } from "openclaw/plugin-sdk/command-auth-native"; +import { getHealthCheck, type HealthCheck, type HealthFinding } from "openclaw/plugin-sdk/health"; +import { + resolveCodexAppServerRuntimeOptions, + resolveCodexAppServerStartOptionsForAgent, +} from "./app-server/config.js"; +import { + isManagedCodexDesktopCommand, + resolveManagedCodexAppServerStartOptions, + resolveManagedCodexNativeCommand, +} from "./app-server/managed-binary.js"; +import { CODEX_APP_SERVER_VERSION } from "./app-server/version.js"; + +export const CODEX_MANAGED_APP_SERVER_CHECK_ID = "codex/managed-app-server"; +const CODEX_VERSION_TIMEOUT_MS = 5_000; +const CODEX_VERSION_MAX_BUFFER_BYTES = 64 * 1024; + +type VersionCommandResult = { + stdout: string; + stderr: string; +}; + +type CodexManagedDoctorDependencies = { + resolveAgentStartOptions?: typeof resolveCodexAppServerStartOptionsForAgent; + resolveStartOptions?: typeof resolveManagedCodexAppServerStartOptions; + isDesktopCommand?: typeof isManagedCodexDesktopCommand; + resolveNativeCommand?: typeof resolveManagedCodexNativeCommand; + runVersionCommand?: (command: string) => Promise; +}; + +type CodexManagedDoctorRegistrationHost = { + readonly registerHealthCheck: (check: HealthCheck) => void; + readonly pluginRoot: string; +}; + +function managedCodexFinding(params: { + message: string; + path?: string; + requirement?: string; + fixHint?: string; +}): HealthFinding { + return { + checkId: CODEX_MANAGED_APP_SERVER_CHECK_ID, + severity: "error", + source: "codex", + message: params.message, + ...(params.path ? { path: params.path } : {}), + ...(params.requirement ? { requirement: params.requirement } : {}), + ...(params.fixHint ? { fixHint: params.fixHint } : {}), + }; +} + +function readErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function parseCodexVersion(output: string): string | undefined { + return /(?:^|\s)(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?:\s|$)/u.exec( + output, + )?.[1]; +} + +function runVersionCommand(command: string): Promise { + return new Promise((resolve, reject) => { + execFile( + command, + ["--version"], + { + encoding: "utf8", + maxBuffer: CODEX_VERSION_MAX_BUFFER_BYTES, + timeout: CODEX_VERSION_TIMEOUT_MS, + windowsHide: true, + }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(readErrorMessage(error), { cause: error })); + return; + } + resolve({ stdout, stderr }); + }, + ); + }); +} + +function createCodexManagedAppServerHealthCheck(params: { + pluginRoot: string; + deps?: CodexManagedDoctorDependencies; +}): HealthCheck & { readonly defaultEnabled: false } { + const resolveStartOptions = + params.deps?.resolveStartOptions ?? resolveManagedCodexAppServerStartOptions; + const resolveAgentStartOptions = + params.deps?.resolveAgentStartOptions ?? resolveCodexAppServerStartOptionsForAgent; + const isDesktopCommand = params.deps?.isDesktopCommand ?? isManagedCodexDesktopCommand; + const resolveNativeCommand = + params.deps?.resolveNativeCommand ?? resolveManagedCodexNativeCommand; + const executeVersion = params.deps?.runVersionCommand ?? runVersionCommand; + + return { + id: CODEX_MANAGED_APP_SERVER_CHECK_ID, + kind: "plugin", + description: "Verify the selected managed Codex app-server binary and pinned version.", + source: "codex", + defaultEnabled: false, + async detect(ctx) { + const pluginConfig = ctx.cfg.plugins?.entries?.codex?.config; + const start = resolveCodexAppServerRuntimeOptions({ + pluginConfig, + env: ctx.env ?? process.env, + }).start; + if (start.transport !== "stdio" || start.commandSource !== "managed") { + return []; + } + + const env = ctx.env ?? process.env; + let resolved; + for (const agentId of listAgentIds(ctx.cfg)) { + const model = resolveDefaultModelForAgent({ cfg: ctx.cfg, agentId }); + if ( + resolveEffectiveAgentRuntime({ + cfg: ctx.cfg, + provider: model.provider, + modelId: model.model, + agentId, + }) !== "codex" + ) { + continue; + } + const agentStart = resolveAgentStartOptions({ + startOptions: start, + agentDir: resolveAgentDir(ctx.cfg, agentId, env), + env, + }); + try { + resolved = await resolveStartOptions(agentStart, { pluginRoot: params.pluginRoot }); + } catch (error) { + return [ + managedCodexFinding({ + message: `Managed Codex app-server could not be resolved: ${readErrorMessage(error)}`, + path: params.pluginRoot, + requirement: `an executable Codex ${CODEX_APP_SERVER_VERSION} managed artifact`, + fixHint: + "Reinstall the staged OpenClaw package with its @openai/codex platform dependency, then rerun the candidate check.", + }), + ]; + } + if (!isDesktopCommand(resolved.command)) { + break; + } + resolved = undefined; + } + + if (!resolved) { + return []; + } + + const nativeCommand = resolveNativeCommand(resolved.command); + if (!nativeCommand) { + return [ + managedCodexFinding({ + message: "Managed Codex app-server resolved a launcher without a native artifact.", + path: resolved.command, + requirement: `the platform-native Codex ${CODEX_APP_SERVER_VERSION} executable`, + fixHint: + "Reinstall the staged OpenClaw package with the matching @openai/codex platform package, then rerun the candidate check.", + }), + ]; + } + + let output: VersionCommandResult; + try { + output = await executeVersion(nativeCommand); + } catch (error) { + return [ + managedCodexFinding({ + message: `Managed Codex app-server version check failed: ${readErrorMessage(error)}`, + path: nativeCommand, + requirement: `Codex ${CODEX_APP_SERVER_VERSION} must report its version within ${CODEX_VERSION_TIMEOUT_MS} ms`, + fixHint: + "Repair or reinstall the staged OpenClaw package, then rerun the candidate check before cutover.", + }), + ]; + } + + const detectedVersion = parseCodexVersion(`${output.stdout}\n${output.stderr}`); + if (detectedVersion !== CODEX_APP_SERVER_VERSION) { + return [ + managedCodexFinding({ + message: detectedVersion + ? `Managed Codex app-server version mismatch: expected ${CODEX_APP_SERVER_VERSION}, detected ${detectedVersion}.` + : `Managed Codex app-server did not report a parseable version; expected ${CODEX_APP_SERVER_VERSION}.`, + path: nativeCommand, + requirement: `the exact OpenClaw-pinned Codex version ${CODEX_APP_SERVER_VERSION}`, + fixHint: + "Reinstall the staged OpenClaw package so its managed @openai/codex dependency matches the pinned version, then rerun the candidate check.", + }), + ]; + } + return []; + }, + }; +} + +export function registerCodexManagedAppServerDoctorChecks( + host: CodexManagedDoctorRegistrationHost, + deps?: CodexManagedDoctorDependencies, +): void { + if (getHealthCheck(CODEX_MANAGED_APP_SERVER_CHECK_ID)) { + return; + } + host.registerHealthCheck( + createCodexManagedAppServerHealthCheck({ pluginRoot: host.pluginRoot, deps }), + ); +} diff --git a/scripts/pr-lib/operation-lock.sh b/scripts/pr-lib/operation-lock.sh index 31a6ee62e3a4..016363eb7778 100644 --- a/scripts/pr-lib/operation-lock.sh +++ b/scripts/pr-lib/operation-lock.sh @@ -141,7 +141,11 @@ finish_pr_operation_completion() { local operation_status="$1" trap - EXIT trap '' PIPE - if [ "$BASHPID" = "$PR_OPERATION_COMPLETION_LEADER_PID" ]; then + # macOS system Bash 3.2 has no BASHPID. $$ identifies the top-level shell, + # while BASH_SUBSHELL fences forked subshells that retain the same $$. + if [ "${BASH_SUBSHELL:-0}" -eq 0 ] && + [ "$$" = "$PR_OPERATION_COMPLETION_LEADER_PID" ] + then notify_pr_operation_phase operation-complete 2>/dev/null || : fi exit "$operation_status" @@ -149,7 +153,7 @@ finish_pr_operation_completion() { install_pr_operation_completion_trap() { [ -z "$PR_OPERATION_COMPLETION_LEADER_PID" ] || return 0 - PR_OPERATION_COMPLETION_LEADER_PID="$BASHPID" + PR_OPERATION_COMPLETION_LEADER_PID="$$" trap 'finish_pr_operation_completion "$?"' EXIT } @@ -158,11 +162,11 @@ install_pr_operation_completion_trap() { if [ "${OPENCLAW_PR_DEDICATED_PROCESS_GROUP:-}" = "1" ]; then if [ "${OPENCLAW_PR_LOCK_NOTIFY_FD:-}" = "3" ] && [ "${OPENCLAW_PR_LOCK_SUPERVISOR_PID:-}" = "$PPID" ] && - [ "$BASHPID" = "$$" ] + [ "${BASH_SUBSHELL:-0}" -eq 0 ] then - pr_operation_entry_pgid=$(ps -o pgid= -p "$BASHPID" 2>/dev/null || true) + pr_operation_entry_pgid=$(ps -o pgid= -p "$$" 2>/dev/null || true) pr_operation_entry_pgid="${pr_operation_entry_pgid//[[:space:]]/}" - if [ "$pr_operation_entry_pgid" = "$BASHPID" ]; then + if [ "$pr_operation_entry_pgid" = "$$" ]; then install_pr_operation_completion_trap fi unset pr_operation_entry_pgid diff --git a/src/flows/bundled-health-checks.test.ts b/src/flows/bundled-health-checks.test.ts index 63749cb25251..3dcfe2e24666 100644 --- a/src/flows/bundled-health-checks.test.ts +++ b/src/flows/bundled-health-checks.test.ts @@ -12,6 +12,7 @@ import { const STATE_DEFERRED_CHECK_ID = "memory-core/managed-local-embedding-setup"; const mocks = vi.hoisted(() => ({ + registerCodexManagedAppServerDoctorChecks: vi.fn(), inspectEmbeddingProviderSetup: vi.fn(), loadPluginManifestRegistryForPluginRegistry: vi.fn(() => ({ plugins: [], @@ -28,7 +29,12 @@ const mocks = vi.hoisted(() => ({ } : dirName === "cua-computer" ? { registerCuaDriverDoctorChecks: mocks.registerCuaDriverDoctorChecks } - : { registerPolicyDoctorChecks: mocks.registerPolicyDoctorChecks }, + : dirName === "codex" + ? { + registerCodexManagedAppServerDoctorChecks: + mocks.registerCodexManagedAppServerDoctorChecks, + } + : { registerPolicyDoctorChecks: mocks.registerPolicyDoctorChecks }, ), resolveProviderPolicySurface: vi.fn((): ProviderPolicySurface | null => ({ inspectEmbeddingProviderSetup: mocks.inspectEmbeddingProviderSetup, @@ -282,6 +288,63 @@ describe("registerBundledHealthChecks", () => { }); }); + it("loads managed Codex health when an effective model route selects Codex", () => { + registerBundledHealthChecks({ + cfg: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-sol" }, + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + }, + }, + }, + }, + cwd: workspaceDir, + }); + + expect(mocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith({ + dirName: "codex", + artifactBasename: "api.js", + }); + expect(mocks.registerCodexManagedAppServerDoctorChecks).toHaveBeenCalledWith({ + registerHealthCheck: expect.any(Function), + }); + }); + + it("does not load managed Codex health for OpenClaw routes or disabled Codex", () => { + for (const cfg of [ + { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-sol" }, + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + }, + { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-sol" }, + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + }, + }, + }, + plugins: { entries: { codex: { enabled: false } } }, + }, + ]) { + vi.clearAllMocks(); + registerBundledHealthChecks({ cfg, cwd: workspaceDir }); + expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalledWith({ + dirName: "codex", + artifactBasename: "api.js", + }); + } + }); + it("does not use policy.jsonc existence as extension activation", () => { writeFileSync(join(workspaceDir, "policy.jsonc"), "{}\n", "utf-8"); diff --git a/src/flows/bundled-health-checks.ts b/src/flows/bundled-health-checks.ts index 93330463a42e..6e52f0baeaf5 100644 --- a/src/flows/bundled-health-checks.ts +++ b/src/flows/bundled-health-checks.ts @@ -1,5 +1,6 @@ // Bundled health checks define built-in doctor checks for runtime readiness. import { asOptionalObjectRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; +import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginId, normalizePluginsConfig } from "../plugins/config-state.js"; import { passesManifestOwnerBasePolicy } from "../plugins/manifest-owner-policy.js"; @@ -16,6 +17,9 @@ type EmbeddingProviderSetupInspectionResult = // Bridges bundled plugin doctor checks into the core health registry. type BundledHealthApi = { + registerCodexManagedAppServerDoctorChecks?: (host: { + registerHealthCheck: typeof registerHealthCheck; + }) => void; pluginStateIsolatedDoctorCheckIds?: readonly string[]; registerCuaDriverDoctorChecks?: (host: { registerHealthCheck: typeof registerHealthCheck; @@ -105,6 +109,12 @@ export function registerBundledHealthChecks(params: { }, memoryCoreActive: isMemoryCoreActive(params.cfg), }); + if (shouldRegisterCodexManagedHealth(params.cfg)) { + loadBundledPluginPublicArtifactModuleSync({ + dirName: "codex", + artifactBasename: "api.js", + }).registerCodexManagedAppServerDoctorChecks?.({ registerHealthCheck }); + } if (shouldRegisterPolicyHealth(params)) { loadBundledPluginPublicArtifactModuleSync({ dirName: "policy", @@ -119,6 +129,19 @@ export function registerBundledHealthChecks(params: { } } +function shouldRegisterCodexManagedHealth(cfg: OpenClawConfig): boolean { + if (!collectConfiguredAgentHarnessRuntimes(cfg).includes("codex")) { + return false; + } + if (cfg.plugins?.entries?.codex?.enabled === false) { + return false; + } + return passesManifestOwnerBasePolicy({ + plugin: { id: "codex" }, + normalizedConfig: normalizePluginsConfig(cfg.plugins), + }); +} + function isMemoryCoreActive(cfg: OpenClawConfig): boolean { const plugins = normalizePluginsConfig(cfg.plugins); const selectedMemoryPluginId = diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index 2b3b52e2b940..e805985cfe6e 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -30,6 +30,7 @@ const REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDING_COUNTS = new Map([ ["@openclaw/acpx:dangerous-exec:dist/mcp-proxy.mjs", 1], ["@openclaw/acpx:dangerous-exec:dist/service-.js", 1], - ["@openclaw/codex:dangerous-exec:dist/run-attempt-.js", 2], + ["@openclaw/codex:dangerous-exec:dist/api.js", 1], + ["@openclaw/codex:dangerous-exec:dist/dynamic-tools-.js", 2], ["@openclaw/codex:dangerous-exec:dist/session-catalog-.js", 1], ["@openclaw/codex:dangerous-exec:dist/transport-stdio-.js", 1], ["@openclaw/llama-cpp-provider:dangerous-exec:dist/index.js", 1], @@ -96,6 +98,7 @@ function isScannerWalkedPackedPath(packedPath: string): boolean { function normalizePackedFindingPath(packedPath: string): string { for (const prefix of [ + "dynamic-tools", "outbound-payload.test-harness", "run-attempt", "runtime-entry", @@ -330,14 +333,20 @@ describe("publishable plugin npm package install security scan", () => { }); it("requires exact occurrence counts for reviewed Codex dist chunks", () => { - const runAttemptKey = "@openclaw/codex:dangerous-exec:dist/run-attempt-.js"; + const dynamicToolsKey = "@openclaw/codex:dangerous-exec:dist/dynamic-tools-.js"; + expect( + expectedOptionalReviewedFindingsForPackedPath( + "@openclaw/codex", + "dist/dynamic-tools-current.js", + ), + ).toEqual([dynamicToolsKey, dynamicToolsKey]); expect( expectedOptionalReviewedFindingsForPackedPath( "@openclaw/codex", "dist/run-attempt-current.js", ), - ).toEqual([runAttemptKey, runAttemptKey]); + ).toEqual([]); expect( expectedOptionalReviewedFindingsForPackedPath( "@openclaw/codex",