diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0f9ea8b92..883e1becdd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Dependencies: refresh workspace dependency pins and lockfile, including `@openai/codex` `0.130.0`, `acpx` `0.7.0`, AWS SDK `3.1044.0`, OpenTelemetry `0.217.0`, `typebox` `1.1.38`, `vite` `8.0.11`, `oxfmt` `0.48.0`, and `oxlint` `1.63.0`, and update the Codex harness model snapshot for the new bundled app-server catalog. - Plugins/install: add guarded plugin install overrides so onboarding and repair tests can route specific plugins to registry specs or local `npm pack` artifacts via environment variables. +- Tests/Docker: add Codex on-demand install and live plugin-tool dependency E2E lanes for packaged onboarding and npm-pack plugin proof. - Plugins/ACPX: accept an optional `args` array in `agents.` config so paths and flag values containing spaces stay intact when spawning ACP agent processes. Thanks @TheArchitectit and @BunsDev. - Agents: inject the current provider/model identity into system prompts, including configured prompt overrides and CLI hook prompt replacements, so agents can answer model-identity questions from the actual runtime selection. - Plugins/CLI: add the optional bundled `oc-path` plugin, providing `openclaw path` for surgical `oc://` access to markdown, JSONC, and JSONL workspace files. diff --git a/docs/help/testing.md b/docs/help/testing.md index 6c43e7a004f..51ef5bd6fe1 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -81,6 +81,14 @@ When debugging real providers/models (requires real creds): `OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE=0 OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=0 OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=0 OPENCLAW_LIVE_CODEX_HARNESS_SUBAGENT_PROBE=1 pnpm test:docker:live-codex-harness`. This exits after the sub-agent probe unless `OPENCLAW_LIVE_CODEX_HARNESS_SUBAGENT_ONLY=0` is set. +- Codex on-demand install smoke: `pnpm test:docker:codex-on-demand` + - Installs the packaged OpenClaw tarball in Docker, runs OpenAI API-key + onboarding, and verifies the Codex plugin plus `@openai/codex` dependency + were downloaded into the managed npm root on demand. +- Live plugin tool dependency smoke: `pnpm test:docker:live-plugin-tool` + - Packs a fixture plugin with a real `slugify` dependency, installs it through + `npm-pack:`, verifies the dependency under the managed npm root, then asks a + live OpenAI model to call the plugin tool and return the hidden slug. - Crestodian rescue command smoke: `pnpm test:live:crestodian-rescue-channel` - Opt-in belt-and-suspenders check for the message-channel rescue command surface. It exercises `/crestodian status`, queues a persistent model diff --git a/package.json b/package.json index 3f93d25b724..fcad827b19f 100644 --- a/package.json +++ b/package.json @@ -1557,6 +1557,7 @@ "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-plugin-tool": "bash scripts/e2e/live-plugin-tool-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", @@ -1568,6 +1569,7 @@ "test:docker:live:all": "OPENCLAW_DOCKER_ALL_LIVE_MODE=only node scripts/test-docker-all.mjs", "test:docker:local:all": "OPENCLAW_DOCKER_ALL_LIVE_MODE=skip node scripts/test-docker-all.mjs", "test:docker:mcp-channels": "bash scripts/e2e/mcp-channels-docker.sh", + "test:docker:codex-on-demand": "bash scripts/e2e/codex-on-demand-docker.sh", "test:docker:npm-onboard-channel-agent": "bash scripts/e2e/npm-onboard-channel-agent-docker.sh", "test:docker:npm-onboard-discord-channel-agent": "OPENCLAW_NPM_ONBOARD_CHANNEL=discord bash scripts/e2e/npm-onboard-channel-agent-docker.sh", "test:docker:npm-onboard-slack-channel-agent": "OPENCLAW_NPM_ONBOARD_CHANNEL=slack bash scripts/e2e/npm-onboard-channel-agent-docker.sh", diff --git a/scripts/check-docker-e2e-boundaries.mjs b/scripts/check-docker-e2e-boundaries.mjs index b20ffec0e22..5e966b4403a 100644 --- a/scripts/check-docker-e2e-boundaries.mjs +++ b/scripts/check-docker-e2e-boundaries.mjs @@ -14,7 +14,7 @@ const packageJson = JSON.parse(readText("package.json")); const packageScripts = new Set(Object.keys(packageJson.scripts ?? {})); // These lanes prove package-installed surfaces against live auth, so they // intentionally need both live credentials and a package-backed image. -const livePackageBackedLanes = new Set(["live-codex-npm-plugin", "openwebui"]); +const livePackageBackedLanes = new Set(["live-codex-npm-plugin", "live-plugin-tool", "openwebui"]); function readText(relativePath) { return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8"); diff --git a/scripts/e2e/codex-on-demand-docker.sh b/scripts/e2e/codex-on-demand-docker.sh new file mode 100755 index 00000000000..8518962d0c4 --- /dev/null +++ b/scripts/e2e/codex-on-demand-docker.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Installs a prepared OpenClaw npm tarball in Docker, runs OpenAI onboarding, +# and verifies the Codex plugin plus @openai/codex dependency are downloaded on demand. +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-on-demand-e2e" OPENCLAW_CODEX_ON_DEMAND_E2E_IMAGE)" +DOCKER_TARGET="${OPENCLAW_CODEX_ON_DEMAND_DOCKER_TARGET:-bare}" +HOST_BUILD="${OPENCLAW_CODEX_ON_DEMAND_HOST_BUILD:-1}" +PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" + +docker_e2e_build_or_reuse "$IMAGE_NAME" codex-on-demand "$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-on-demand "$PACKAGE_TGZ")" + return 0 + fi + if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then + echo "OPENCLAW_CODEX_ON_DEMAND_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2 + exit 1 + fi + PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz codex-on-demand)" +} + +prepare_package_tgz + +docker_e2e_package_mount_args "$PACKAGE_TGZ" +run_log="$(docker_e2e_run_log codex-on-demand)" +OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 codex-on-demand empty)" + +echo "Running Codex on-demand Docker E2E..." +if ! docker_e2e_run_with_harness \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ + -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 OPENAI_API_KEY="sk-openclaw-codex-on-demand-e2e" + +dump_debug_logs() { + local status="$1" + echo "Codex on-demand scenario failed with exit code $status" >&2 + openclaw_e2e_dump_logs \ + /tmp/openclaw-install.log \ + /tmp/openclaw-onboard.json \ + /tmp/openclaw-plugins-list.json \ + /tmp/openclaw-codex-inspect.json +} +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 + +openclaw_e2e_assert_dep_absent "@openclaw/codex" "$HOME/.openclaw" "$NPM_CONFIG_PREFIX" +openclaw_e2e_assert_dep_absent "@openai/codex" "$HOME/.openclaw" "$NPM_CONFIG_PREFIX" + +echo "Running non-interactive OpenAI onboarding; Codex should install on demand..." +openclaw onboard --non-interactive --accept-risk \ + --mode local \ + --auth-choice openai-api-key \ + --secret-input-mode ref \ + --skip-daemon \ + --skip-ui \ + --skip-channels \ + --skip-skills \ + --skip-health \ + --json >/tmp/openclaw-onboard.json + +openclaw plugins list --json >/tmp/openclaw-plugins-list.json +openclaw plugins inspect codex --runtime --json >/tmp/openclaw-codex-inspect.json +node scripts/e2e/lib/codex-on-demand/assertions.mjs + +echo "Codex on-demand Docker E2E passed" +EOF + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 +fi + +rm -f "$run_log" +echo "Codex on-demand Docker E2E passed" diff --git a/scripts/e2e/lib/codex-on-demand/assertions.mjs b/scripts/e2e/lib/codex-on-demand/assertions.mjs new file mode 100644 index 00000000000..16c39f5795e --- /dev/null +++ b/scripts/e2e/lib/codex-on-demand/assertions.mjs @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import path from "node:path"; + +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 installRecords() { + const indexPath = path.join(stateDir(), "plugins", "installs.json"); + const index = fs.existsSync(indexPath) ? readJson(indexPath) : {}; + return index.installRecords || index.records || cfg.plugins?.installs || {}; +} + +function findPackageJson(packageName, roots) { + const packagePath = packageName.startsWith("@") + ? path.join(...packageName.split("/"), "package.json") + : path.join(packageName, "package.json"); + const candidates = roots.map((root) => path.join(root, "node_modules", packagePath)); + return candidates.find((candidate) => fs.existsSync(candidate)); +} + +const cfg = readJson(configPath()); +const inspect = readJson("/tmp/openclaw-codex-inspect.json"); +const records = installRecords(); +const codexRecord = records.codex || inspect.install; +if (!codexRecord) { + throw new Error(`missing codex install record: ${JSON.stringify(records)}`); +} +if (codexRecord.source !== "npm") { + throw new Error(`expected npm codex install record, got ${codexRecord.source}`); +} +if (!String(codexRecord.spec || "").includes("@openclaw/codex")) { + throw new Error(`expected @openclaw/codex install spec, got ${codexRecord.spec}`); +} + +const npmRoot = path.join(stateDir(), "npm"); +const installPath = String(codexRecord.installPath || "").replace(/^~(?=$|\/)/u, process.env.HOME); +if (!installPath) { + throw new Error(`missing codex installPath: ${JSON.stringify(codexRecord)}`); +} +assertPathInside(npmRoot, installPath, "codex install path"); + +const codexPackageJson = path.join(installPath, "package.json"); +if (!fs.existsSync(codexPackageJson)) { + throw new Error(`missing npm-installed @openclaw/codex package: ${codexPackageJson}`); +} +const codexPackage = readJson(codexPackageJson); +if (codexPackage.name !== "@openclaw/codex") { + throw new Error(`unexpected codex package name: ${codexPackage.name}`); +} + +const openAiCodexPackageJson = findPackageJson("@openai/codex", [installPath, npmRoot]); +if (!openAiCodexPackageJson) { + throw new Error("missing @openai/codex dependency under managed npm root"); +} +assertPathInside(npmRoot, openAiCodexPackageJson, "@openai/codex dependency"); + +const list = readJson("/tmp/openclaw-plugins-list.json"); +const plugin = (list.plugins || []).find((entry) => entry.id === "codex"); +if (!plugin || plugin.enabled !== true || plugin.status !== "loaded") { + throw new Error(`codex plugin was not enabled+loaded: ${JSON.stringify(plugin)}`); +} + +if (inspect.plugin?.id !== "codex" || inspect.plugin?.status !== "loaded") { + throw new Error(`unexpected codex inspect state: ${JSON.stringify(inspect.plugin)}`); +} +const hasHarness = + (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 (!hasHarness) { + throw new Error(`codex harness was not registered: ${JSON.stringify(inspect.plugin)}`); +} + +const primaryModel = cfg.agents?.defaults?.model?.primary; +if (primaryModel !== "openai/gpt-5.5") { + throw new Error(`expected OpenAI onboarding model openai/gpt-5.5, got ${primaryModel}`); +} +const providerRuntime = cfg.models?.providers?.openai?.agentRuntime?.id; +if (providerRuntime && providerRuntime !== "codex") { + throw new Error(`unexpected OpenAI provider runtime: ${providerRuntime}`); +} + +const authPath = path.join(stateDir(), "agents", "main", "agent", "auth-profiles.json"); +const authRaw = fs.readFileSync(authPath, "utf8"); +if (!authRaw.includes("OPENAI_API_KEY")) { + throw new Error("auth profile did not persist OPENAI_API_KEY env ref"); +} +if (authRaw.includes("sk-openclaw-codex-on-demand-e2e")) { + throw new Error("auth profile persisted the raw OpenAI test key"); +} diff --git a/scripts/e2e/lib/live-plugin-tool/assertions.mjs b/scripts/e2e/lib/live-plugin-tool/assertions.mjs new file mode 100644 index 00000000000..e803dbbc9f0 --- /dev/null +++ b/scripts/e2e/lib/live-plugin-tool/assertions.mjs @@ -0,0 +1,269 @@ +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 requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`missing ${name}`); + } + return value; +} + +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 writeJson(file, value) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function installRecords() { + const indexPath = path.join(stateDir(), "plugins", "installs.json"); + const index = fs.existsSync(indexPath) ? readJson(indexPath) : {}; + const cfg = fs.existsSync(configPath()) ? readJson(configPath()) : {}; + return index.installRecords || index.records || cfg.plugins?.installs || {}; +} + +function pluginInstallPath() { + const pluginId = requireEnv("PLUGIN_ID"); + const inspect = fs.existsSync("/tmp/openclaw-plugin-inspect.json") + ? readJson("/tmp/openclaw-plugin-inspect.json") + : {}; + const record = installRecords()[pluginId] || inspect.install; + if (!record) { + throw new Error(`missing ${pluginId} install record`); + } + if (record.source !== "npm" || record.artifactKind !== "npm-pack") { + throw new Error(`expected npm-pack install record: ${JSON.stringify(record)}`); + } + return String(record.installPath || "").replace(/^~(?=$|\/)/u, process.env.HOME); +} + +function writeFixture() { + const dir = process.argv[3]; + if (!dir) { + throw new Error("write-fixture requires output dir"); + } + const pluginId = requireEnv("PLUGIN_ID"); + const pluginName = requireEnv("PLUGIN_NAME"); + const version = requireEnv("PLUGIN_VERSION"); + const toolName = requireEnv("TOOL_NAME"); + const seed = requireEnv("SEED"); + writeJson(path.join(dir, "package.json"), { + name: pluginName, + version, + dependencies: { slugify: "^1.6.6" }, + openclaw: { extensions: ["./index.js"] }, + }); + writeJson(path.join(dir, "openclaw.plugin.json"), { + id: pluginId, + name: "E2E Slug Tool", + description: "Docker E2E plugin tool fixture", + activation: { onStartup: true }, + contracts: { tools: [toolName] }, + configSchema: { type: "object", additionalProperties: false }, + }); + fs.writeFileSync( + path.join(dir, "index.js"), + `const slugify = require("slugify");\n` + + `const value = slugify(${JSON.stringify(seed)}, { lower: true, strict: true });\n` + + `module.exports = {\n` + + ` id: ${JSON.stringify(pluginId)},\n` + + ` name: "E2E Slug Tool",\n` + + ` register(api) {\n` + + ` api.registerTool({\n` + + ` name: ${JSON.stringify(toolName)},\n` + + ` description: "Return the hidden Docker E2E slug generated by the plugin dependency.",\n` + + ` parameters: { type: "object", properties: {}, additionalProperties: false },\n` + + ` async execute() {\n` + + ` return { content: [{ type: "text", text: value }] };\n` + + ` },\n` + + ` });\n` + + ` },\n` + + `};\n`, + ); +} + +function configure() { + const modelRef = requireEnv("MODEL_REF"); + const pluginId = requireEnv("PLUGIN_ID"); + const toolName = requireEnv("TOOL_NAME"); + const cfgPath = configPath(); + const cfg = fs.existsSync(cfgPath) ? readJson(cfgPath) : {}; + const [providerId, modelId] = modelRef.split("/"); + if (providerId !== "openai" || !modelId) { + throw new Error(`live plugin tool E2E expects an openai/* model, got ${modelRef}`); + } + cfg.plugins = { + ...cfg.plugins, + enabled: true, + allow: Array.from(new Set([...(cfg.plugins?.allow || []), "openai", pluginId])).toSorted( + (left, right) => left.localeCompare(right), + ), + entries: { + ...cfg.plugins?.entries, + openai: { ...cfg.plugins?.entries?.openai, enabled: true }, + [pluginId]: { ...cfg.plugins?.entries?.[pluginId], enabled: true }, + }, + }; + cfg.tools = { + ...cfg.tools, + allow: [toolName], + }; + cfg.models = { + ...cfg.models, + mode: "merge", + providers: { + ...cfg.models?.providers, + openai: { + ...cfg.models?.providers?.openai, + api: "openai-responses", + baseUrl: (process.env.OPENAI_BASE_URL || "https://api.openai.com/v1").trim(), + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + agentRuntime: { id: "pi" }, + timeoutSeconds: 300, + models: [ + { + id: modelId, + name: modelId, + api: "openai-responses", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + contextTokens: 96000, + maxTokens: 512, + }, + ], + }, + }, + }; + cfg.agents = { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + model: { primary: modelRef, fallbacks: [] }, + models: { + ...cfg.agents?.defaults?.models, + [modelRef]: { + ...cfg.agents?.defaults?.models?.[modelRef], + agentRuntime: { id: "pi" }, + params: { transport: "sse", openaiWsWarmup: false }, + }, + }, + workspace: path.join(stateDir(), "workspace"), + skipBootstrap: true, + timeoutSeconds: 420, + }, + }; + writeJson(cfgPath, cfg); +} + +function findDependencyPackageJson(packageName) { + const installPath = pluginInstallPath(); + const npmRoot = path.join(stateDir(), "npm"); + return [ + path.join(installPath, "node_modules", packageName, "package.json"), + path.join(npmRoot, "node_modules", packageName, "package.json"), + ].find((candidate) => fs.existsSync(candidate)); +} + +function assertInstalled() { + const pluginId = requireEnv("PLUGIN_ID"); + const pluginName = requireEnv("PLUGIN_NAME"); + const toolName = requireEnv("TOOL_NAME"); + const npmRoot = path.join(stateDir(), "npm"); + const installPath = pluginInstallPath(); + assertPathInside(npmRoot, installPath, "fixture plugin install path"); + const packageJson = path.join(installPath, "package.json"); + if (!fs.existsSync(packageJson)) { + throw new Error(`missing fixture plugin package.json: ${packageJson}`); + } + const pkg = readJson(packageJson); + if (pkg.name !== pluginName) { + throw new Error(`unexpected fixture package name: ${pkg.name}`); + } + const slugifyPackageJson = findDependencyPackageJson("slugify"); + if (!slugifyPackageJson) { + throw new Error("missing slugify dependency installed by npm-pack plugin install"); + } + assertPathInside(npmRoot, slugifyPackageJson, "slugify dependency"); + + const list = readJson("/tmp/openclaw-plugins-list.json"); + const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); + if (!plugin || plugin.enabled !== true || plugin.status !== "loaded") { + throw new Error(`fixture plugin was not enabled+loaded: ${JSON.stringify(plugin)}`); + } + const inspect = readJson("/tmp/openclaw-plugin-inspect.json"); + const toolNames = Array.isArray(inspect.tools) + ? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : [])) + : []; + if (!toolNames.includes(toolName)) { + throw new Error(`fixture tool was not registered: ${JSON.stringify(inspect.tools)}`); + } +} + +function assertAgentTurn() { + const expected = requireEnv("EXPECTED_SLUG"); + const toolName = requireEnv("TOOL_NAME"); + const stdout = fs.readFileSync("/tmp/openclaw-agent.json", "utf8"); + const stderr = fs.existsSync("/tmp/openclaw-agent.err") + ? fs.readFileSync("/tmp/openclaw-agent.err", "utf8") + : ""; + const response = JSON.parse(stdout); + const text = (response.payloads || []).map((payload) => payload?.text || "").join("\n"); + if (!text.includes(expected)) { + throw new Error( + `live agent reply did not contain tool slug ${expected}:\nstdout=${stdout}\nstderr=${stderr}`, + ); + } + const sessionsDir = path.join(stateDir(), "agents", "main", "sessions"); + const sessionFiles = fs + .readdirSync(sessionsDir, { recursive: true }) + .map((entry) => path.join(sessionsDir, String(entry))) + .filter((entry) => entry.endsWith(".jsonl") && fs.existsSync(entry)); + const transcript = sessionFiles.map((file) => fs.readFileSync(file, "utf8")).join("\n"); + if (!transcript.includes(toolName) || !transcript.includes(expected)) { + throw new Error( + `session transcript did not show ${toolName} returning ${expected}; checked ${sessionFiles.join(", ")}`, + ); + } +} + +const commands = { + "write-fixture": writeFixture, + configure, + "assert-installed": assertInstalled, + "assert-agent-turn": assertAgentTurn, +}; + +const fn = commands[command]; +if (!fn) { + throw new Error(`unknown live plugin tool assertion command: ${command}`); +} +fn(); diff --git a/scripts/e2e/live-plugin-tool-docker.sh b/scripts/e2e/live-plugin-tool-docker.sh new file mode 100755 index 00000000000..9312b851bf6 --- /dev/null +++ b/scripts/e2e/live-plugin-tool-docker.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# Installs a packed plugin with a real npm dependency, exposes its tool to a +# live OpenAI agent turn, and verifies the model received the dependency-made string. +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-live-plugin-tool-e2e" OPENCLAW_LIVE_PLUGIN_TOOL_E2E_IMAGE)" +DOCKER_TARGET="${OPENCLAW_LIVE_PLUGIN_TOOL_DOCKER_TARGET:-bare}" +HOST_BUILD="${OPENCLAW_LIVE_PLUGIN_TOOL_HOST_BUILD:-1}" +PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" +PROFILE_FILE="${OPENCLAW_LIVE_PLUGIN_TOOL_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFILE_FILE:-$HOME/.openclaw-testbox-live.profile}}" +if [ ! -f "$PROFILE_FILE" ] && [ -f "$HOME/.profile" ]; then + PROFILE_FILE="$HOME/.profile" +fi + +docker_e2e_build_or_reuse "$IMAGE_NAME" live-plugin-tool "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET" + +prepare_package_tgz() { + if [ -n "$PACKAGE_TGZ" ]; then + PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz live-plugin-tool "$PACKAGE_TGZ")" + return 0 + fi + if [ "$HOST_BUILD" = "0" ] && [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ]; then + echo "OPENCLAW_LIVE_PLUGIN_TOOL_HOST_BUILD=0 requires OPENCLAW_CURRENT_PACKAGE_TGZ" >&2 + exit 1 + fi + PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz live-plugin-tool)" +} + +prepare_package_tgz + +PROFILE_MOUNT=() +PROFILE_STATUS="none" +if [ -f "$PROFILE_FILE" ] && [ -r "$PROFILE_FILE" ]; then + set -a + # shellcheck disable=SC1090 + source "$PROFILE_FILE" + set +a + 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 live-plugin-tool)" +OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 live-plugin-tool empty)" + +echo "Running live plugin tool Docker E2E..." +echo "Profile file: $PROFILE_STATUS" +if ! docker_e2e_run_with_harness \ + -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ + -e OPENAI_API_KEY \ + -e OPENAI_BASE_URL \ + -e OPENCLAW_LIVE_PLUGIN_TOOL_MODEL="${OPENCLAW_LIVE_PLUGIN_TOOL_MODEL:-openai/gpt-5.4-mini}" \ + -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 + +MODEL_REF="${OPENCLAW_LIVE_PLUGIN_TOOL_MODEL:?missing OPENCLAW_LIVE_PLUGIN_TOOL_MODEL}" +PLUGIN_ID="e2e-slug-tool" +PLUGIN_NAME="@openclaw/e2e-slug-tool" +PLUGIN_VERSION="0.0.0-e2e.1" +TOOL_NAME="e2e_slug_probe" +SEED="OpenClaw E2E Plugin Tool $(date +%s)-$RANDOM" +EXPECTED_SLUG="$(printf '%s' "$SEED" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')" +export MODEL_REF PLUGIN_ID PLUGIN_NAME PLUGIN_VERSION TOOL_NAME SEED EXPECTED_SLUG + +dump_debug_logs() { + local status="$1" + echo "Live plugin tool scenario failed with exit code $status" >&2 + openclaw_e2e_dump_logs \ + /tmp/openclaw-install.log \ + /tmp/openclaw-plugin-install.log \ + /tmp/openclaw-plugin-enable.log \ + /tmp/openclaw-plugins-list.json \ + /tmp/openclaw-plugin-inspect.json \ + /tmp/openclaw-agent.json \ + /tmp/openclaw-agent.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 + +fixture_dir="$(mktemp -d /tmp/openclaw-live-plugin-tool.XXXXXX)" +plugin_dir="$fixture_dir/package" +mkdir -p "$plugin_dir" +node scripts/e2e/lib/live-plugin-tool/assertions.mjs write-fixture "$plugin_dir" +plugin_pack="$(cd "$plugin_dir" && npm pack --pack-destination "$fixture_dir" --silent)" +plugin_tgz="$fixture_dir/$plugin_pack" + +echo "Installing fixture plugin from npm-pack: $plugin_tgz" +openclaw plugins install "npm-pack:$plugin_tgz" --force >/tmp/openclaw-plugin-install.log 2>&1 +node scripts/e2e/lib/live-plugin-tool/assertions.mjs configure +openclaw plugins enable "$PLUGIN_ID" >/tmp/openclaw-plugin-enable.log 2>&1 +openclaw plugins list --json >/tmp/openclaw-plugins-list.json +openclaw plugins inspect "$PLUGIN_ID" --runtime --json >/tmp/openclaw-plugin-inspect.json +node scripts/e2e/lib/live-plugin-tool/assertions.mjs assert-installed + +echo "Running live OpenAI agent turn that must call $TOOL_NAME..." +openclaw agent --local \ + --agent main \ + --session-id live-plugin-tool \ + --model "$MODEL_REF" \ + --message "Call the tool named ${TOOL_NAME}. Reply with only the exact text returned by that tool. Do not compute, transform, or explain it." \ + --thinking off \ + --timeout 420 \ + --json >/tmp/openclaw-agent.json 2>/tmp/openclaw-agent.err + +node scripts/e2e/lib/live-plugin-tool/assertions.mjs assert-agent-turn + +echo "Live plugin tool Docker E2E passed" +EOF + docker_e2e_print_log "$run_log" + rm -f "$run_log" + exit 1 +fi + +rm -f "$run_log" +echo "Live plugin tool Docker E2E passed" diff --git a/scripts/lib/docker-e2e-plan.mjs b/scripts/lib/docker-e2e-plan.mjs index 7d5db1dbf70..4b00ffd9784 100644 --- a/scripts/lib/docker-e2e-plan.mjs +++ b/scripts/lib/docker-e2e-plan.mjs @@ -331,7 +331,8 @@ function laneCredentialRequirements(poolLane) { if ( poolLane.name === "openwebui" || poolLane.name === "openai-web-search-minimal" || - poolLane.name === "live-codex-npm-plugin" + poolLane.name === "live-codex-npm-plugin" || + poolLane.name === "live-plugin-tool" ) { credentials.push("openai"); } diff --git a/scripts/lib/docker-e2e-scenarios.mjs b/scripts/lib/docker-e2e-scenarios.mjs index b3f1d4db704..5a49cf4bc0e 100644 --- a/scripts/lib/docker-e2e-scenarios.mjs +++ b/scripts/lib/docker-e2e-scenarios.mjs @@ -182,6 +182,11 @@ export const mainLanes = [ stateScenario: "empty", weight: 2, }), + npmLane("codex-on-demand", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:codex-on-demand", { + resources: ["service"], + stateScenario: "empty", + weight: 3, + }), npmLane( "npm-onboard-channel-agent", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:npm-onboard-channel-agent", @@ -360,6 +365,15 @@ export const tailLanes = [ weight: 3, }, ), + liveLane("live-plugin-tool", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-plugin-tool", { + cacheKey: "plugin-tool", + e2eImageKind: "bare", + provider: "openai", + resources: ["npm"], + stateScenario: "empty", + timeoutMs: 20 * 60 * 1000, + weight: 3, + }), liveLane( "live-cli-backend-codex", liveDockerScriptCommand( diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 76824a1e48a..0b66108170a 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -593,6 +593,46 @@ describe("scripts/lib/docker-e2e-plan", () => { }); }); + it("plans the Codex on-demand onboarding lane as package-backed npm proof", () => { + const plan = planFor({ selectedLaneNames: ["codex-on-demand"] }); + + expect(plan.lanes).toEqual([ + expect.objectContaining({ + command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:codex-on-demand", + imageKind: "bare", + live: false, + name: "codex-on-demand", + resources: ["docker", "npm", "service"], + stateScenario: "empty", + }), + ]); + expect(plan.needs).toMatchObject({ + bareImage: true, + package: true, + }); + }); + + it("plans the live plugin tool lane as package-backed OpenAI proof", () => { + const plan = planFor({ selectedLaneNames: ["live-plugin-tool"] }); + + expect(plan.credentials).toEqual(["openai"]); + expect(plan.lanes).toEqual([ + expect.objectContaining({ + command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-plugin-tool", + imageKind: "bare", + live: true, + name: "live-plugin-tool", + resources: ["docker", "live", "live:openai", "npm"], + stateScenario: "empty", + }), + ]); + expect(plan.needs).toMatchObject({ + bareImage: true, + liveImage: true, + package: true, + }); + }); + it("plans Open WebUI as a live-auth functional image lane", () => { const plan = planFor({ includeOpenWebUI: true,