From 23ac9ccfd5602ea4d70a2eb437fdb767d0878bcf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 20:08:07 +0100 Subject: [PATCH] test: add codex npm plugin Docker live proof --- CHANGELOG.md | 1 + package.json | 1 + scripts/e2e/codex-npm-plugin-live-docker.sh | 174 ++++++++ .../lib/codex-npm-plugin-live/assertions.mjs | 401 ++++++++++++++++++ scripts/lib/docker-e2e-plan.mjs | 6 +- scripts/lib/docker-e2e-scenarios.mjs | 13 + .../contracts/host-hooks.contract.test.ts | 34 ++ src/plugins/install-security-scan.runtime.ts | 2 +- src/plugins/install.npm-spec.test.ts | 38 ++ src/plugins/install.test.ts | 4 +- src/plugins/install.ts | 30 +- src/plugins/registry.ts | 19 +- test/scripts/docker-e2e-plan.test.ts | 21 + 13 files changed, 738 insertions(+), 6 deletions(-) create mode 100644 scripts/e2e/codex-npm-plugin-live-docker.sh create mode 100644 scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4286ce17cca..e81ad2cfa25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky. +- Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof. ## 2026.5.2 diff --git a/package.json b/package.json index 840e5892dc0..c082991b526 100644 --- a/package.json +++ b/package.json @@ -1527,6 +1527,7 @@ "test:docker:live-cli-backend:gemini:resume": "OPENCLAW_LIVE_CLI_BACKEND_MODEL=google-gemini-cli/gemini-3-flash-preview OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE=1 bash scripts/test-live-cli-backend-docker.sh", "test:docker:live-codex-bind": "OPENCLAW_LIVE_CODEX_BIND=1 OPENCLAW_LIVE_CODEX_TEST_FILES=src/gateway/gateway-codex-bind.live.test.ts bash scripts/test-live-codex-harness-docker.sh", "test:docker:live-codex-harness": "bash scripts/test-live-codex-harness-docker.sh", + "test:docker:live-codex-npm-plugin": "bash scripts/e2e/codex-npm-plugin-live-docker.sh", "test:docker:live-gateway": "bash scripts/test-live-gateway-models-docker.sh", "test:docker:live-gateway:claude": "OPENCLAW_LIVE_GATEWAY_PROVIDERS=claude-cli OPENCLAW_LIVE_GATEWAY_MODELS=claude-cli/claude-sonnet-4-6 bash scripts/test-live-gateway-models-docker.sh", "test:docker:live-gateway:codex": "OPENCLAW_LIVE_GATEWAY_PROVIDERS=codex-cli OPENCLAW_LIVE_GATEWAY_MODELS=codex-cli/gpt-5.5 bash scripts/test-live-gateway-models-docker.sh", diff --git a/scripts/e2e/codex-npm-plugin-live-docker.sh b/scripts/e2e/codex-npm-plugin-live-docker.sh new file mode 100644 index 00000000000..6dae81500c7 --- /dev/null +++ b/scripts/e2e/codex-npm-plugin-live-docker.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# Installs OpenClaw from a prepared package tarball, installs @openclaw/codex +# from the real npm registry, and verifies a live Codex app-server turn. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-codex-npm-plugin-live-e2e" OPENCLAW_CODEX_NPM_PLUGIN_E2E_IMAGE)" +DOCKER_TARGET="${OPENCLAW_CODEX_NPM_PLUGIN_DOCKER_TARGET:-bare}" +HOST_BUILD="${OPENCLAW_CODEX_NPM_PLUGIN_HOST_BUILD:-1}" +PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" +PROFILE_FILE="${OPENCLAW_CODEX_NPM_PLUGIN_PROFILE_FILE:-$HOME/.profile}" + +docker_e2e_build_or_reuse "$IMAGE_NAME" codex-npm-plugin-live "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET" + +prepare_package_tgz() { + if [ -n "$PACKAGE_TGZ" ]; then + PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz codex-npm-plugin-live "$PACKAGE_TGZ")" + return 0 + fi + if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then + echo "OPENCLAW_CODEX_NPM_PLUGIN_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2 + exit 1 + fi + PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz codex-npm-plugin-live)" +} + +prepare_package_tgz + +PROFILE_MOUNT=() +PROFILE_STATUS="none" +if [ -f "$PROFILE_FILE" ] && [ -r "$PROFILE_FILE" ]; then + PROFILE_MOUNT=(-v "$PROFILE_FILE":/home/appuser/.profile:ro) + PROFILE_STATUS="$PROFILE_FILE" +fi + +docker_e2e_package_mount_args "$PACKAGE_TGZ" +run_log="$(docker_e2e_run_log codex-npm-plugin-live)" +OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 codex-npm-plugin-live empty)" + +echo "Running Codex npm plugin live Docker E2E..." +echo "Profile file: $PROFILE_STATUS" +if ! docker_e2e_run_with_harness \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENCLAW_CODEX_NPM_PLUGIN_MODEL="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:-codex/gpt-5.4}" \ + -e OPENCLAW_CODEX_NPM_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:-npm:@openclaw/codex@beta}" \ + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + "${PROFILE_MOUNT[@]}" \ + -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'; then +set -euo pipefail + +source scripts/lib/openclaw-e2e-instance.sh +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" +export NPM_CONFIG_PREFIX="$HOME/.npm-global" +export npm_config_prefix="$NPM_CONFIG_PREFIX" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" +export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" +export npm_config_cache="$NPM_CONFIG_CACHE" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +export OPENCLAW_AGENT_HARNESS_FALLBACK=none + +for profile_path in "$HOME/.profile" /home/appuser/.profile; do + if [ -f "$profile_path" ] && [ -r "$profile_path" ]; then + set +e +u + source "$profile_path" + set -euo pipefail + break + fi +done +if [ -z "${OPENAI_API_KEY:-}" ]; then + echo "ERROR: OPENAI_API_KEY was not available after sourcing ~/.profile." >&2 + exit 1 +fi +export OPENAI_API_KEY +if [ -n "${OPENAI_BASE_URL:-}" ]; then + export OPENAI_BASE_URL +fi + +CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:?missing OPENCLAW_CODEX_NPM_PLUGIN_SPEC}" +MODEL_REF="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:?missing OPENCLAW_CODEX_NPM_PLUGIN_MODEL}" +SESSION_ID="codex-npm-plugin-live" +SUCCESS_MARKER="OPENCLAW-CODEX-NPM-PLUGIN-LIVE-OK" + +dump_debug_logs() { + local status="$1" + echo "Codex npm plugin live scenario failed with exit code $status" >&2 + openclaw_e2e_dump_logs \ + /tmp/openclaw-install.log \ + /tmp/openclaw-codex-plugin-install.log \ + /tmp/openclaw-codex-plugin-enable.log \ + /tmp/openclaw-codex-plugins-list.json \ + /tmp/openclaw-codex-plugin-inspect.json \ + /tmp/openclaw-codex-preflight.log \ + /tmp/openclaw-codex-agent.json \ + /tmp/openclaw-codex-agent.err \ + /tmp/openclaw-codex-plugin-uninstall.log \ + /tmp/openclaw-codex-plugins-list-after-uninstall.json \ + /tmp/openclaw-codex-agent-after-uninstall.json \ + /tmp/openclaw-codex-agent-after-uninstall.err +} +trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR + +mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" +chmod 700 "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" || true + +openclaw_e2e_install_package /tmp/openclaw-install.log +command -v openclaw >/dev/null + +echo "Installing Codex plugin from npm: $CODEX_PLUGIN_SPEC" +openclaw plugins install "$CODEX_PLUGIN_SPEC" --force >/tmp/openclaw-codex-plugin-install.log 2>&1 + +node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs configure "$MODEL_REF" + +echo "Enabling Codex plugin..." +openclaw plugins enable codex >/tmp/openclaw-codex-plugin-enable.log 2>&1 + +openclaw plugins list --json >/tmp/openclaw-codex-plugins-list.json +openclaw plugins inspect codex --runtime --json >/tmp/openclaw-codex-plugin-inspect.json +node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-plugin "$CODEX_PLUGIN_SPEC" +node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-npm-deps + +CODEX_BIN="$(node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs print-codex-bin)" +printf '%s\n' "$OPENAI_API_KEY" | "$CODEX_BIN" login --with-api-key >/dev/null + +echo "Running Codex CLI preflight via managed npm dependency..." +"$CODEX_BIN" exec \ + --json \ + --color never \ + --skip-git-repo-check \ + "Reply exactly: ${SUCCESS_MARKER}-PREFLIGHT" >/tmp/openclaw-codex-preflight.log 2>&1 +node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-preflight "${SUCCESS_MARKER}-PREFLIGHT" + +echo "Running OpenClaw local agent turn through npm-installed Codex plugin..." +openclaw agent --local \ + --agent main \ + --session-id "$SESSION_ID" \ + --model "$MODEL_REF" \ + --message "Reply exactly: $SUCCESS_MARKER" \ + --thinking low \ + --timeout 420 \ + --json >/tmp/openclaw-codex-agent.json 2>/tmp/openclaw-codex-agent.err + +node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$SESSION_ID" "$MODEL_REF" + +echo "Uninstalling Codex plugin and verifying the configured harness now fails..." +openclaw plugins uninstall codex --force >/tmp/openclaw-codex-plugin-uninstall.log 2>&1 +openclaw plugins list --json >/tmp/openclaw-codex-plugins-list-after-uninstall.json +node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-uninstalled + +set +e +openclaw agent --local \ + --agent main \ + --session-id "${SESSION_ID}-after-uninstall" \ + --model "$MODEL_REF" \ + --message "Reply exactly: ${SUCCESS_MARKER}-AFTER-UNINSTALL" \ + --thinking low \ + --timeout 120 \ + --json >/tmp/openclaw-codex-agent-after-uninstall.json 2>/tmp/openclaw-codex-agent-after-uninstall.err +after_uninstall_status=$? +set -e +node scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs assert-agent-error "$after_uninstall_status" + +echo "Codex npm plugin live Docker E2E passed" +EOF + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 +fi + +rm -f "$run_log" +echo "Codex npm plugin live Docker E2E passed" diff --git a/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs b/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs new file mode 100644 index 00000000000..a92587df410 --- /dev/null +++ b/scripts/e2e/lib/codex-npm-plugin-live/assertions.mjs @@ -0,0 +1,401 @@ +import fs from "node:fs"; +import path from "node:path"; + +const command = process.argv[2]; +const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); + +function stateDir() { + return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME, ".openclaw"); +} + +function configPath() { + return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir(), "openclaw.json"); +} + +function realPathMaybe(filePath) { + try { + return fs.realpathSync(filePath); + } catch { + return path.resolve(filePath); + } +} + +function assertPathInside(parentPath, childPath, label) { + const parent = realPathMaybe(parentPath); + const child = realPathMaybe(childPath); + const relative = path.relative(parent, child); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`${label} resolved outside ${parentPath}: ${child}`); + } +} + +function configure() { + const modelRef = process.argv[3] || "codex/gpt-5.4"; + const state = stateDir(); + const cfgPath = configPath(); + const cfg = fs.existsSync(cfgPath) ? readJson(cfgPath) : {}; + cfg.plugins = { + ...cfg.plugins, + enabled: true, + allow: Array.from(new Set([...(cfg.plugins?.allow || []), "codex"])).sort(), + entries: { + ...cfg.plugins?.entries, + codex: { + ...cfg.plugins?.entries?.codex, + enabled: true, + config: { + ...cfg.plugins?.entries?.codex?.config, + discovery: { enabled: false }, + appServer: { + ...cfg.plugins?.entries?.codex?.config?.appServer, + mode: "yolo", + approvalPolicy: "never", + sandbox: "danger-full-access", + requestTimeoutMs: 420_000, + }, + }, + }, + }, + }; + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { primary: modelRef, fallbacks: [] }, + agentRuntime: { id: "codex", fallback: "none" }, + workspace: path.join(state, "workspace"), + skipBootstrap: true, + timeoutSeconds: 420, + }, + }; + fs.mkdirSync(path.dirname(cfgPath), { recursive: true }); + fs.writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`); +} + +function readInstallRecord() { + const indexPath = path.join(stateDir(), "plugins", "installs.json"); + const index = readJson(indexPath); + const record = (index.installRecords || index.records || {}).codex; + if (!record) { + throw new Error("missing codex install record"); + } + return record; +} + +function readInstallRecords() { + const indexPath = path.join(stateDir(), "plugins", "installs.json"); + if (!fs.existsSync(indexPath)) { + return {}; + } + const index = readJson(indexPath); + return index.installRecords || index.records || {}; +} + +function assertPlugin() { + const spec = process.argv[3] || "npm:@openclaw/codex@beta"; + const list = readJson("/tmp/openclaw-codex-plugins-list.json"); + const inspect = readJson("/tmp/openclaw-codex-plugin-inspect.json"); + const plugin = (list.plugins || []).find((entry) => entry.id === "codex"); + if (!plugin) { + throw new Error("codex plugin not found in plugins list --json output"); + } + if (plugin.status !== "loaded" || plugin.enabled !== true) { + throw new Error( + `expected codex to be enabled+loaded, got enabled=${plugin.enabled} status=${plugin.status}`, + ); + } + if (inspect.plugin?.id !== "codex" || inspect.plugin?.status !== "loaded") { + throw new Error(`unexpected inspect plugin state: ${JSON.stringify(inspect.plugin)}`); + } + if ( + !Array.isArray(inspect.plugin?.providerIds) || + !inspect.plugin.providerIds.includes("codex") + ) { + throw new Error(`codex provider was not registered: ${JSON.stringify(inspect.plugin)}`); + } + const hasCodexHarness = + (Array.isArray(inspect.plugin?.agentHarnessIds) && + inspect.plugin.agentHarnessIds.includes("codex")) || + (Array.isArray(inspect.capabilities) && + inspect.capabilities.some( + (entry) => entry?.kind === "agent-harness" && entry.ids?.includes("codex"), + )); + if (!hasCodexHarness) { + throw new Error(`codex harness was not registered: ${JSON.stringify(inspect.plugin)}`); + } + const diagnostics = [...(list.diagnostics || []), ...(inspect.diagnostics || [])]; + const errors = diagnostics + .filter((diag) => diag?.level === "error") + .map((diag) => String(diag.message || "")); + if (errors.length > 0) { + throw new Error(`unexpected plugin diagnostics errors: ${errors.join("; ")}`); + } + + const record = readInstallRecord(); + const expectedSpec = spec.replace(/^npm:/u, ""); + if (record.source !== "npm") { + throw new Error(`expected codex npm install record, got source=${record.source}`); + } + if (record.spec !== expectedSpec) { + throw new Error(`expected codex npm spec ${expectedSpec}, got ${record.spec}`); + } + if (!record.resolvedVersion || !record.resolvedSpec) { + throw new Error(`missing codex npm resolution metadata: ${JSON.stringify(record)}`); + } +} + +function managedNpmRoot() { + return path.join(stateDir(), "npm"); +} + +function codexInstallPath() { + const record = readInstallRecord(); + if (typeof record.installPath !== "string" || record.installPath.length === 0) { + throw new Error(`missing codex installPath: ${JSON.stringify(record)}`); + } + return record.installPath.replace(/^~(?=$|\/)/u, process.env.HOME); +} + +function findPackageJson(packageName) { + const parts = packageName.split("/"); + const candidates = + packageName.startsWith("@") && parts.length === 2 + ? [ + path.join(codexInstallPath(), "node_modules", parts[0], parts[1], "package.json"), + path.join(managedNpmRoot(), "node_modules", parts[0], parts[1], "package.json"), + ] + : [ + path.join(codexInstallPath(), "node_modules", packageName, "package.json"), + path.join(managedNpmRoot(), "node_modules", packageName, "package.json"), + ]; + return candidates.find((candidate) => fs.existsSync(candidate)); +} + +function assertNpmDeps() { + const npmRoot = managedNpmRoot(); + const installPath = codexInstallPath(); + const pluginPackageJson = path.join(installPath, "package.json"); + if (!fs.existsSync(pluginPackageJson)) { + throw new Error(`missing npm-installed @openclaw/codex package.json: ${pluginPackageJson}`); + } + assertPathInside(npmRoot, installPath, "codex plugin install path"); + assertPathInside(npmRoot, pluginPackageJson, "codex plugin package"); + + const pluginPackage = readJson(pluginPackageJson); + if (pluginPackage.name !== "@openclaw/codex") { + throw new Error(`unexpected codex package name: ${pluginPackage.name}`); + } + + const openAiCodexPackageJson = findPackageJson("@openai/codex"); + if (!openAiCodexPackageJson) { + throw new Error("missing @openai/codex dependency under .openclaw/npm"); + } + assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency"); + + const bin = resolveCodexBin(); + if (!fs.existsSync(bin)) { + throw new Error(`missing managed Codex binary: ${bin}`); + } + assertPathInside(npmRoot, bin, "managed Codex binary"); +} + +function resolveCodexBin() { + const commandName = process.platform === "win32" ? "codex.cmd" : "codex"; + const candidates = [ + path.join(codexInstallPath(), "node_modules", ".bin", commandName), + path.join(managedNpmRoot(), "node_modules", ".bin", commandName), + ]; + const candidate = candidates.find((entry) => fs.existsSync(entry)); + if (candidate) { + return candidate; + } + const packageJson = findPackageJson("@openai/codex"); + if (!packageJson) { + throw new Error("cannot resolve Codex binary without @openai/codex package"); + } + const packageRoot = path.dirname(packageJson); + const pkg = readJson(packageJson); + const binPath = + typeof pkg.bin === "string" + ? pkg.bin + : pkg.bin && typeof pkg.bin.codex === "string" + ? pkg.bin.codex + : undefined; + if (!binPath) { + throw new Error(`@openai/codex package has no codex bin: ${packageJson}`); + } + return path.resolve(packageRoot, binPath); +} + +function printCodexBin() { + assertNpmDeps(); + process.stdout.write(`${resolveCodexBin()}\n`); +} + +function assertPreflight() { + const marker = process.argv[3]; + const output = fs.readFileSync("/tmp/openclaw-codex-preflight.log", "utf8"); + if (!output.includes(marker)) { + throw new Error(`Codex CLI preflight did not contain ${marker}:\n${output}`); + } +} + +function listFilesRecursive(root) { + if (!fs.existsSync(root)) { + return []; + } + const files = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + const entries = fs.readdirSync(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + } + return files; +} + +function assertNativeCodexSessionEvidence(params) { + const roots = params.roots.filter((root) => fs.existsSync(root)); + const files = roots.flatMap((root) => + listFilesRecursive(root).filter((filePath) => filePath.endsWith(".jsonl")), + ); + if (files.length === 0) { + throw new Error( + `missing native Codex session transcript files; checked ${params.roots.join(", ")}`, + ); + } + const matchingFile = files.find((filePath) => { + const content = fs.readFileSync(filePath, "utf8"); + return content.includes(params.marker) || content.includes(params.threadId); + }); + if (!matchingFile) { + throw new Error( + `native Codex session transcripts did not contain ${params.marker} or ${params.threadId}; checked ${files.join(", ")}`, + ); + } + assertPathInside(params.codexHome, matchingFile, "native Codex session transcript"); +} + +function assertAgentTurn() { + const marker = process.argv[3]; + const sessionId = process.argv[4]; + const modelRef = process.argv[5]; + const stdout = fs.readFileSync("/tmp/openclaw-codex-agent.json", "utf8"); + const stderr = fs.existsSync("/tmp/openclaw-codex-agent.err") + ? fs.readFileSync("/tmp/openclaw-codex-agent.err", "utf8") + : ""; + const response = JSON.parse(stdout); + const text = (response.payloads || []).map((payload) => payload?.text || "").join("\n"); + if (!text.includes(marker)) { + throw new Error( + `OpenClaw agent reply did not contain ${marker}:\nstdout=${stdout}\nstderr=${stderr}`, + ); + } + + const sessionsDir = path.join(stateDir(), "agents", "main", "sessions"); + const storePath = path.join(sessionsDir, "sessions.json"); + const store = readJson(storePath); + const entry = Object.values(store).find((candidate) => candidate?.sessionId === sessionId); + if (!entry) { + throw new Error(`missing session store entry for ${sessionId}: ${JSON.stringify(store)}`); + } + if (entry.agentHarnessId !== "codex") { + throw new Error(`expected codex harness in session entry, got ${entry.agentHarnessId}`); + } + if (entry.modelOverride && entry.modelOverride !== modelRef) { + throw new Error(`unexpected session model override: ${entry.modelOverride}`); + } + if (typeof entry.sessionFile !== "string" || !fs.existsSync(entry.sessionFile)) { + throw new Error(`missing OpenClaw session file: ${entry.sessionFile}`); + } + + const bindingPath = `${entry.sessionFile}.codex-app-server.json`; + const binding = readJson(bindingPath); + if (binding.schemaVersion !== 1 || typeof binding.threadId !== "string") { + throw new Error(`invalid Codex app-server binding: ${JSON.stringify(binding)}`); + } + if (binding.model !== modelRef.split("/").slice(1).join("/")) { + throw new Error(`unexpected Codex binding model: ${binding.model}`); + } + if (binding.modelProvider && binding.modelProvider !== "codex") { + throw new Error(`unexpected Codex binding provider: ${binding.modelProvider}`); + } + + const codexHome = path.join(stateDir(), "agents", "main", "agent", "codex-home"); + const nativeHome = path.join(codexHome, "home"); + if (!fs.existsSync(codexHome) || !fs.existsSync(nativeHome)) { + throw new Error(`missing isolated Codex home: ${codexHome}`); + } + const codexSessionRoot = path.join(codexHome, "sessions"); + const nativeSessionRoot = path.join(nativeHome, ".codex", "sessions"); + assertNativeCodexSessionEvidence({ + codexHome, + marker, + roots: [codexSessionRoot, nativeSessionRoot], + threadId: binding.threadId, + }); +} + +function assertUninstalled() { + const records = readInstallRecords(); + if (records.codex) { + throw new Error( + `codex install record still exists after uninstall: ${JSON.stringify(records.codex)}`, + ); + } + const list = readJson("/tmp/openclaw-codex-plugins-list-after-uninstall.json"); + const plugin = (list.plugins || []).find((entry) => entry.id === "codex"); + if (plugin?.status === "loaded" || plugin?.enabled === true) { + throw new Error(`codex plugin still loaded/enabled after uninstall: ${JSON.stringify(plugin)}`); + } + const diagnostics = list.diagnostics || []; + const errors = diagnostics + .filter((diag) => diag?.level === "error") + .map((diag) => String(diag.message || "")); + if (errors.length > 0) { + throw new Error(`unexpected plugin diagnostics errors after uninstall: ${errors.join("; ")}`); + } +} + +function assertAgentError() { + const status = Number(process.argv[3]); + if (!Number.isInteger(status) || status === 0) { + throw new Error( + `expected OpenClaw agent to fail after Codex uninstall, got status ${process.argv[3]}`, + ); + } + const stdout = fs.existsSync("/tmp/openclaw-codex-agent-after-uninstall.json") + ? fs.readFileSync("/tmp/openclaw-codex-agent-after-uninstall.json", "utf8") + : ""; + const stderr = fs.existsSync("/tmp/openclaw-codex-agent-after-uninstall.err") + ? fs.readFileSync("/tmp/openclaw-codex-agent-after-uninstall.err", "utf8") + : ""; + const combined = `${stdout}\n${stderr}`; + if (!combined.includes('Requested agent harness "codex" is not registered')) { + throw new Error(`unexpected post-uninstall agent error:\nstdout=${stdout}\nstderr=${stderr}`); + } +} + +const commands = { + configure, + "assert-plugin": assertPlugin, + "assert-npm-deps": assertNpmDeps, + "print-codex-bin": printCodexBin, + "assert-preflight": assertPreflight, + "assert-agent-turn": assertAgentTurn, + "assert-uninstalled": assertUninstalled, + "assert-agent-error": assertAgentError, +}; + +const fn = commands[command]; +if (!fn) { + throw new Error(`unknown codex npm plugin live assertion command: ${command}`); +} +fn(); diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index fbe229b7637..421128108ab 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -327,7 +327,11 @@ function laneCredentialRequirements(poolLane) { if (poolLane.name === "install-e2e-anthropic") { credentials.push("anthropic"); } - if (poolLane.name === "openwebui" || poolLane.name === "openai-web-search-minimal") { + if ( + poolLane.name === "openwebui" || + poolLane.name === "openai-web-search-minimal" || + poolLane.name === "live-codex-npm-plugin" + ) { credentials.push("openai"); } return credentials; diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index bdbd43593ef..7fbedbe121e 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -298,6 +298,19 @@ export const tailLanes = [ weight: 3, }, ), + liveLane( + "live-codex-npm-plugin", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-npm-plugin", + { + cacheKey: "codex-npm-plugin", + e2eImageKind: "bare", + provider: "openai", + resources: ["npm"], + stateScenario: "empty", + timeoutMs: 30 * 60 * 1000, + weight: 3, + }, + ), liveLane( "live-cli-backend-codex", liveDockerScriptCommand( diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts index 76a239b0a9a..33f4ff51fed 100644 --- a/src/plugins/contracts/host-hooks.contract.test.ts +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -127,6 +127,40 @@ describe("host-hook fixture plugin contract", () => { ); }); + it("allows the official npm Codex plugin to keep /codex command ownership", () => { + const { config, registry } = createPluginRegistryFixture(); + const codexRoot = path.join("/tmp", ".openclaw", "npm", "node_modules", "@openclaw", "codex"); + registerTestPlugin({ + registry, + config, + record: createPluginRecord({ + id: "codex", + name: "Codex", + origin: "global", + rootDir: codexRoot, + source: path.join(codexRoot, "index.ts"), + }), + register(api) { + api.registerCommand({ + name: "codex", + description: "Official npm Codex command", + ownership: "reserved", + handler: async () => ({ text: "ok" }), + }); + }, + }); + + expect(registry.registry.commands.map((entry) => entry.command.name)).toEqual(["codex"]); + expect(registry.registry.diagnostics).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "codex", + message: expect.stringContaining("only bundled plugins can claim reserved command"), + }), + ]), + ); + }); + it("rejects reserved command ownership for non-reserved bundled command names", () => { const { config, registry } = createPluginRegistryFixture(); registerTestPlugin({ diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index fa46b314249..b09c25fbc08 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -601,7 +601,7 @@ function logTrustedSourceLinkedOfficialInstall(params: { targetLabel: string; }) { params.logger.warn?.( - `WARNING: ${params.targetLabel} allowed because it is an official source-linked ClawHub package: ${buildCriticalDetails({ findings: params.findings })}`, + `WARNING: ${params.targetLabel} allowed because it is an official OpenClaw package: ${buildCriticalDetails({ findings: params.findings })}`, ); } diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index f6f7301b9a7..f71f669f107 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -260,6 +260,44 @@ describe("installPluginFromNpmSpec", () => { }); }); + it("allows the official Codex npm plugin to spawn its managed app-server", async () => { + const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm"); + const warnings: string[] = []; + mockNpmViewAndInstall({ + spec: "@openclaw/codex@beta", + packageName: "@openclaw/codex", + version: "2026.5.1-beta.1", + pluginId: "codex", + npmRoot, + indexJs: `import { spawn } from "node:child_process";\nspawn("codex", ["app-server"]);`, + }); + + const result = await installPluginFromNpmSpec({ + spec: "@openclaw/codex@beta", + npmDir: npmRoot, + logger: { + info: () => {}, + warn: (msg: string) => warnings.push(msg), + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.pluginId).toBe("codex"); + expect( + warnings.some((warning) => + warning.includes("allowed because it is an official OpenClaw package"), + ), + ).toBe(true); + expectNpmInstallIntoRoot({ + calls: runCommandWithTimeoutMock.mock.calls, + npmRoot, + spec: "@openclaw/codex@beta", + }); + }); + it("rejects non-registry npm specs", async () => { const result = await installPluginFromNpmSpec({ spec: "github:evil/evil" }); expect(result.ok).toBe(false); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index ee8cf313483..cd1481d9ec8 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -773,7 +773,7 @@ describe("installPluginFromArchive", () => { expect(result.ok).toBe(true); expect( warnings.some((warning) => - warning.includes("allowed because it is an official source-linked ClawHub package"), + warning.includes("allowed because it is an official OpenClaw package"), ), ).toBe(true); }); @@ -1935,7 +1935,7 @@ describe("installPluginFromArchive", () => { expect(result.ok).toBe(true); expect( warnings.some((warning) => - warning.includes("allowed because it is an official source-linked ClawHub package"), + warning.includes("allowed because it is an official OpenClaw package"), ), ).toBe(true); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 8e8d17dab78..a0e41be21b6 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -110,6 +110,7 @@ type PluginInstallPolicyRequest = { }; const defaultLogger: PluginInstallLogger = {}; +const TRUSTED_OFFICIAL_NPM_PLUGIN_PACKAGES = new Map([["@openclaw/codex", "codex"]]); function ensureOpenClawExtensions(params: { manifest: PackageManifest }): | { @@ -185,6 +186,26 @@ function hasPackageRuntimeDependencies(manifest: PackageManifest): boolean { ); } +function isTrustedOfficialNpmPluginInstall(params: { + installPolicyRequest?: PluginInstallPolicyRequest; + packageName: string; + pluginId: string; +}): boolean { + if (params.installPolicyRequest?.kind !== "plugin-npm") { + return false; + } + const requested = parseRegistryNpmSpec(params.installPolicyRequest.requestedSpecifier ?? ""); + if (!requested) { + return false; + } + const expectedPluginId = TRUSTED_OFFICIAL_NPM_PLUGIN_PACKAGES.get(requested.name); + return ( + expectedPluginId !== undefined && + params.packageName === requested.name && + params.pluginId === expectedPluginId + ); +} + function buildBlockedInstallResult(params: { blocked: NonNullable["blocked"]>; }): Extract { @@ -697,12 +718,19 @@ async function validatePackagePluginInstallSource(params: { const scanMode = params.resolveEffectiveMode ? await params.resolveEffectiveMode(pluginId) : params.mode; + const trustedOfficialInstall = + params.trustedSourceLinkedOfficialInstall || + isTrustedOfficialNpmPluginInstall({ + installPolicyRequest: params.installPolicyRequest, + packageName: pkgName, + pluginId, + }); const scanResult = await runInstallSourceScan({ subject: `Plugin "${pluginId}"`, scan: async () => await params.runtime.scanPackageInstallSource({ dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, - trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall, + trustedSourceLinkedOfficialInstall: trustedOfficialInstall, packageDir: params.packageDir, pluginId, logger: params.logger, diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 607590c7126..d3a3590c141 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -250,6 +250,23 @@ export function resolvePluginPath(input: string, rootDir: string | undefined): s return rootDir ? path.resolve(rootDir, trimmed) : resolveUserPath(input); } +function isOfficialNpmCodexPluginRecord(record: Pick) { + if (record.id !== "codex") { + return false; + } + const sourcePath = path + .normalize(record.rootDir ?? record.source) + .split(path.sep) + .join("/"); + return sourcePath.includes("/node_modules/@openclaw/codex"); +} + +function canClaimReservedCommandOwnership( + record: Pick, +) { + return record.origin === "bundled" || isOfficialNpmCodexPluginRecord(record); +} + const ACTIVE_PLUGIN_HOOK_REGISTRATIONS_KEY = Symbol.for("openclaw.activePluginHookRegistrations"); const activePluginHookRegistrations = resolveGlobalSingleton< Map[1] }>> @@ -1428,7 +1445,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } const allowReservedCommandNames = command.ownership === "reserved"; - if (allowReservedCommandNames && record.origin !== "bundled") { + if (allowReservedCommandNames && !canClaimReservedCommandOwnership(record)) { pushDiagnostic({ level: "error", pluginId: record.id, diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 7faa467950b..6455429f107 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -423,6 +423,27 @@ describe("scripts/lib/docker-e2e-plan", () => { }); }); + it("plans the Codex npm plugin live lane as package-backed OpenAI proof", () => { + const plan = planFor({ selectedLaneNames: ["live-codex-npm-plugin"] }); + + expect(plan.credentials).toEqual(["openai"]); + expect(plan.lanes).toEqual([ + expect.objectContaining({ + command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-codex-npm-plugin", + imageKind: "bare", + live: true, + name: "live-codex-npm-plugin", + resources: ["docker", "live", "live:openai", "npm"], + stateScenario: "empty", + }), + ]); + expect(plan.needs).toMatchObject({ + bareImage: true, + liveImage: true, + package: true, + }); + }); + it("plans Open WebUI as a functional-image lane with OpenAI credentials", () => { const plan = planFor({ includeOpenWebUI: true,