diff --git a/scripts/e2e/browser-cdp-snapshot-docker.sh b/scripts/e2e/browser-cdp-snapshot-docker.sh index 2a8af4b6aa8..ec1acfc21ea 100755 --- a/scripts/e2e/browser-cdp-snapshot-docker.sh +++ b/scripts/e2e/browser-cdp-snapshot-docker.sh @@ -41,11 +41,12 @@ EOF echo "Building Docker image: $IMAGE_NAME" docker_build_run browser-cdp-snapshot-build -t "$IMAGE_NAME" -f "$build_dir/Dockerfile" "$build_dir" fi -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 browser-cdp-snapshot empty)" echo "Starting browser CDP snapshot container..." +docker_e2e_harness_mount_args docker_cmd docker run -d \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ --name "$CONTAINER_NAME" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_GATEWAY_TOKEN="$TOKEN" \ @@ -56,7 +57,6 @@ docker_cmd docker run -d \ -e OPENCLAW_SKIP_CRON=1 \ -e OPENCLAW_SKIP_CANVAS_HOST=1 \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail source scripts/lib/openclaw-e2e-instance.sh diff --git a/scripts/e2e/bundled-channel-runtime-deps-docker.sh b/scripts/e2e/bundled-channel-runtime-deps-docker.sh index a78bffac82e..95912768ea4 100644 --- a/scripts/e2e/bundled-channel-runtime-deps-docker.sh +++ b/scripts/e2e/bundled-channel-runtime-deps-docker.sh @@ -48,5 +48,4 @@ prepare_package_tgz docker_e2e_package_mount_args "$PACKAGE_TGZ" docker_e2e_harness_mount_args - run_bundled_channel_runtime_dep_scenarios diff --git a/scripts/e2e/config-reload-source-docker.sh b/scripts/e2e/config-reload-source-docker.sh index 99569377e40..96f1dd4d785 100755 --- a/scripts/e2e/config-reload-source-docker.sh +++ b/scripts/e2e/config-reload-source-docker.sh @@ -16,11 +16,10 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" config-reload "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 config-reload empty)" echo "Starting gateway container..." -docker run -d \ +docker_e2e_run_detached_with_harness \ --name "$CONTAINER_NAME" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e GATEWAY_AUTH_TOKEN_REF="$TOKEN" \ @@ -30,7 +29,6 @@ docker run -d \ -e OPENCLAW_SKIP_CRON=1 \ -e OPENCLAW_SKIP_CANVAS_HOST=1 \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail source scripts/lib/openclaw-e2e-instance.sh diff --git a/scripts/e2e/cron-mcp-cleanup-docker.sh b/scripts/e2e/cron-mcp-cleanup-docker.sh index 5dc76c0306b..4b7e732cf0f 100644 --- a/scripts/e2e/cron-mcp-cleanup-docker.sh +++ b/scripts/e2e/cron-mcp-cleanup-docker.sh @@ -18,13 +18,12 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" cron-mcp-cleanup -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 cron-mcp-cleanup empty)" echo "Running in-container cron/subagent MCP cleanup smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_TEST_FAST=1" \ -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ @@ -37,7 +36,6 @@ docker run --rm \ -e "GW_URL=ws://127.0.0.1:$PORT" \ -e "GW_TOKEN=$TOKEN" \ -e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail source scripts/lib/openclaw-e2e-instance.sh diff --git a/scripts/e2e/doctor-install-switch-docker.sh b/scripts/e2e/doctor-install-switch-docker.sh index 943ae5d18bf..f2cebdbe0f6 100755 --- a/scripts/e2e/doctor-install-switch-docker.sh +++ b/scripts/e2e/doctor-install-switch-docker.sh @@ -15,368 +15,9 @@ OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)" docker_e2e_build_or_reuse "$IMAGE_NAME" doctor-switch "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "bare" echo "Running doctor install switch E2E..." -docker run --rm \ +docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e "OPENCLAW_TEST_STATE_FUNCTION_B64=$OPENCLAW_TEST_STATE_FUNCTION_B64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ "$IMAGE_NAME" \ - bash -lc ' - set -euo pipefail - eval "$(printf "%s" "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}" | base64 -d)" - - # Keep logs focused; the npm global install step can emit noisy deprecation warnings. - export npm_config_loglevel=error - export npm_config_fund=false - export npm_config_audit=false - export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 - - # Stub systemd/loginctl so doctor + daemon flows work in Docker. - export PATH="/tmp/openclaw-bin:$PATH" - mkdir -p /tmp/openclaw-bin - - cat > /tmp/openclaw-bin/systemctl <<"SYSTEMCTL" -#!/usr/bin/env bash -set -euo pipefail - -args=("$@") -if [[ "${args[0]:-}" == "--user" ]]; then - args=("${args[@]:1}") -fi -cmd="${args[0]:-}" -case "$cmd" in - status) - exit 0 - ;; - is-active) - echo "inactive" >&2 - exit 3 - ;; - is-enabled) - unit="${args[1]:-}" - unit_path="$HOME/.config/systemd/user/${unit}" - if [ -f "$unit_path" ]; then - echo "enabled" - exit 0 - fi - echo "disabled" >&2 - exit 1 - ;; - show) - echo "ActiveState=inactive" - echo "SubState=dead" - echo "MainPID=0" - echo "ExecMainStatus=0" - echo "ExecMainCode=0" - exit 0 - ;; - *) - exit 0 - ;; -esac -SYSTEMCTL - chmod +x /tmp/openclaw-bin/systemctl - - cat > /tmp/openclaw-bin/loginctl <<"LOGINCTL" -#!/usr/bin/env bash -set -euo pipefail - -if [[ "$*" == *"show-user"* ]]; then - echo "Linger=yes" - exit 0 -fi -if [[ "$*" == *"enable-linger"* ]]; then - exit 0 -fi -exit 0 -LOGINCTL - chmod +x /tmp/openclaw-bin/loginctl - - package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" - git_root="/tmp/openclaw-git" - mkdir -p "$git_root" - # The git-style install fixture is unpacked from the tarball so this lane does - # not depend on checkout source files being present in the Docker image. - tar -xzf "$package_tgz" -C "$git_root" --strip-components=1 - ( - cd "$git_root" - npm install --omit=optional --no-fund --no-audit >/tmp/openclaw-git-install.log 2>&1 - git init -q - git config user.email "docker-e2e@openclaw.local" - git config user.name "OpenClaw Docker E2E" - git add -A - git commit -qm "test fixture" - ) - npm_log="/tmp/openclaw-doctor-switch-npm-install.log" - if ! npm install -g --prefix /tmp/npm-prefix "$package_tgz" >"$npm_log" 2>&1; then - cat "$npm_log" - exit 1 - fi - - npm_bin="/tmp/npm-prefix/bin/openclaw" - npm_root="/tmp/npm-prefix/lib/node_modules/openclaw" - if [ -f "$npm_root/dist/index.mjs" ]; then - npm_entry="$npm_root/dist/index.mjs" - else - npm_entry="$npm_root/dist/index.js" - fi - - if [ -f "$git_root/dist/index.mjs" ]; then - git_entry="$git_root/dist/index.mjs" - else - git_entry="$git_root/dist/index.js" - fi - git_cli="$git_root/openclaw.mjs" - - package_version="$(node -p "require(\"$npm_root/package.json\").version")" - is_legacy_package_acceptance_compat() { - node - "$1" <<"NODE" -const version = process.argv[2] || ""; -const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?$/.exec(version); -if (!match) process.exit(1); -const value = [Number(match[1]), Number(match[2]), Number(match[3])]; -const max = [2026, 4, 25]; -for (let i = 0; i < value.length; i += 1) { - if (value[i] < max[i]) process.exit(0); - if (value[i] > max[i]) process.exit(1); -} -process.exit(0); -NODE - } - - assert_entrypoint() { - local unit_path="$1" - local expected="$2" - local exec_line="" - exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true) - if [ -z "$exec_line" ]; then - echo "Missing ExecStart in $unit_path" - exit 1 - fi - exec_line="${exec_line#ExecStart=}" - entrypoint=$(echo "$exec_line" | awk "{print \$2}") - entrypoint="${entrypoint%\"}" - entrypoint="${entrypoint#\"}" - if [ "$entrypoint" != "$expected" ]; then - echo "Expected entrypoint $expected, got $entrypoint" - exit 1 - fi - } - - assert_exec_arg() { - local unit_path="$1" - local index="$2" - local expected="$3" - local exec_line="" - local actual="" - exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true) - if [ -z "$exec_line" ]; then - echo "Missing ExecStart in $unit_path" - exit 1 - fi - exec_line="${exec_line#ExecStart=}" - actual=$(echo "$exec_line" | awk -v field="$index" "{print \$field}") - actual="${actual%\"}" - actual="${actual#\"}" - if [ "$actual" != "$expected" ]; then - echo "Expected ExecStart arg $index to be $expected, got $actual" - cat "$unit_path" - exit 1 - fi - } - - assert_env_value() { - local unit_path="$1" - local key="$2" - local expected="$3" - if ! grep -Fxq "Environment=${key}=${expected}" "$unit_path"; then - echo "Expected Environment=${key}=${expected} in $unit_path" - cat "$unit_path" - exit 1 - fi - } - - assert_no_env_key() { - local unit_path="$1" - local key="$2" - if grep -q "^Environment=${key}=" "$unit_path"; then - echo "Expected no Environment=${key}= line in $unit_path" - cat "$unit_path" - exit 1 - fi - } - - # Each flow: install service with one variant, run doctor from the other, - # and verify ExecStart entrypoint switches accordingly. - run_flow() { - local name="$1" - local install_cmd="$2" - local install_expected="$3" - local doctor_cmd="$4" - local doctor_expected="$5" - local install_log="/tmp/openclaw-doctor-switch-${name}-install.log" - local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log" - local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-300s}" - - echo "== Flow: $name ==" - openclaw_test_state_create "switch-${name}" empty - export USER="testuser" - - if ! timeout "$command_timeout" bash -c "$install_cmd" >"$install_log" 2>&1; then - cat "$install_log" - exit 1 - fi - rm -f "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" - rm -rf "$HOME/.config/fish" "$HOME/.config/powershell" - - unit_path="$HOME/.config/systemd/user/openclaw-gateway.service" - if [ ! -f "$unit_path" ]; then - echo "Missing unit file: $unit_path" - exit 1 - fi - assert_entrypoint "$unit_path" "$install_expected" - - if ! timeout "$command_timeout" bash -c "$doctor_cmd" >"$doctor_log" 2>&1; then - cat "$doctor_log" - exit 1 - fi - - assert_entrypoint "$unit_path" "$doctor_expected" - } - - run_flow \ - "npm-to-git" \ - "$npm_bin daemon install --force" \ - "$npm_entry" \ - "node $git_cli doctor --repair --force --yes" \ - "$git_entry" - - run_flow \ - "git-to-npm" \ - "node $git_cli daemon install --force" \ - "$git_entry" \ - "$npm_bin doctor --repair --force --yes" \ - "$npm_entry" - - run_proxy_env_flow() { - local name="proxy-env-cleanup" - local install_log="/tmp/openclaw-doctor-switch-${name}-install.log" - local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log" - local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-300s}" - - echo "== Flow: $name ==" - openclaw_test_state_create "switch-${name}" empty - export USER="testuser" - - unit_path="$HOME/.config/systemd/user/openclaw-gateway.service" - if ! timeout "$command_timeout" env \ - HTTP_PROXY="http://proxy.local:7890" \ - HTTPS_PROXY="https://proxy.local:7890" \ - NO_PROXY="localhost,127.0.0.1" \ - "$npm_bin" gateway install --force >"$install_log" 2>&1; then - cat "$install_log" - exit 1 - fi - assert_no_env_key "$unit_path" "HTTP_PROXY" - assert_no_env_key "$unit_path" "HTTPS_PROXY" - assert_no_env_key "$unit_path" "NO_PROXY" - - { - printf "%s\n" "Environment=HTTP_PROXY=http://stale-proxy.local:7890" - printf "%s\n" "Environment=HTTPS_PROXY=https://stale-proxy.local:7890" - } >>"$unit_path" - if ! timeout "$command_timeout" node "$git_cli" doctor --repair --yes >"$doctor_log" 2>&1; then - cat "$doctor_log" - exit 1 - fi - assert_no_env_key "$unit_path" "HTTP_PROXY" - assert_no_env_key "$unit_path" "HTTPS_PROXY" - } - - run_proxy_env_flow - - run_wrapper_flow() { - local name="wrapper-persistence" - local install_log="/tmp/openclaw-doctor-switch-${name}-install.log" - local reinstall_log="/tmp/openclaw-doctor-switch-${name}-reinstall.log" - local env_repair_log="/tmp/openclaw-doctor-switch-${name}-env-repair.log" - local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log" - local clear_log="/tmp/openclaw-doctor-switch-${name}-clear.log" - local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-300s}" - - echo "== Flow: $name ==" - openclaw_test_state_create "switch-${name}" empty - export USER="testuser" - mkdir -p "$HOME/.local/bin" - local wrapper="$HOME/.local/bin/openclaw-wrapper" - cat > "$wrapper" <> "$HOME/openclaw-wrapper-argv.log" -exec "$npm_bin" "\$@" -WRAPPER - chmod +x "$wrapper" - - local unit_path="$HOME/.config/systemd/user/openclaw-gateway.service" - - if ! timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" --force >"$install_log" 2>&1; then - cat "$install_log" - exit 1 - fi - assert_exec_arg "$unit_path" 1 "$wrapper" - assert_exec_arg "$unit_path" 2 "gateway" - assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" - - if ! timeout "$command_timeout" "$npm_bin" gateway install --force >"$reinstall_log" 2>&1; then - cat "$reinstall_log" - exit 1 - fi - assert_exec_arg "$unit_path" 1 "$wrapper" - assert_exec_arg "$unit_path" 2 "gateway" - assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" - - sed -i "/^Environment=OPENCLAW_WRAPPER=/d" "$unit_path" - if ! timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then - cat "$env_repair_log" - exit 1 - fi - assert_exec_arg "$unit_path" 1 "$wrapper" - assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" - - sed -i "s#^Environment=OPENCLAW_WRAPPER=.*#Environment=OPENCLAW_WRAPPER=/tmp/stale-openclaw-wrapper#" "$unit_path" - if ! timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then - cat "$env_repair_log" - exit 1 - fi - assert_exec_arg "$unit_path" 1 "$wrapper" - assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" - - if ! timeout "$command_timeout" node "$git_cli" doctor --repair --force --yes >"$doctor_log" 2>&1; then - cat "$doctor_log" - exit 1 - fi - if ! grep -Fq "Gateway service invokes OPENCLAW_WRAPPER:" "$doctor_log"; then - echo "Expected doctor to report active wrapper" - cat "$doctor_log" - exit 1 - fi - assert_exec_arg "$unit_path" 1 "$wrapper" - assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" - - if ! timeout "$command_timeout" env OPENCLAW_WRAPPER= "$npm_bin" gateway install --force >"$clear_log" 2>&1; then - cat "$clear_log" - exit 1 - fi - assert_no_env_key "$unit_path" "OPENCLAW_WRAPPER" - assert_entrypoint "$unit_path" "$npm_entry" - } - - if "$npm_bin" gateway install --help 2>&1 | grep -q -- "--wrapper"; then - run_wrapper_flow - elif is_legacy_package_acceptance_compat "$package_version"; then - # Legacy compatibility: 2026.4.25 and older did not ship gateway install --wrapper. - echo "Skipping wrapper persistence; package gateway install does not support --wrapper." - else - echo "Package $package_version must support gateway install --wrapper." >&2 - exit 1 - fi -' + bash scripts/e2e/lib/doctor-install-switch/scenario.sh diff --git a/scripts/e2e/gateway-network-docker.sh b/scripts/e2e/gateway-network-docker.sh index cdd57de32f4..7ef54eb167f 100644 --- a/scripts/e2e/gateway-network-docker.sh +++ b/scripts/e2e/gateway-network-docker.sh @@ -24,13 +24,14 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" gateway-network "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" -docker_e2e_harness_mount_args echo "Creating Docker network..." docker_cmd docker network create "$NET_NAME" >/dev/null echo "Starting gateway container..." +docker_e2e_harness_mount_args docker_cmd docker run -d \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ --name "$GW_NAME" \ --network "$NET_NAME" \ -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ @@ -38,7 +39,6 @@ docker_cmd docker run -d \ -e "OPENCLAW_SKIP_GMAIL_WATCHER=1" \ -e "OPENCLAW_SKIP_CRON=1" \ -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail; source scripts/lib/openclaw-e2e-instance.sh; entry=\"\$(openclaw_e2e_resolve_entrypoint)\"; node \"\$entry\" config set gateway.controlUi.enabled false >/dev/null; openclaw_e2e_exec_gateway \"\$entry\" $PORT lan /tmp/gateway-net-e2e.log" >/dev/null diff --git a/scripts/e2e/lib/bundled-channel/channel.sh b/scripts/e2e/lib/bundled-channel/channel.sh index 4fcb885574c..ab3e987b45c 100644 --- a/scripts/e2e/lib/bundled-channel/channel.sh +++ b/scripts/e2e/lib/bundled-channel/channel.sh @@ -11,12 +11,12 @@ run_channel_scenario() { echo "Running bundled $channel runtime deps Docker E2E..." run_logged_print "bundled-channel-deps-$channel" timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_CHANNEL_UNDER_TEST="$channel" \ -e OPENCLAW_DEP_SENTINEL="$dep_sentinel" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail diff --git a/scripts/e2e/lib/bundled-channel/disabled-config.sh b/scripts/e2e/lib/bundled-channel/disabled-config.sh index d8587768562..3bc53c60448 100644 --- a/scripts/e2e/lib/bundled-channel/disabled-config.sh +++ b/scripts/e2e/lib/bundled-channel/disabled-config.sh @@ -9,10 +9,10 @@ run_disabled_config_scenario() { echo "Running bundled channel disabled-config runtime deps Docker E2E..." run_logged_print bundled-channel-disabled-config timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail diff --git a/scripts/e2e/lib/bundled-channel/load-failure.sh b/scripts/e2e/lib/bundled-channel/load-failure.sh index 6949dab0658..fe15c2c9ad3 100644 --- a/scripts/e2e/lib/bundled-channel/load-failure.sh +++ b/scripts/e2e/lib/bundled-channel/load-failure.sh @@ -9,10 +9,10 @@ run_load_failure_scenario() { echo "Running bundled channel load-failure isolation Docker E2E..." run_logged_print bundled-channel-load-failure timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail diff --git a/scripts/e2e/lib/bundled-channel/root-owned.sh b/scripts/e2e/lib/bundled-channel/root-owned.sh index d1c1ae3e8fd..b96aac32d14 100644 --- a/scripts/e2e/lib/bundled-channel/root-owned.sh +++ b/scripts/e2e/lib/bundled-channel/root-owned.sh @@ -5,10 +5,11 @@ run_root_owned_global_scenario() { echo "Running bundled channel root-owned global install Docker E2E..." - run_logged_print bundled-channel-root-owned timeout "$DOCKER_RUN_TIMEOUT" docker run --rm --user root \ + run_logged_print bundled-channel-root-owned timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ + --user root \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail diff --git a/scripts/e2e/lib/bundled-channel/setup-entry.sh b/scripts/e2e/lib/bundled-channel/setup-entry.sh index 438cea3ce95..d6461aa8780 100644 --- a/scripts/e2e/lib/bundled-channel/setup-entry.sh +++ b/scripts/e2e/lib/bundled-channel/setup-entry.sh @@ -9,10 +9,10 @@ run_setup_entry_scenario() { echo "Running bundled channel setup-entry runtime deps Docker E2E..." run_logged_print bundled-channel-setup-entry timeout "$DOCKER_RUN_TIMEOUT" docker run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail diff --git a/scripts/e2e/lib/bundled-channel/update.sh b/scripts/e2e/lib/bundled-channel/update.sh index 6e2276f8adb..837ec5dd271 100644 --- a/scripts/e2e/lib/bundled-channel/update.sh +++ b/scripts/e2e/lib/bundled-channel/update.sh @@ -9,12 +9,12 @@ run_update_scenario() { echo "Running bundled channel runtime deps Docker update E2E..." run_logged_print_heartbeat bundled-channel-update 30 timeout "$DOCKER_UPDATE_RUN_TIMEOUT" docker run --rm \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e OPENCLAW_BUNDLED_CHANNEL_UPDATE_BASELINE_VERSION="$UPDATE_BASELINE_VERSION" \ -e "OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS=${OPENCLAW_BUNDLED_CHANNEL_UPDATE_TARGETS:-telegram,discord,slack,feishu,memory-lancedb,acpx}" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$state_script_b64" \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail diff --git a/scripts/e2e/lib/doctor-install-switch/scenario.sh b/scripts/e2e/lib/doctor-install-switch/scenario.sh new file mode 100644 index 00000000000..b98c1e7ba01 --- /dev/null +++ b/scripts/e2e/lib/doctor-install-switch/scenario.sh @@ -0,0 +1,349 @@ +#!/usr/bin/env bash +set -euo pipefail +source scripts/lib/openclaw-e2e-instance.sh +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_FUNCTION_B64:?missing OPENCLAW_TEST_STATE_FUNCTION_B64}" + +# Keep logs focused; the npm global install step can emit noisy deprecation warnings. +export npm_config_loglevel=error +export npm_config_fund=false +export npm_config_audit=false +export OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 + +# Stub systemd/loginctl so doctor + daemon flows work in Docker. +export PATH="/tmp/openclaw-bin:$PATH" +mkdir -p /tmp/openclaw-bin + +cat >/tmp/openclaw-bin/systemctl <<"SYSTEMCTL" +#!/usr/bin/env bash +set -euo pipefail + +args=("$@") +if [[ "${args[0]:-}" == "--user" ]]; then +args=("${args[@]:1}") +fi +cmd="${args[0]:-}" +case "$cmd" in +status) + exit 0 + ;; +is-active) + echo "inactive" >&2 + exit 3 + ;; +is-enabled) + unit="${args[1]:-}" + unit_path="$HOME/.config/systemd/user/${unit}" + if [ -f "$unit_path" ]; then + echo "enabled" + exit 0 + fi + echo "disabled" >&2 + exit 1 + ;; +show) + echo "ActiveState=inactive" + echo "SubState=dead" + echo "MainPID=0" + echo "ExecMainStatus=0" + echo "ExecMainCode=0" + exit 0 + ;; +*) + exit 0 + ;; +esac +SYSTEMCTL +chmod +x /tmp/openclaw-bin/systemctl + +cat >/tmp/openclaw-bin/loginctl <<"LOGINCTL" +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$*" == *"show-user"* ]]; then +echo "Linger=yes" +exit 0 +fi +if [[ "$*" == *"enable-linger"* ]]; then +exit 0 +fi +exit 0 +LOGINCTL +chmod +x /tmp/openclaw-bin/loginctl + +package_tgz="${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" +git_root="/tmp/openclaw-git" +mkdir -p "$git_root" +# The git-style install fixture is unpacked from the tarball so this lane does +# not depend on checkout source files being present in the Docker image. +tar -xzf "$package_tgz" -C "$git_root" --strip-components=1 +( + cd "$git_root" + npm install --omit=optional --no-fund --no-audit >/tmp/openclaw-git-install.log 2>&1 + git init -q + git config user.email "docker-e2e@openclaw.local" + git config user.name "OpenClaw Docker E2E" + git add -A + git commit -qm "test fixture" +) +npm_log="/tmp/openclaw-doctor-switch-npm-install.log" +if ! npm install -g --prefix /tmp/npm-prefix "$package_tgz" >"$npm_log" 2>&1; then + cat "$npm_log" + exit 1 +fi + +npm_bin="/tmp/npm-prefix/bin/openclaw" +npm_root="/tmp/npm-prefix/lib/node_modules/openclaw" +if [ -f "$npm_root/dist/index.mjs" ]; then + npm_entry="$npm_root/dist/index.mjs" +else + npm_entry="$npm_root/dist/index.js" +fi + +if [ -f "$git_root/dist/index.mjs" ]; then + git_entry="$git_root/dist/index.mjs" +else + git_entry="$git_root/dist/index.js" +fi +git_cli="$git_root/openclaw.mjs" + +package_version="$(node -p "require(\"$npm_root/package.json\").version")" +is_legacy_package_acceptance_compat() { + [ "$(node scripts/e2e/lib/package-compat.mjs "$1")" = "1" ] +} + +assert_entrypoint() { + local unit_path="$1" + local expected="$2" + local exec_line="" + exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true) + if [ -z "$exec_line" ]; then + echo "Missing ExecStart in $unit_path" + exit 1 + fi + exec_line="${exec_line#ExecStart=}" + entrypoint=$(echo "$exec_line" | awk "{print \$2}") + entrypoint="${entrypoint%\"}" + entrypoint="${entrypoint#\"}" + if [ "$entrypoint" != "$expected" ]; then + echo "Expected entrypoint $expected, got $entrypoint" + exit 1 + fi +} + +assert_exec_arg() { + local unit_path="$1" + local index="$2" + local expected="$3" + local exec_line="" + local actual="" + exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true) + if [ -z "$exec_line" ]; then + echo "Missing ExecStart in $unit_path" + exit 1 + fi + exec_line="${exec_line#ExecStart=}" + actual=$(echo "$exec_line" | awk -v field="$index" "{print \$field}") + actual="${actual%\"}" + actual="${actual#\"}" + if [ "$actual" != "$expected" ]; then + echo "Expected ExecStart arg $index to be $expected, got $actual" + cat "$unit_path" + exit 1 + fi +} + +assert_env_value() { + local unit_path="$1" + local key="$2" + local expected="$3" + if ! grep -Fxq "Environment=${key}=${expected}" "$unit_path"; then + echo "Expected Environment=${key}=${expected} in $unit_path" + cat "$unit_path" + exit 1 + fi +} + +assert_no_env_key() { + local unit_path="$1" + local key="$2" + if grep -q "^Environment=${key}=" "$unit_path"; then + echo "Expected no Environment=${key}= line in $unit_path" + cat "$unit_path" + exit 1 + fi +} + +# Each flow: install service with one variant, run doctor from the other, +# and verify ExecStart entrypoint switches accordingly. +run_flow() { + local name="$1" + local install_cmd="$2" + local install_expected="$3" + local doctor_cmd="$4" + local doctor_expected="$5" + local install_log="/tmp/openclaw-doctor-switch-${name}-install.log" + local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log" + local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-300s}" + + echo "== Flow: $name ==" + openclaw_test_state_create "switch-${name}" empty + export USER="testuser" + + if ! timeout "$command_timeout" bash -c "$install_cmd" >"$install_log" 2>&1; then + cat "$install_log" + exit 1 + fi + rm -f "$HOME/.zshrc" "$HOME/.bashrc" "$HOME/.bash_profile" + rm -rf "$HOME/.config/fish" "$HOME/.config/powershell" + + unit_path="$HOME/.config/systemd/user/openclaw-gateway.service" + if [ ! -f "$unit_path" ]; then + echo "Missing unit file: $unit_path" + exit 1 + fi + assert_entrypoint "$unit_path" "$install_expected" + + if ! timeout "$command_timeout" bash -c "$doctor_cmd" >"$doctor_log" 2>&1; then + cat "$doctor_log" + exit 1 + fi + + assert_entrypoint "$unit_path" "$doctor_expected" +} + +run_flow \ + "npm-to-git" \ + "$npm_bin daemon install --force" \ + "$npm_entry" \ + "node $git_cli doctor --repair --force --yes" \ + "$git_entry" + +run_flow \ + "git-to-npm" \ + "node $git_cli daemon install --force" \ + "$git_entry" \ + "$npm_bin doctor --repair --force --yes" \ + "$npm_entry" + +run_proxy_env_flow() { + local name="proxy-env-cleanup" + local install_log="/tmp/openclaw-doctor-switch-${name}-install.log" + local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log" + local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-300s}" + + echo "== Flow: $name ==" + openclaw_test_state_create "switch-${name}" empty + export USER="testuser" + + unit_path="$HOME/.config/systemd/user/openclaw-gateway.service" + if ! timeout "$command_timeout" env \ + HTTP_PROXY="http://proxy.local:7890" \ + HTTPS_PROXY="https://proxy.local:7890" \ + NO_PROXY="localhost,127.0.0.1" \ + "$npm_bin" gateway install --force >"$install_log" 2>&1; then + cat "$install_log" + exit 1 + fi + assert_no_env_key "$unit_path" "HTTP_PROXY" + assert_no_env_key "$unit_path" "HTTPS_PROXY" + assert_no_env_key "$unit_path" "NO_PROXY" + + { + printf "%s\n" "Environment=HTTP_PROXY=http://stale-proxy.local:7890" + printf "%s\n" "Environment=HTTPS_PROXY=https://stale-proxy.local:7890" + } >>"$unit_path" + if ! timeout "$command_timeout" node "$git_cli" doctor --repair --yes >"$doctor_log" 2>&1; then + cat "$doctor_log" + exit 1 + fi + assert_no_env_key "$unit_path" "HTTP_PROXY" + assert_no_env_key "$unit_path" "HTTPS_PROXY" +} + +run_proxy_env_flow + +run_wrapper_flow() { + local name="wrapper-persistence" + local install_log="/tmp/openclaw-doctor-switch-${name}-install.log" + local reinstall_log="/tmp/openclaw-doctor-switch-${name}-reinstall.log" + local env_repair_log="/tmp/openclaw-doctor-switch-${name}-env-repair.log" + local doctor_log="/tmp/openclaw-doctor-switch-${name}-doctor.log" + local clear_log="/tmp/openclaw-doctor-switch-${name}-clear.log" + local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-300s}" + + echo "== Flow: $name ==" + openclaw_test_state_create "switch-${name}" empty + export USER="testuser" + mkdir -p "$HOME/.local/bin" + local wrapper="$HOME/.local/bin/openclaw-wrapper" + cat >"$wrapper" <> "$HOME/openclaw-wrapper-argv.log" +exec "$npm_bin" "\$@" +WRAPPER + chmod +x "$wrapper" + + local unit_path="$HOME/.config/systemd/user/openclaw-gateway.service" + + if ! timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" --force >"$install_log" 2>&1; then + cat "$install_log" + exit 1 + fi + assert_exec_arg "$unit_path" 1 "$wrapper" + assert_exec_arg "$unit_path" 2 "gateway" + assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" + + if ! timeout "$command_timeout" "$npm_bin" gateway install --force >"$reinstall_log" 2>&1; then + cat "$reinstall_log" + exit 1 + fi + assert_exec_arg "$unit_path" 1 "$wrapper" + assert_exec_arg "$unit_path" 2 "gateway" + assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" + + sed -i "/^Environment=OPENCLAW_WRAPPER=/d" "$unit_path" + if ! timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then + cat "$env_repair_log" + exit 1 + fi + assert_exec_arg "$unit_path" 1 "$wrapper" + assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" + + sed -i "s#^Environment=OPENCLAW_WRAPPER=.*#Environment=OPENCLAW_WRAPPER=/tmp/stale-openclaw-wrapper#" "$unit_path" + if ! timeout "$command_timeout" "$npm_bin" gateway install --wrapper "$wrapper" >"$env_repair_log" 2>&1; then + cat "$env_repair_log" + exit 1 + fi + assert_exec_arg "$unit_path" 1 "$wrapper" + assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" + + if ! timeout "$command_timeout" node "$git_cli" doctor --repair --force --yes >"$doctor_log" 2>&1; then + cat "$doctor_log" + exit 1 + fi + if ! grep -Fq "Gateway service invokes OPENCLAW_WRAPPER:" "$doctor_log"; then + echo "Expected doctor to report active wrapper" + cat "$doctor_log" + exit 1 + fi + assert_exec_arg "$unit_path" 1 "$wrapper" + assert_env_value "$unit_path" "OPENCLAW_WRAPPER" "$wrapper" + + if ! timeout "$command_timeout" env OPENCLAW_WRAPPER= "$npm_bin" gateway install --force >"$clear_log" 2>&1; then + cat "$clear_log" + exit 1 + fi + assert_no_env_key "$unit_path" "OPENCLAW_WRAPPER" + assert_entrypoint "$unit_path" "$npm_entry" +} + +if "$npm_bin" gateway install --help 2>&1 | grep -q -- "--wrapper"; then + run_wrapper_flow +elif is_legacy_package_acceptance_compat "$package_version"; then + # Legacy compatibility: 2026.4.25 and older did not ship gateway install --wrapper. + echo "Skipping wrapper persistence; package gateway install does not support --wrapper." +else + echo "Package $package_version must support gateway install --wrapper." >&2 + exit 1 +fi diff --git a/scripts/e2e/lib/openai-web-search-minimal/client.mjs b/scripts/e2e/lib/openai-web-search-minimal/client.mjs new file mode 100644 index 00000000000..8a6f180ff8d --- /dev/null +++ b/scripts/e2e/lib/openai-web-search-minimal/client.mjs @@ -0,0 +1,72 @@ +import { readdirSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +async function loadCallGateway() { + const candidates = readdirSync("/app/dist") + .filter((name) => /^call(?:\.runtime)?-[A-Za-z0-9_-]+\.js$/.test(name)) + .toSorted(); + for (const name of candidates) { + const mod = await import(pathToFileURL(`/app/dist/${name}`).href); + if (typeof mod.callGateway === "function") { + return mod.callGateway; + } + } + throw new Error(`unable to find callGateway export in /app/dist (${candidates.join(", ")})`); +} + +const callGateway = await loadCallGateway(); + +const port = process.env.PORT; +const token = process.env.OPENCLAW_GATEWAY_TOKEN; +const mode = process.argv[2]; +const sessionKey = `agent:main:openai-web-search-minimal:${mode}`; +const message = + mode === "reject" ? "FORCE_SCHEMA_REJECT" : "Return exactly OPENCLAW_SCHEMA_E2E_OK."; +const id = mode === "reject" ? "schema-reject" : "schema-success"; + +if (!port || !token) { + throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN"); +} + +async function gatewayAgent(params) { + try { + return { + ok: true, + value: await callGateway({ + url: `ws://127.0.0.1:${port}`, + token, + method: "agent", + params, + expectFinal: true, + timeoutMs: 240_000, + clientName: "gateway-client", + mode: "backend", + scopes: ["operator.write"], + deviceIdentity: null, + }), + }; + } catch (error) { + const combined = String(error); + return { ok: false, error: new Error(combined) }; + } +} + +const result = await gatewayAgent({ + sessionKey, + message, + thinking: "minimal", + deliver: false, + timeout: 180, + idempotencyKey: id, +}); + +if (mode === "reject") { + console.error(result.ok ? JSON.stringify(result.value) : String(result.error)); + process.exit(0); +} +if (!result.ok) { + throw result.error; +} +if (result.value?.status !== "ok") { + throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`); +} diff --git a/scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs b/scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs new file mode 100644 index 00000000000..c5afa1538eb --- /dev/null +++ b/scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs @@ -0,0 +1,158 @@ +import fs from "node:fs"; +import http from "node:http"; + +const port = Number(process.env.MOCK_PORT); +const requestLog = process.env.MOCK_REQUEST_LOG; +const successMarker = process.env.SUCCESS_MARKER; +const rawSchemaError = process.env.RAW_SCHEMA_ERROR; + +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +function writeJson(res, status, body) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function writeOpenAiReject(res) { + writeJson(res, 400, { + error: { + message: rawSchemaError.replace(/^400\s+/, ""), + type: "invalid_request_error", + code: "invalid_request_error", + }, + }); +} + +function hasWebSearchTool(tools) { + return ( + Array.isArray(tools) && + tools.some((tool) => { + if (!tool || typeof tool !== "object") { + return false; + } + if (tool.type === "web_search") { + return true; + } + if (tool.type === "function" && tool.name === "web_search") { + return true; + } + if (tool.type === "function" && tool.function?.name === "web_search") { + return true; + } + return false; + }) + ); +} + +function bodyContainsForceReject(body) { + return JSON.stringify(body).includes("FORCE_SCHEMA_REJECT"); +} + +function responseEvents(text) { + return [ + { + type: "response.output_item.added", + item: { + type: "message", + id: "msg_schema_e2e_1", + role: "assistant", + content: [], + status: "in_progress", + }, + }, + { + type: "response.output_item.done", + item: { + type: "message", + id: "msg_schema_e2e_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }, + }, + { + type: "response.completed", + response: { + id: "resp_schema_e2e_1", + status: "completed", + usage: { + input_tokens: 11, + output_tokens: 7, + total_tokens: 18, + input_tokens_details: { cached_tokens: 0 }, + }, + }, + }, + ]; +} + +function writeSse(res, events) { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-store", + connection: "keep-alive", + }); + for (const event of events) { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + res.write("data: [DONE]\n\n"); + res.end(); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (req.method === "GET" && url.pathname === "/health") { + writeJson(res, 200, { ok: true }); + return; + } + if (req.method === "GET" && url.pathname === "/v1/models") { + writeJson(res, 200, { + object: "list", + data: [{ id: "gpt-5", object: "model", owned_by: "openclaw-e2e" }], + }); + return; + } + + const bodyText = await readBody(req); + let body = {}; + try { + body = bodyText ? JSON.parse(bodyText) : {}; + } catch { + body = {}; + } + fs.appendFileSync( + requestLog, + `${JSON.stringify({ method: req.method, path: url.pathname, body })}\n`, + ); + + if (req.method === "POST" && url.pathname === "/v1/responses") { + if (bodyContainsForceReject(body)) { + writeOpenAiReject(res); + return; + } + if (body?.reasoning?.effort === "minimal" && hasWebSearchTool(body.tools)) { + writeOpenAiReject(res); + return; + } + writeSse(res, responseEvents(successMarker)); + return; + } + + writeJson(res, 404, { + error: { message: `unhandled mock route: ${req.method} ${url.pathname}` }, + }); +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`mock-openai listening on ${port}`); +}); diff --git a/scripts/e2e/lib/openai-web-search-minimal/scenario.sh b/scripts/e2e/lib/openai-web-search-minimal/scenario.sh new file mode 100644 index 00000000000..54bfe116165 --- /dev/null +++ b/scripts/e2e/lib/openai-web-search-minimal/scenario.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +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 OPENCLAW_SKIP_CHANNELS=1 +export OPENCLAW_SKIP_GMAIL_WATCHER=1 +export OPENCLAW_SKIP_CRON=1 +export OPENCLAW_SKIP_CANVAS_HOST=1 +export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1 +export OPENCLAW_SKIP_ACPX_RUNTIME=1 +export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1 + +PORT="${PORT:?missing PORT}" +MOCK_PORT="${MOCK_PORT:?missing MOCK_PORT}" +TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}" +SUCCESS_MARKER="OPENCLAW_SCHEMA_E2E_OK" +RAW_SCHEMA_ERROR="400 The following tools cannot be used with reasoning.effort 'minimal': web_search." +MOCK_REQUEST_LOG="/tmp/openclaw-openai-web-search-minimal-requests.jsonl" +GATEWAY_LOG="/tmp/openclaw-openai-web-search-minimal-gateway.log" +mock_pid="" +gateway_pid="" + +cleanup() { + if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then + kill "$gateway_pid" 2>/dev/null || true + wait "$gateway_pid" 2>/dev/null || true + fi + if [ -n "${mock_pid:-}" ] && kill -0 "$mock_pid" 2>/dev/null; then + kill "$mock_pid" 2>/dev/null || true + wait "$mock_pid" 2>/dev/null || true + fi +} +trap cleanup EXIT + +dump_debug_logs() { + local status="$1" + echo "OpenAI web_search minimal Docker E2E failed with exit code $status" >&2 + for file in \ + "$GATEWAY_LOG" \ + /tmp/openclaw-openai-web-search-minimal-mock.log \ + /tmp/openclaw-openai-web-search-minimal-client-success.log \ + /tmp/openclaw-openai-web-search-minimal-client-reject.log \ + "$MOCK_REQUEST_LOG" \ + "$OPENCLAW_STATE_DIR/openclaw.json"; do + if [ -f "$file" ]; then + echo "--- $file ---" >&2 + sed -n '1,260p' "$file" >&2 || true + fi + done +} +trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR + +entry="$(openclaw_e2e_resolve_entrypoint)" +mkdir -p "$OPENCLAW_STATE_DIR" + +node --input-type=module <<'NODE' +import { patchOpenAINativeWebSearchPayload } from "./dist/extensions/openai/native-web-search.js"; + +const injectedPayload = { + reasoning: { effort: "minimal", summary: "auto" }, +}; +const injectedResult = patchOpenAINativeWebSearchPayload(injectedPayload); +if (injectedResult !== "injected") { + throw new Error(`expected native web_search injection, got ${injectedResult}`); +} +if (injectedPayload.reasoning.effort !== "low") { + throw new Error( + `expected injected native web_search to raise minimal reasoning to low, got ${JSON.stringify(injectedPayload.reasoning)}`, + ); +} +if (!injectedPayload.tools?.some((tool) => tool?.type === "web_search")) { + throw new Error(`native web_search was not injected: ${JSON.stringify(injectedPayload)}`); +} + +const existingNativePayload = { + tools: [{ type: "web_search" }], + reasoning: { effort: "minimal" }, +}; +const existingResult = patchOpenAINativeWebSearchPayload(existingNativePayload); +if (existingResult !== "native_tool_already_present") { + throw new Error(`expected existing native web_search, got ${existingResult}`); +} +if (existingNativePayload.reasoning.effort !== "low") { + throw new Error( + `expected existing native web_search to raise minimal reasoning to low, got ${JSON.stringify(existingNativePayload.reasoning)}`, + ); +} +NODE + +cat >"$OPENCLAW_STATE_DIR/openclaw.json" </tmp/openclaw-openai-web-search-minimal-mock.log 2>&1 & +mock_pid="$!" + +for _ in $(seq 1 80); do + if node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null + +node "$entry" gateway --port "$PORT" --bind loopback --allow-unconfigured >"$GATEWAY_LOG" 2>&1 & +gateway_pid="$!" +for _ in $(seq 1 360); do + if ! kill -0 "$gateway_pid" 2>/dev/null; then + echo "gateway exited before listening" >&2 + exit 1 + fi + if node "$entry" gateway health \ + --url "ws://127.0.0.1:$PORT" \ + --token "$TOKEN" \ + --timeout 120000 \ + --json >/dev/null 2>&1; then + break + fi + sleep 0.25 +done +node "$entry" gateway health \ + --url "ws://127.0.0.1:$PORT" \ + --token "$TOKEN" \ + --timeout 120000 \ + --json >/dev/null + +PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" node scripts/e2e/lib/openai-web-search-minimal/client.mjs success >/tmp/openclaw-openai-web-search-minimal-client-success.log 2>&1 + +node - "$MOCK_REQUEST_LOG" <<'NODE' +const fs = require("node:fs"); +const logPath = process.argv[2]; +const entries = fs.readFileSync(logPath, "utf8").trim().split(/\n+/).filter(Boolean).map((line) => JSON.parse(line)); +const responseEntries = entries.filter((entry) => entry.path === "/v1/responses"); +if (responseEntries.length < 1) { + throw new Error(`mock OpenAI /v1/responses was not used. Requests: ${JSON.stringify(entries)}`); +} +const success = responseEntries.find((entry) => JSON.stringify(entry.body).includes("OPENCLAW_SCHEMA_E2E_OK")); +if (!success) { + throw new Error(`missing success request. Requests: ${JSON.stringify(responseEntries)}`); +} +const tools = Array.isArray(success.body.tools) ? success.body.tools : []; +const hasWebSearch = tools.some((tool) => tool?.type === "web_search" || (tool?.type === "function" && (tool?.name === "web_search" || tool?.function?.name === "web_search"))); +if (!hasWebSearch) { + throw new Error(`success request did not include web_search. Body: ${JSON.stringify(success.body)}`); +} +if (success.body.reasoning?.effort === "minimal") { + throw new Error(`expected web_search request to avoid minimal reasoning, got ${JSON.stringify(success.body.reasoning)}`); +} +NODE + +PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" node scripts/e2e/lib/openai-web-search-minimal/client.mjs reject >/tmp/openclaw-openai-web-search-minimal-client-reject.log 2>&1 + +for _ in $(seq 1 80); do + if grep -Fq "$RAW_SCHEMA_ERROR" "$GATEWAY_LOG"; then + break + fi + sleep 0.25 +done +grep -F "$RAW_SCHEMA_ERROR" "$GATEWAY_LOG" >/dev/null + +echo "OpenAI web_search minimal reasoning Docker E2E passed" diff --git a/scripts/e2e/lib/package-compat.mjs b/scripts/e2e/lib/package-compat.mjs new file mode 100644 index 00000000000..1063f2e3a94 --- /dev/null +++ b/scripts/e2e/lib/package-compat.mjs @@ -0,0 +1,11 @@ +export function legacyPackageAcceptanceCompat(version) { + const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?/.exec(version || ""); + const [year, month, day] = match?.slice(1, 4).map(Number) ?? []; + return ( + Boolean(match) && (year < 2026 || (year === 2026 && (month < 4 || (month === 4 && day <= 25)))) + ); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + console.log(legacyPackageAcceptanceCompat(process.argv[2]) ? "1" : "0"); +} diff --git a/scripts/e2e/lib/plugin-update/probe.mjs b/scripts/e2e/lib/plugin-update/probe.mjs index d021ed533f3..2bcc451115d 100644 --- a/scripts/e2e/lib/plugin-update/probe.mjs +++ b/scripts/e2e/lib/plugin-update/probe.mjs @@ -2,6 +2,7 @@ import fs from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; +import { legacyPackageAcceptanceCompat } from "../package-compat.mjs"; const home = os.homedir(); @@ -25,14 +26,6 @@ const pluginRecordSnapshot = () => { return { source, spec, resolvedName, resolvedVersion, resolvedSpec, integrity, shasum }; }; -function legacyCompat(version) { - const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?/.exec(version); - const [year, month, day] = match?.slice(1, 4).map(Number) ?? []; - return ( - Boolean(match) && (year < 2026 || (year === 2026 && (month < 4 || (month === 4 && day <= 25)))) - ); -} - function openclawPath(...parts) { return path.join(home, ".openclaw", ...parts); } @@ -121,7 +114,7 @@ function assertOutput(logPath) { const [command, arg] = process.argv.slice(2); const commands = { - "legacy-compat": () => console.log(legacyCompat(arg || "") ? "1" : "0"), + "legacy-compat": () => console.log(legacyPackageAcceptanceCompat(arg || "") ? "1" : "0"), seed: seedInstallState, "wait-registry": waitRegistry, snapshot: () => process.stdout.write(JSON.stringify(pluginRecordSnapshot(), null, 2)), diff --git a/scripts/e2e/lib/plugins/sweep.sh b/scripts/e2e/lib/plugins/sweep.sh new file mode 100644 index 00000000000..d5fc1c57826 --- /dev/null +++ b/scripts/e2e/lib/plugins/sweep.sh @@ -0,0 +1,910 @@ +#!/usr/bin/env bash +set -euo pipefail + +source scripts/lib/openclaw-e2e-instance.sh +source scripts/lib/docker-e2e-logs.sh +OPENCLAW_ENTRY="$(openclaw_e2e_resolve_entrypoint)" +export OPENCLAW_ENTRY +PACKAGE_VERSION="$(node -p 'require("./package.json").version')" +OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$(node scripts/e2e/lib/package-compat.mjs "$PACKAGE_VERSION")" +export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT + +openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" +BUNDLED_PLUGIN_ROOT_DIR="extensions" +OPENCLAW_PLUGIN_HOME="$HOME/.openclaw/$BUNDLED_PLUGIN_ROOT_DIR" + +record_fixture_plugin_trust() { + local plugin_id="$1" + local plugin_root="$2" + local enabled="$3" + node - "$plugin_id" "$plugin_root" "$enabled" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.argv[2]; +const pluginRoot = process.argv[3]; +const enabled = process.argv[4] === "1"; +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) + ? JSON.parse(fs.readFileSync(configPath, "utf8")) + : {}; +const plugins = (config.plugins ??= {}); +const entries = (plugins.entries ??= {}); +entries[pluginId] = { ...(entries[pluginId] ?? {}), enabled }; +delete plugins.installs; +plugins.allow = Array.from(new Set([...(plugins.allow ?? []), pluginId])).sort(); +fs.mkdirSync(path.dirname(configPath), { recursive: true }); +fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + +const ledgerPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const ledger = fs.existsSync(ledgerPath) + ? JSON.parse(fs.readFileSync(ledgerPath, "utf8")) + : { + version: 1, + warning: + "DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.", + records: {}, + }; +ledger.updatedAtMs = Date.now(); +ledger.records ??= {}; +ledger.records[pluginId] = { + ...(ledger.records[pluginId] ?? {}), + source: "path", + installPath: pluginRoot, + sourcePath: pluginRoot, +}; +fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); +fs.writeFileSync(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, "utf8"); +NODE +} + +write_fixture_plugin() { + local dir="$1" + local id="$2" + local version="$3" + local method="$4" + local name="$5" + + mkdir -p "$dir" + cat >"$dir/package.json" <"$dir/index.js" < ({ ok: true })); + }, +}; +JS + cat >"$dir/openclaw.plugin.json" <<'JSON' +{ + "id": "placeholder", + "configSchema": { + "type": "object", + "properties": {} + } +} +JSON + node - "$dir/openclaw.plugin.json" "$id" <<'NODE' +const fs = require("node:fs"); +const file = process.argv[2]; +const id = process.argv[3]; +const parsed = JSON.parse(fs.readFileSync(file, "utf8")); +parsed.id = id; +fs.writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`); +NODE +} + +demo_plugin_id="demo-plugin" +demo_plugin_root="$OPENCLAW_PLUGIN_HOME/$demo_plugin_id" +mkdir -p "$demo_plugin_root" + +cat >"$demo_plugin_root/index.js" <<'JS' +module.exports = { + id: "demo-plugin", + name: "Demo Plugin", + description: "Docker E2E demo plugin", + register(api) { + api.registerTool(() => null, { name: "demo_tool" }); + api.registerGatewayMethod("demo.ping", async () => ({ ok: true })); + api.registerCli(() => {}, { commands: ["demo"] }); + api.registerService({ id: "demo-service", start: () => {} }); + }, +}; +JS +cat >"$demo_plugin_root/openclaw.plugin.json" <<'JSON' +{ + "id": "demo-plugin", + "configSchema": { + "type": "object", + "properties": {} + } +} +JSON +record_fixture_plugin_trust "$demo_plugin_id" "$demo_plugin_root" 1 + +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin --json >/tmp/plugins-inspect.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8")); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-inspect.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin"); +if (!plugin) throw new Error("plugin not found"); +if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); +} + +const assertIncludes = (list, value, label) => { + if (!Array.isArray(list) || !list.includes(value)) { + throw new Error(`${label} missing: ${value}`); + } +}; + +const inspectToolNames = Array.isArray(inspect.tools) + ? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : [])) + : []; +assertIncludes(inspectToolNames, "demo_tool", "tool"); +assertIncludes(inspect.gatewayMethods, "demo.ping", "gateway method"); +assertIncludes(inspect.cliCommands, "demo", "cli command"); +assertIncludes(inspect.services, "demo-service", "service"); + +const diagErrors = (data.diagnostics || []).filter((diag) => diag.level === "error"); +if (diagErrors.length > 0) { + throw new Error(`diagnostics errors: ${diagErrors.map((diag) => diag.message).join("; ")}`); +} + +console.log("ok"); +NODE + +echo "Testing tgz install flow..." +pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")" +mkdir -p "$pack_dir/package" +cat >"$pack_dir/package/package.json" <<'JSON' +{ + "name": "@openclaw/demo-plugin-tgz", + "version": "0.0.1", + "openclaw": { "extensions": ["./index.js"] } +} +JSON +cat >"$pack_dir/package/index.js" <<'JS' +module.exports = { + id: "demo-plugin-tgz", + name: "Demo Plugin TGZ", + register(api) { + api.registerGatewayMethod("demo.tgz", async () => ({ ok: true })); + }, +}; +JS +cat >"$pack_dir/package/openclaw.plugin.json" <<'JSON' +{ + "id": "demo-plugin-tgz", + "configSchema": { + "type": "object", + "properties": {} + } +} +JSON +tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package + +run_logged install-tgz node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins2.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-tgz --json >/tmp/plugins2-inspect.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8")); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins2-inspect.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-tgz"); +if (!plugin) throw new Error("tgz plugin not found"); +if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); +} +if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes("demo.tgz")) { + throw new Error("expected gateway method demo.tgz"); +} +console.log("ok"); +NODE + +echo "Testing install from local folder (plugins.load.paths)..." +dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")" +cat >"$dir_plugin/package.json" <<'JSON' +{ + "name": "@openclaw/demo-plugin-dir", + "version": "0.0.1", + "openclaw": { "extensions": ["./index.js"] } +} +JSON +cat >"$dir_plugin/index.js" <<'JS' +module.exports = { + id: "demo-plugin-dir", + name: "Demo Plugin DIR", + register(api) { + api.registerGatewayMethod("demo.dir", async () => ({ ok: true })); + }, +}; +JS +cat >"$dir_plugin/openclaw.plugin.json" <<'JSON' +{ + "id": "demo-plugin-dir", + "configSchema": { + "type": "object", + "properties": {} + } +} +JSON + +run_logged install-dir node "$OPENCLAW_ENTRY" plugins install "$dir_plugin" +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins3.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-dir --json >/tmp/plugins3-inspect.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8")); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins3-inspect.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-dir"); +if (!plugin) throw new Error("dir plugin not found"); +if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); +} +if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes("demo.dir")) { + throw new Error("expected gateway method demo.dir"); +} +console.log("ok"); +NODE + +echo "Testing install from npm spec (file:)..." +file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")" +mkdir -p "$file_pack_dir/package" +cat >"$file_pack_dir/package/package.json" <<'JSON' +{ + "name": "@openclaw/demo-plugin-file", + "version": "0.0.1", + "openclaw": { "extensions": ["./index.js"] } +} +JSON +cat >"$file_pack_dir/package/index.js" <<'JS' +module.exports = { + id: "demo-plugin-file", + name: "Demo Plugin FILE", + register(api) { + api.registerGatewayMethod("demo.file", async () => ({ ok: true })); + }, +}; +JS +cat >"$file_pack_dir/package/openclaw.plugin.json" <<'JSON' +{ + "id": "demo-plugin-file", + "configSchema": { + "type": "object", + "properties": {} + } +} +JSON + +run_logged install-file node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package" +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins4.json +node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-file --json >/tmp/plugins4-inspect.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8")); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins4-inspect.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-file"); +if (!plugin) throw new Error("file plugin not found"); +if (plugin.status !== "loaded") { + throw new Error(`unexpected status: ${plugin.status}`); +} +if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes("demo.file")) { + throw new Error("expected gateway method demo.file"); +} +console.log("ok"); +NODE + +echo "Testing Claude bundle enable and inspect flow..." +bundle_plugin_id="claude-bundle-e2e" +bundle_root="$OPENCLAW_PLUGIN_HOME/$bundle_plugin_id" +mkdir -p "$bundle_root/.claude-plugin" "$bundle_root/commands" +cat >"$bundle_root/.claude-plugin/plugin.json" <<'JSON' +{ + "name": "claude-bundle-e2e" +} +JSON +cat >"$bundle_root/commands/office-hours.md" <<'MD' +--- +description: Help with architecture and rollout planning +--- +Act as an engineering advisor. + +Focus on: +$ARGUMENTS +MD +record_fixture_plugin_trust "$bundle_plugin_id" "$bundle_root" 0 + +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-bundle-disabled.json +node - <<'NODE' +const fs = require("node:fs"); +const data = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-disabled.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "claude-bundle-e2e"); +if (!plugin) throw new Error("Claude bundle plugin not found"); +if (plugin.status !== "disabled") { + throw new Error(`expected disabled bundle before enable, got ${plugin.status}`); +} +console.log("ok"); +NODE + +run_logged enable-claude-bundle node "$OPENCLAW_ENTRY" plugins enable claude-bundle-e2e +node "$OPENCLAW_ENTRY" plugins inspect claude-bundle-e2e --json >/tmp/plugins-bundle-inspect.json +node - <<'NODE' +const fs = require("node:fs"); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-inspect.json", "utf8")); +if (inspect.plugin?.bundleFormat !== "claude") { + throw new Error(`expected Claude bundle format, got ${inspect.plugin?.bundleFormat}`); +} +if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") { + throw new Error( + `expected enabled loaded Claude bundle, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, + ); +} +console.log("ok"); +NODE + +echo "Testing plugin install visible after explicit restart..." +slash_install_dir="$(mktemp -d "/tmp/openclaw-plugin-slash-install.XXXXXX")" +cat >"$slash_install_dir/package.json" <<'JSON' +{ + "name": "@openclaw/slash-install-plugin", + "version": "0.0.1", + "openclaw": { "extensions": ["./index.js"] } +} +JSON +cat >"$slash_install_dir/index.js" <<'JS' +module.exports = { + id: "slash-install-plugin", + name: "Slash Install Plugin", + register(api) { + api.registerGatewayMethod("demo.slash.install", async () => ({ ok: true })); + }, +}; +JS +cat >"$slash_install_dir/openclaw.plugin.json" <<'JSON' +{ + "id": "slash-install-plugin", + "configSchema": { + "type": "object", + "properties": {} + } +} +JSON + +run_logged install-slash-plugin node "$OPENCLAW_ENTRY" plugins install "$slash_install_dir" +node "$OPENCLAW_ENTRY" plugins inspect slash-install-plugin --json >/tmp/plugin-command-install-show.json +node - <<'NODE' +const fs = require("node:fs"); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugin-command-install-show.json", "utf8")); +if (inspect.plugin?.status !== "loaded") { + throw new Error(`expected loaded status after install, got ${inspect.plugin?.status}`); +} +if (inspect.plugin?.enabled !== true) { + throw new Error(`expected enabled status after install, got ${inspect.plugin?.enabled}`); +} +if (!inspect.gatewayMethods.includes("demo.slash.install")) { + throw new Error(`expected installed gateway method, got ${inspect.gatewayMethods.join(", ")}`); +} +console.log("ok"); +NODE + +echo "Testing marketplace install and update flows..." +marketplace_root="$HOME/.claude/plugins/marketplaces/fixture-marketplace" +mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin" +write_fixture_plugin \ + "$marketplace_root/plugins/marketplace-shortcut" \ + "marketplace-shortcut" \ + "0.0.1" \ + "demo.marketplace.shortcut.v1" \ + "Marketplace Shortcut" +write_fixture_plugin \ + "$marketplace_root/plugins/marketplace-direct" \ + "marketplace-direct" \ + "0.0.1" \ + "demo.marketplace.direct.v1" \ + "Marketplace Direct" +cat >"$marketplace_root/.claude-plugin/marketplace.json" <<'JSON' +{ + "name": "Fixture Marketplace", + "version": "1.0.0", + "plugins": [ + { + "name": "marketplace-shortcut", + "version": "0.0.1", + "description": "Shortcut install fixture", + "source": "./plugins/marketplace-shortcut" + }, + { + "name": "marketplace-direct", + "version": "0.0.1", + "description": "Explicit marketplace fixture", + "source": { + "type": "path", + "path": "./plugins/marketplace-direct" + } + } + ] +} +JSON +cat >"$HOME/.claude/plugins/known_marketplaces.json" </tmp/marketplace-list.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/marketplace-list.json", "utf8")); +const names = (data.plugins || []).map((entry) => entry.name).sort(); +if (data.name !== "Fixture Marketplace") { + throw new Error(`unexpected marketplace name: ${data.name}`); +} +if (!names.includes("marketplace-shortcut") || !names.includes("marketplace-direct")) { + throw new Error(`unexpected marketplace plugins: ${names.join(", ")}`); +} +console.log("ok"); +NODE + +run_logged install-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures +run_logged install-marketplace-direct node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-marketplace.json +node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-shortcut-inspect.json +node "$OPENCLAW_ENTRY" plugins inspect marketplace-direct --json >/tmp/plugins-marketplace-direct-inspect.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace.json", "utf8")); +const shortcutInspect = JSON.parse( + fs.readFileSync("/tmp/plugins-marketplace-shortcut-inspect.json", "utf8"), +); +const directInspect = JSON.parse( + fs.readFileSync("/tmp/plugins-marketplace-direct-inspect.json", "utf8"), +); +const getPlugin = (id) => { + const plugin = (data.plugins || []).find((entry) => entry.id === id); + if (!plugin) throw new Error(`plugin not found: ${id}`); + if (plugin.status !== "loaded") { + throw new Error(`unexpected status for ${id}: ${plugin.status}`); + } + return plugin; +}; + +const shortcut = getPlugin("marketplace-shortcut"); +const direct = getPlugin("marketplace-direct"); +if (shortcut.version !== "0.0.1") { + throw new Error(`unexpected shortcut version: ${shortcut.version}`); +} +if (direct.version !== "0.0.1") { + throw new Error(`unexpected direct version: ${direct.version}`); +} +if (!shortcutInspect.gatewayMethods.includes("demo.marketplace.shortcut.v1")) { + throw new Error("expected marketplace shortcut gateway method"); +} +if (!directInspect.gatewayMethods.includes("demo.marketplace.direct.v1")) { + throw new Error("expected marketplace direct gateway method"); +} +console.log("ok"); +NODE + +node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; +if (!allowLegacyCompat && !index.installRecords) { + throw new Error("expected modern installRecords in installed plugin index"); +} +const installRecords = allowLegacyCompat + ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} + : index.installRecords ?? {}; +for (const id of ["marketplace-shortcut", "marketplace-direct"]) { + const record = installRecords[id]; + if (!record) { + if (allowLegacyCompat) { + console.log(`legacy package did not persist marketplace install record for ${id}`); + continue; + } + throw new Error(`missing marketplace install record for ${id}`); + } + if (record.source !== "marketplace") { + throw new Error(`unexpected source for ${id}: ${record.source}`); + } + if (record.marketplaceSource !== "claude-fixtures") { + throw new Error(`unexpected marketplace source for ${id}: ${record.marketplaceSource}`); + } + if (record.marketplacePlugin !== id) { + throw new Error(`unexpected marketplace plugin for ${id}: ${record.marketplacePlugin}`); + } +} +console.log("ok"); +NODE + +write_fixture_plugin \ + "$marketplace_root/plugins/marketplace-shortcut" \ + "marketplace-shortcut" \ + "0.0.2" \ + "demo.marketplace.shortcut.v2" \ + "Marketplace Shortcut" +run_logged update-marketplace-shortcut-dry-run node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut --dry-run +run_logged update-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut +node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-marketplace-updated.json +node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json >/tmp/plugins-marketplace-updated-inspect.json + +node - <<'NODE' +const fs = require("node:fs"); + +const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated.json", "utf8")); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated-inspect.json", "utf8")); +const plugin = (data.plugins || []).find((entry) => entry.id === "marketplace-shortcut"); +if (!plugin) throw new Error("updated marketplace plugin not found"); +if (plugin.version !== "0.0.2") { + throw new Error(`unexpected updated version: ${plugin.version}`); +} +if (!inspect.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { + throw new Error(`expected updated gateway method, got ${inspect.gatewayMethods.join(", ")}`); +} +console.log("ok"); +NODE + +if [ "${OPENCLAW_PLUGINS_E2E_CLAWHUB:-1}" = "0" ]; then + echo "Skipping ClawHub plugin install and uninstall (OPENCLAW_PLUGINS_E2E_CLAWHUB=0)." +else + echo "Testing ClawHub kitchen-sink plugin install and uninstall..." + CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:openclaw-kitchen-sink}" + CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-openclaw-kitchen-sink-fixture}" + export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID + + start_clawhub_fixture_server() { + local fixture_dir="$1" + local server_log="$fixture_dir/clawhub-fixture.log" + local server_port_file="$fixture_dir/clawhub-fixture-port" + local server_pid_file="$fixture_dir/clawhub-fixture-pid" + + node - "$server_port_file" <<'NODE' >"$server_log" 2>&1 & +const crypto = require("node:crypto"); +const http = require("node:http"); +const path = require("node:path"); +const { createRequire } = require("node:module"); + +const portFile = process.argv[2]; +const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); +const JSZip = requireFromApp("jszip"); +const packageName = "openclaw-kitchen-sink"; +const pluginId = "openclaw-kitchen-sink-fixture"; +const version = "0.1.0"; + +async function main() { + const zip = new JSZip(); + zip.file( + "package/package.json", + `${JSON.stringify( + { + name: packageName, + version, + openclaw: { extensions: ["./index.js"] }, + }, + null, + 2, + )}\n`, + { date: new Date(0) }, + ); + zip.file( + "package/index.js", + `module.exports = { + id: "${pluginId}", + name: "OpenClaw Kitchen Sink", + description: "Docker E2E kitchen-sink plugin fixture", + register(api) { + api.on("before_agent_start", async (event, context) => ({ + kitchenSink: true, + observedEventKeys: Object.keys(event || {}), + observedContextKeys: Object.keys(context || {}), + })); + api.registerTool(() => null, { name: "kitchen_sink_tool" }); + api.registerGatewayMethod("kitchen-sink.ping", async () => ({ ok: true })); + api.registerCli(() => {}, { commands: ["kitchen-sink"] }); + api.registerService({ id: "kitchen-sink-service", start: () => {} }); + }, +}; +`, + { date: new Date(0) }, + ); + zip.file( + "package/openclaw.plugin.json", + `${JSON.stringify( + { + id: pluginId, + configSchema: { + type: "object", + properties: {}, + }, + }, + null, + 2, + )}\n`, + { date: new Date(0) }, + ); + + const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); + const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); + + const json = (response, value) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(`${JSON.stringify(value)}\n`); + }; + + const server = http.createServer((request, response) => { + const url = new URL(request.url, "http://127.0.0.1"); + if (request.method !== "GET") { + response.writeHead(405); + response.end("method not allowed"); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) { + json(response, { + package: { + name: packageName, + displayName: "OpenClaw Kitchen Sink", + family: "code-plugin", + channel: "official", + isOfficial: true, + runtimeId: pluginId, + latestVersion: version, + createdAt: 0, + updatedAt: 0, + compatibility: { + pluginApiRange: ">=2026.4.26", + minGatewayVersion: "2026.4.26", + }, + }, + }); + return; + } + if ( + url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}` + ) { + json(response, { + version: { + version, + createdAt: 0, + changelog: "Kitchen-sink fixture package for Docker plugin E2E.", + sha256hash, + compatibility: { + pluginApiRange: ">=2026.4.26", + minGatewayVersion: "2026.4.26", + }, + }, + }); + return; + } + if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) { + response.writeHead(200, { + "content-type": "application/zip", + "content-length": String(archive.length), + }); + response.end(archive); + return; + } + response.writeHead(404, { "content-type": "text/plain" }); + response.end(`not found: ${url.pathname}`); + }); + + server.listen(0, "127.0.0.1", () => { + require("node:fs").writeFileSync(portFile, String(server.address().port)); + }); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +NODE + local server_pid="$!" + echo "$server_pid" >"$server_pid_file" + + for _ in $(seq 1 100); do + if [[ -s "$server_port_file" ]]; then + export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")" + trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT + return 0 + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + cat "$server_log" + return 1 + fi + sleep 0.1 + done + + cat "$server_log" + echo "Timed out waiting for ClawHub fixture server." >&2 + return 1 + } + + if [[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]; then + # Keep the release-path smoke hermetic; live ClawHub can rate-limit CI. + clawhub_fixture_dir="$(mktemp -d "/tmp/openclaw-clawhub-fixture.XXXXXX")" + start_clawhub_fixture_server "$clawhub_fixture_dir" + fi + + node - <<'NODE' +const spec = process.env.CLAWHUB_PLUGIN_SPEC; +if (!spec?.startsWith("clawhub:")) { + throw new Error(`expected clawhub: spec, got ${spec}`); +} + +const parsePackageName = (rawSpec) => { + const value = rawSpec.slice("clawhub:".length).trim(); + const slashIndex = value.lastIndexOf("/"); + const atIndex = value.lastIndexOf("@"); + return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; +}; + +const packageName = parsePackageName(spec); +const baseUrl = (process.env.OPENCLAW_CLAWHUB_URL || process.env.CLAWHUB_URL || "https://clawhub.ai") + .replace(/\/+$/, ""); +const token = + process.env.OPENCLAW_CLAWHUB_TOKEN || + process.env.CLAWHUB_TOKEN || + process.env.CLAWHUB_AUTH_TOKEN || + ""; +const response = await fetch(`${baseUrl}/api/v1/packages/${encodeURIComponent(packageName)}`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, +}); +if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`ClawHub package preflight failed for ${packageName}: ${response.status} ${body}`); +} +const detail = await response.json(); +const family = detail.package?.family; +if (family !== "code-plugin" && family !== "bundle-plugin") { + throw new Error(`ClawHub package ${packageName} is not installable as a plugin: ${family}`); +} +if (detail.package?.runtimeId && detail.package.runtimeId !== process.env.CLAWHUB_PLUGIN_ID) { + throw new Error( + `ClawHub package ${packageName} runtimeId ${detail.package.runtimeId} does not match expected ${process.env.CLAWHUB_PLUGIN_ID}`, + ); +} +console.log(`Using ClawHub package ${packageName} (${family}).`); +NODE + + run_logged install-clawhub node "$OPENCLAW_ENTRY" plugins install "$CLAWHUB_PLUGIN_SPEC" + node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-clawhub-installed.json + node "$OPENCLAW_ENTRY" plugins inspect "$CLAWHUB_PLUGIN_ID" --json >/tmp/plugins-clawhub-inspect.json + + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.env.CLAWHUB_PLUGIN_ID; +const spec = process.env.CLAWHUB_PLUGIN_SPEC; +const parsePackageName = (rawSpec) => { + const value = rawSpec.slice("clawhub:".length).trim(); + const slashIndex = value.lastIndexOf("/"); + const atIndex = value.lastIndexOf("@"); + return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; +}; +const packageName = parsePackageName(spec); +const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-installed.json", "utf8")); +const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-inspect.json", "utf8")); +const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); +if (!plugin) throw new Error(`ClawHub plugin not found after install: ${pluginId}`); +if (plugin.status !== "loaded") { + throw new Error(`unexpected ClawHub plugin status for ${pluginId}: ${plugin.status}`); +} +if (inspect.plugin?.id !== pluginId) { + throw new Error(`unexpected ClawHub inspect plugin id: ${inspect.plugin?.id}`); +} + +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; +if (!allowLegacyCompat && !index.installRecords) { + throw new Error("expected modern installRecords in installed plugin index"); +} +const installRecords = allowLegacyCompat + ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} + : index.installRecords ?? {}; +const record = installRecords[pluginId]; +if (!record) throw new Error(`missing ClawHub install record for ${pluginId}`); +if (record.source !== "clawhub") { + throw new Error(`unexpected ClawHub install source for ${pluginId}: ${record.source}`); +} +if (record.clawhubPackage !== packageName) { + throw new Error( + `unexpected ClawHub package for ${pluginId}: ${record.clawhubPackage}, expected ${packageName}`, + ); +} +if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") { + throw new Error(`unexpected ClawHub family for ${pluginId}: ${record.clawhubFamily}`); +} +if (typeof record.installPath !== "string" || record.installPath.length === 0) { + throw new Error(`missing ClawHub install path for ${pluginId}`); +} + +const installPath = record.installPath.replace(/^~(?=$|\/)/, process.env.HOME); +const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions"); +if (!installPath.startsWith(`${extensionsRoot}${path.sep}`)) { + throw new Error(`ClawHub install path is outside managed extensions root: ${installPath}`); +} +if (!fs.existsSync(installPath)) { + throw new Error(`ClawHub install path missing on disk: ${installPath}`); +} +fs.writeFileSync("/tmp/plugins-clawhub-install-path.txt", installPath, "utf8"); +console.log("ok"); +NODE + + run_logged uninstall-clawhub node "$OPENCLAW_ENTRY" plugins uninstall "$CLAWHUB_PLUGIN_SPEC" --force + node "$OPENCLAW_ENTRY" plugins list --json >/tmp/plugins-clawhub-uninstalled.json + + node - <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); + +const pluginId = process.env.CLAWHUB_PLUGIN_ID; +const installPath = fs.readFileSync("/tmp/plugins-clawhub-install-path.txt", "utf8").trim(); +const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-uninstalled.json", "utf8")); +if ((list.plugins || []).some((entry) => entry.id === pluginId)) { + throw new Error(`ClawHub plugin still listed after uninstall: ${pluginId}`); +} + +const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); +const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {}; +const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; +const installRecords = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; +if (installRecords[pluginId]) { + throw new Error(`ClawHub install record still present after uninstall: ${pluginId}`); +} + +const configAfterUninstallPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); +const configAfterUninstall = fs.existsSync(configAfterUninstallPath) + ? JSON.parse(fs.readFileSync(configAfterUninstallPath, "utf8")) + : {}; +if (configAfterUninstall.plugins?.entries?.[pluginId]) { + throw new Error(`ClawHub config entry still present after uninstall: ${pluginId}`); +} +if ((configAfterUninstall.plugins?.allow || []).includes(pluginId)) { + throw new Error(`ClawHub allowlist entry still present after uninstall: ${pluginId}`); +} +if ((configAfterUninstall.plugins?.deny || []).includes(pluginId)) { + throw new Error(`ClawHub denylist entry still present after uninstall: ${pluginId}`); +} +if (fs.existsSync(installPath)) { + throw new Error(`ClawHub managed install directory still exists after uninstall: ${installPath}`); +} +console.log("ok"); +NODE +fi diff --git a/scripts/e2e/mcp-channels-docker.sh b/scripts/e2e/mcp-channels-docker.sh index 5f5a11a77c6..542e1ef0088 100644 --- a/scripts/e2e/mcp-channels-docker.sh +++ b/scripts/e2e/mcp-channels-docker.sh @@ -18,13 +18,12 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" mcp-channels -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 mcp-channels empty)" echo "Running in-container gateway + MCP smoke..." # Harness files are mounted read-only; the app under test comes from /app/dist. set +e -docker run --rm \ +docker_e2e_run_with_harness \ --name "$CONTAINER_NAME" \ -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ -e "OPENCLAW_SKIP_CHANNELS=1" \ @@ -37,7 +36,6 @@ docker run --rm \ -e "GW_URL=ws://127.0.0.1:$PORT" \ -e "GW_TOKEN=$TOKEN" \ -e "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc "set -euo pipefail source scripts/lib/openclaw-e2e-instance.sh diff --git a/scripts/e2e/npm-telegram-live-docker.sh b/scripts/e2e/npm-telegram-live-docker.sh index 143fd7809b6..fe967b64955 100755 --- a/scripts/e2e/npm-telegram-live-docker.sh +++ b/scripts/e2e/npm-telegram-live-docker.sh @@ -89,7 +89,6 @@ if [ -z "$PACKAGE_LABEL" ]; then fi docker_e2e_build_or_reuse "$IMAGE_NAME" npm-telegram-live "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "$DOCKER_TARGET" -docker_e2e_harness_mount_args mkdir -p "$ROOT_DIR/.artifacts/qa-e2e" run_log="$(mktemp "${TMPDIR:-/tmp}/openclaw-npm-telegram-live.XXXXXX")" @@ -189,10 +188,9 @@ openclaw --version EOF # Mount only test harness/plugin QA sources; the SUT itself is the installed package candidate. -run_logged docker run --rm \ +run_logged docker_e2e_run_with_harness \ "${docker_env[@]}" \ -v "$ROOT_DIR/.artifacts:/app/.artifacts" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -v "$ROOT_DIR/extensions:/app/extensions:ro" \ -v "$npm_prefix_host:/npm-global" \ -i "$IMAGE_NAME" bash -s <<'EOF' diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index 306538b9f91..ecbbd9da874 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -7,12 +7,10 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-onboard-e2e" OPENCLAW_ONBOARD_E OPENCLAW_TEST_STATE_FUNCTION_B64="$(docker_e2e_test_state_function_b64)" docker_e2e_build_or_reuse "$IMAGE_NAME" onboard -docker_e2e_harness_mount_args echo "Running onboarding E2E..." -docker run --rm -t \ +docker_e2e_run_with_harness -t \ -e "OPENCLAW_TEST_STATE_FUNCTION_B64=$OPENCLAW_TEST_STATE_FUNCTION_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" bash -lc ' set -euo pipefail trap "" PIPE diff --git a/scripts/e2e/openai-image-auth-docker.sh b/scripts/e2e/openai-image-auth-docker.sh index d14b2986426..176469c530d 100644 --- a/scripts/e2e/openai-image-auth-docker.sh +++ b/scripts/e2e/openai-image-auth-docker.sh @@ -10,16 +10,14 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-openai-image-auth-e2e" OPENCLAW SKIP_BUILD="${OPENCLAW_OPENAI_IMAGE_AUTH_E2E_SKIP_BUILD:-0}" docker_e2e_build_or_reuse "$IMAGE_NAME" openai-image-auth "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" -docker_e2e_harness_mount_args OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 openai-image-auth empty)" echo "Running OpenAI image auth Docker E2E..." # Harness files are mounted read-only; the app under test comes from /app/dist. -run_logged openai-image-auth docker run --rm \ +docker_e2e_run_logged_with_harness openai-image-auth \ -e "OPENAI_API_KEY=sk-openclaw-image-auth-e2e" \ -e "OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER=1" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ -i "$IMAGE_NAME" bash -lc ' set -euo pipefail eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)" diff --git a/scripts/e2e/openai-web-search-minimal-docker.sh b/scripts/e2e/openai-web-search-minimal-docker.sh index 4c169164d5b..5166e895014 100755 --- a/scripts/e2e/openai-web-search-minimal-docker.sh +++ b/scripts/e2e/openai-web-search-minimal-docker.sh @@ -14,451 +14,12 @@ docker_e2e_build_or_reuse "$IMAGE_NAME" openai-web-search-minimal "$ROOT_DIR/scr OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 openai-web-search-minimal empty)" echo "Running OpenAI web_search minimal reasoning Docker E2E..." -run_logged openai-web-search-minimal docker run --rm \ +docker_e2e_run_logged_with_harness openai-web-search-minimal \ --add-host api.openai.com:127.0.0.1 \ -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ -e "OPENAI_API_KEY=sk-openclaw-web-search-minimal-e2e" \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ -e "PORT=$PORT" \ -e "MOCK_PORT=$MOCK_PORT" \ - -i "$IMAGE_NAME" bash -s <<'EOF' -set -euo pipefail - -eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)" -export OPENCLAW_SKIP_CHANNELS=1 -export OPENCLAW_SKIP_GMAIL_WATCHER=1 -export OPENCLAW_SKIP_CRON=1 -export OPENCLAW_SKIP_CANVAS_HOST=1 -export OPENCLAW_SKIP_BROWSER_CONTROL_SERVER=1 -export OPENCLAW_SKIP_ACPX_RUNTIME=1 -export OPENCLAW_SKIP_ACPX_RUNTIME_PROBE=1 - -PORT="${PORT:?missing PORT}" -MOCK_PORT="${MOCK_PORT:?missing MOCK_PORT}" -TOKEN="${OPENCLAW_GATEWAY_TOKEN:?missing OPENCLAW_GATEWAY_TOKEN}" -SUCCESS_MARKER="OPENCLAW_SCHEMA_E2E_OK" -RAW_SCHEMA_ERROR="400 The following tools cannot be used with reasoning.effort 'minimal': web_search." -MOCK_REQUEST_LOG="/tmp/openclaw-openai-web-search-minimal-requests.jsonl" -GATEWAY_LOG="/tmp/openclaw-openai-web-search-minimal-gateway.log" -mock_pid="" -gateway_pid="" - -cleanup() { - if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then - kill "$gateway_pid" 2>/dev/null || true - wait "$gateway_pid" 2>/dev/null || true - fi - if [ -n "${mock_pid:-}" ] && kill -0 "$mock_pid" 2>/dev/null; then - kill "$mock_pid" 2>/dev/null || true - wait "$mock_pid" 2>/dev/null || true - fi -} -trap cleanup EXIT - -dump_debug_logs() { - local status="$1" - echo "OpenAI web_search minimal Docker E2E failed with exit code $status" >&2 - for file in \ - "$GATEWAY_LOG" \ - /tmp/openclaw-openai-web-search-minimal-mock.log \ - /tmp/openclaw-openai-web-search-minimal-client-success.log \ - /tmp/openclaw-openai-web-search-minimal-client-reject.log \ - "$MOCK_REQUEST_LOG" \ - "$OPENCLAW_STATE_DIR/openclaw.json"; do - if [ -f "$file" ]; then - echo "--- $file ---" >&2 - sed -n '1,260p' "$file" >&2 || true - fi - done -} -trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR - -entry=dist/index.mjs -[ -f "$entry" ] || entry=dist/index.js -mkdir -p "$OPENCLAW_STATE_DIR" - -node --input-type=module <<'NODE' -import { patchOpenAINativeWebSearchPayload } from "./dist/extensions/openai/native-web-search.js"; - -const injectedPayload = { - reasoning: { effort: "minimal", summary: "auto" }, -}; -const injectedResult = patchOpenAINativeWebSearchPayload(injectedPayload); -if (injectedResult !== "injected") { - throw new Error(`expected native web_search injection, got ${injectedResult}`); -} -if (injectedPayload.reasoning.effort !== "low") { - throw new Error( - `expected injected native web_search to raise minimal reasoning to low, got ${JSON.stringify(injectedPayload.reasoning)}`, - ); -} -if (!injectedPayload.tools?.some((tool) => tool?.type === "web_search")) { - throw new Error(`native web_search was not injected: ${JSON.stringify(injectedPayload)}`); -} - -const existingNativePayload = { - tools: [{ type: "web_search" }], - reasoning: { effort: "minimal" }, -}; -const existingResult = patchOpenAINativeWebSearchPayload(existingNativePayload); -if (existingResult !== "native_tool_already_present") { - throw new Error(`expected existing native web_search, got ${existingResult}`); -} -if (existingNativePayload.reasoning.effort !== "low") { - throw new Error( - `expected existing native web_search to raise minimal reasoning to low, got ${JSON.stringify(existingNativePayload.reasoning)}`, - ); -} -NODE - -cat >"$OPENCLAW_STATE_DIR/openclaw.json" </tmp/openclaw-openai-web-search-minimal-mock.mjs <<'NODE' -import http from "node:http"; -import fs from "node:fs"; - -const port = Number(process.env.MOCK_PORT); -const requestLog = process.env.MOCK_REQUEST_LOG; -const successMarker = process.env.SUCCESS_MARKER; -const rawSchemaError = process.env.RAW_SCHEMA_ERROR; - -function readBody(req) { - return new Promise((resolve, reject) => { - let body = ""; - req.setEncoding("utf8"); - req.on("data", (chunk) => { - body += chunk; - }); - req.on("end", () => resolve(body)); - req.on("error", reject); - }); -} - -function writeJson(res, status, body) { - res.writeHead(status, { "content-type": "application/json" }); - res.end(JSON.stringify(body)); -} - -function writeOpenAiReject(res) { - writeJson(res, 400, { - error: { - message: rawSchemaError.replace(/^400\s+/, ""), - type: "invalid_request_error", - code: "invalid_request_error", - }, - }); -} - -function hasWebSearchTool(tools) { - return Array.isArray(tools) && tools.some((tool) => { - if (!tool || typeof tool !== "object") return false; - if (tool.type === "web_search") return true; - if (tool.type === "function" && tool.name === "web_search") return true; - if (tool.type === "function" && tool.function?.name === "web_search") return true; - return false; - }); -} - -function bodyContainsForceReject(body) { - return JSON.stringify(body).includes("FORCE_SCHEMA_REJECT"); -} - -function responseEvents(text) { - return [ - { - type: "response.output_item.added", - item: { - type: "message", - id: "msg_schema_e2e_1", - role: "assistant", - content: [], - status: "in_progress", - }, - }, - { - type: "response.output_item.done", - item: { - type: "message", - id: "msg_schema_e2e_1", - role: "assistant", - status: "completed", - content: [{ type: "output_text", text, annotations: [] }], - }, - }, - { - type: "response.completed", - response: { - id: "resp_schema_e2e_1", - status: "completed", - usage: { - input_tokens: 11, - output_tokens: 7, - total_tokens: 18, - input_tokens_details: { cached_tokens: 0 }, - }, - }, - }, - ]; -} - -function writeSse(res, events) { - res.writeHead(200, { - "content-type": "text/event-stream", - "cache-control": "no-store", - connection: "keep-alive", - }); - for (const event of events) { - res.write(`data: ${JSON.stringify(event)}\n\n`); - } - res.write("data: [DONE]\n\n"); - res.end(); -} - -const server = http.createServer(async (req, res) => { - const url = new URL(req.url ?? "/", "http://127.0.0.1"); - if (req.method === "GET" && url.pathname === "/health") { - writeJson(res, 200, { ok: true }); - return; - } - if (req.method === "GET" && url.pathname === "/v1/models") { - writeJson(res, 200, { - object: "list", - data: [{ id: "gpt-5", object: "model", owned_by: "openclaw-e2e" }], - }); - return; - } - - const bodyText = await readBody(req); - let body = {}; - try { - body = bodyText ? JSON.parse(bodyText) : {}; - } catch { - body = {}; - } - fs.appendFileSync(requestLog, `${JSON.stringify({ method: req.method, path: url.pathname, body })}\n`); - - if (req.method === "POST" && url.pathname === "/v1/responses") { - if (bodyContainsForceReject(body)) { - writeOpenAiReject(res); - return; - } - if (body?.reasoning?.effort === "minimal" && hasWebSearchTool(body.tools)) { - writeOpenAiReject(res); - return; - } - writeSse(res, responseEvents(successMarker)); - return; - } - - writeJson(res, 404, { error: { message: `unhandled mock route: ${req.method} ${url.pathname}` } }); -}); - -server.listen(port, "127.0.0.1", () => { - console.log(`mock-openai listening on ${port}`); -}); -NODE - -MOCK_PORT="$MOCK_PORT" \ -MOCK_REQUEST_LOG="$MOCK_REQUEST_LOG" \ -SUCCESS_MARKER="$SUCCESS_MARKER" \ -RAW_SCHEMA_ERROR="$RAW_SCHEMA_ERROR" \ -node /tmp/openclaw-openai-web-search-minimal-mock.mjs >/tmp/openclaw-openai-web-search-minimal-mock.log 2>&1 & -mock_pid="$!" - -for _ in $(seq 1 80); do - if node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then - break - fi - sleep 0.1 -done -node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null - -node "$entry" gateway --port "$PORT" --bind loopback --allow-unconfigured >"$GATEWAY_LOG" 2>&1 & -gateway_pid="$!" -for _ in $(seq 1 360); do - if ! kill -0 "$gateway_pid" 2>/dev/null; then - echo "gateway exited before listening" >&2 - exit 1 - fi - if node "$entry" gateway health \ - --url "ws://127.0.0.1:$PORT" \ - --token "$TOKEN" \ - --timeout 120000 \ - --json >/dev/null 2>&1; then - break - fi - sleep 0.25 -done -node "$entry" gateway health \ - --url "ws://127.0.0.1:$PORT" \ - --token "$TOKEN" \ - --timeout 120000 \ - --json >/dev/null - -cat >/tmp/openclaw-openai-web-search-minimal-client.mjs <<'NODE' -import { readdirSync } from "node:fs"; -import { pathToFileURL } from "node:url"; - -async function loadCallGateway() { - const candidates = readdirSync("/app/dist") - .filter((name) => /^call(?:\.runtime)?-[A-Za-z0-9_-]+\.js$/.test(name)) - .sort(); - for (const name of candidates) { - const mod = await import(pathToFileURL(`/app/dist/${name}`).href); - if (typeof mod.callGateway === "function") return mod.callGateway; - } - throw new Error(`unable to find callGateway export in /app/dist (${candidates.join(", ")})`); -} - -const callGateway = await loadCallGateway(); - -const port = process.env.PORT; -const token = process.env.OPENCLAW_GATEWAY_TOKEN; -const mode = process.argv[2]; -const sessionKey = `agent:main:openai-web-search-minimal:${mode}`; -const message = - mode === "reject" - ? "FORCE_SCHEMA_REJECT" - : "Return exactly OPENCLAW_SCHEMA_E2E_OK."; -const id = mode === "reject" ? "schema-reject" : "schema-success"; - -if (!port || !token) throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN"); - -async function gatewayAgent(params) { - try { - return { - ok: true, - value: await callGateway({ - url: `ws://127.0.0.1:${port}`, - token, - method: "agent", - params, - expectFinal: true, - timeoutMs: 240_000, - clientName: "gateway-client", - mode: "backend", - scopes: ["operator.write"], - deviceIdentity: null, - }), - }; - } catch (error) { - const combined = String(error); - return { ok: false, error: new Error(combined) }; - } -} - -const result = await gatewayAgent({ - sessionKey, - message, - thinking: "minimal", - deliver: false, - timeout: 180, - idempotencyKey: id, -}); - -if (mode === "reject") { - console.error(result.ok ? JSON.stringify(result.value) : String(result.error)); - process.exit(0); -} -if (!result.ok) throw result.error; -if (result.value?.status !== "ok") { - throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`); -} -NODE - -PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" node /tmp/openclaw-openai-web-search-minimal-client.mjs success >/tmp/openclaw-openai-web-search-minimal-client-success.log 2>&1 - -node - "$MOCK_REQUEST_LOG" <<'NODE' -const fs = require("node:fs"); -const logPath = process.argv[2]; -const entries = fs.readFileSync(logPath, "utf8").trim().split(/\n+/).filter(Boolean).map((line) => JSON.parse(line)); -const responseEntries = entries.filter((entry) => entry.path === "/v1/responses"); -if (responseEntries.length < 1) { - throw new Error(`mock OpenAI /v1/responses was not used. Requests: ${JSON.stringify(entries)}`); -} -const success = responseEntries.find((entry) => JSON.stringify(entry.body).includes("OPENCLAW_SCHEMA_E2E_OK")); -if (!success) { - throw new Error(`missing success request. Requests: ${JSON.stringify(responseEntries)}`); -} -const tools = Array.isArray(success.body.tools) ? success.body.tools : []; -const hasWebSearch = tools.some((tool) => tool?.type === "web_search" || (tool?.type === "function" && (tool?.name === "web_search" || tool?.function?.name === "web_search"))); -if (!hasWebSearch) { - throw new Error(`success request did not include web_search. Body: ${JSON.stringify(success.body)}`); -} -if (success.body.reasoning?.effort === "minimal") { - throw new Error(`expected web_search request to avoid minimal reasoning, got ${JSON.stringify(success.body.reasoning)}`); -} -NODE - -PORT="$PORT" OPENCLAW_GATEWAY_TOKEN="$TOKEN" node /tmp/openclaw-openai-web-search-minimal-client.mjs reject >/tmp/openclaw-openai-web-search-minimal-client-reject.log 2>&1 - -for _ in $(seq 1 80); do - if grep -Fq "$RAW_SCHEMA_ERROR" "$GATEWAY_LOG"; then - break - fi - sleep 0.25 -done -grep -F "$RAW_SCHEMA_ERROR" "$GATEWAY_LOG" >/dev/null - -echo "OpenAI web_search minimal reasoning Docker E2E passed" -EOF + "$IMAGE_NAME" \ + bash scripts/e2e/lib/openai-web-search-minimal/scenario.sh diff --git a/scripts/e2e/openwebui-docker.sh b/scripts/e2e/openwebui-docker.sh index a271132ef30..2edf138dce4 100755 --- a/scripts/e2e/openwebui-docker.sh +++ b/scripts/e2e/openwebui-docker.sh @@ -49,7 +49,6 @@ cleanup() { trap cleanup EXIT docker_e2e_build_or_reuse "$IMAGE_NAME" openwebui -docker_e2e_harness_mount_args echo "Pulling Open WebUI image: $OPENWEBUI_IMAGE" timeout "$DOCKER_PULL_TIMEOUT" docker pull "$OPENWEBUI_IMAGE" >/dev/null @@ -59,7 +58,9 @@ docker_cmd docker network create "$NET_NAME" >/dev/null echo "Starting gateway container..." # Harness files are mounted read-only; the app under test comes from /app/dist. +docker_e2e_harness_mount_args docker_cmd docker run -d \ + "${DOCKER_E2E_HARNESS_ARGS[@]}" \ --name "$GW_NAME" \ --network "$NET_NAME" \ -e "OPENCLAW_GATEWAY_TOKEN=$TOKEN" \ @@ -70,7 +71,6 @@ docker_cmd docker run -d \ -e "OPENCLAW_SKIP_CANVAS_HOST=1" \ -e OPENAI_API_KEY \ ${OPENAI_BASE_URL_VALUE:+-e OPENAI_BASE_URL} \ - "${DOCKER_E2E_HARNESS_ARGS[@]}" \ "$IMAGE_NAME" \ bash -lc ' set -euo pipefail diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index e482bef6afd..b81a914a53b 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -28,962 +28,6 @@ for env_name in \ done echo "Running plugins Docker E2E..." -RUN_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-plugins-run.XXXXXX")" -if ! docker run --rm "${DOCKER_ENV_ARGS[@]}" -i "$IMAGE_NAME" bash -s >"$RUN_LOG" 2>&1 <<'EOF' -set -euo pipefail - -if [ -f dist/index.mjs ]; then - OPENCLAW_ENTRY="dist/index.mjs" -elif [ -f dist/index.js ]; then - OPENCLAW_ENTRY="dist/index.js" -else - echo "Missing dist/index.(m)js (build output):" - ls -la dist || true - exit 1 -fi -export OPENCLAW_ENTRY -PACKAGE_VERSION="$(node -p 'require("./package.json").version')" -OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT="$( - node - "$PACKAGE_VERSION" <<'NODE' -const version = process.argv[2] || ""; -const match = /^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:[-+].*)?$/.exec(version); -if (!match) { - console.log("0"); - process.exit(0); -} -const value = [Number(match[1]), Number(match[2]), Number(match[3])]; -const max = [2026, 4, 25]; -for (let i = 0; i < value.length; i += 1) { - if (value[i] < max[i]) { - console.log("1"); - process.exit(0); - } - if (value[i] > max[i]) { - console.log("0"); - process.exit(0); - } -} -console.log("1"); -NODE -)" -export OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT - -eval "$(printf "%s" "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}" | base64 -d)" -BUNDLED_PLUGIN_ROOT_DIR="extensions" -OPENCLAW_PLUGIN_HOME="$HOME/.openclaw/$BUNDLED_PLUGIN_ROOT_DIR" - -record_fixture_plugin_trust() { - local plugin_id="$1" - local plugin_root="$2" - local enabled="$3" - node - <<'NODE' "$plugin_id" "$plugin_root" "$enabled" -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.argv[2]; -const pluginRoot = process.argv[3]; -const enabled = process.argv[4] === "1"; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, "utf8")) - : {}; -const plugins = (config.plugins ??= {}); -const entries = (plugins.entries ??= {}); -entries[pluginId] = { ...(entries[pluginId] ?? {}), enabled }; -delete plugins.installs; -plugins.allow = Array.from(new Set([...(plugins.allow ?? []), pluginId])).sort(); -fs.mkdirSync(path.dirname(configPath), { recursive: true }); -fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - -const ledgerPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const ledger = fs.existsSync(ledgerPath) - ? JSON.parse(fs.readFileSync(ledgerPath, "utf8")) - : { - version: 1, - warning: - "DO NOT EDIT. This file is generated by OpenClaw plugin install/update/uninstall commands. Use `openclaw plugins install/update/uninstall` instead.", - records: {}, - }; -ledger.updatedAtMs = Date.now(); -ledger.records ??= {}; -ledger.records[pluginId] = { - ...(ledger.records[pluginId] ?? {}), - source: "path", - installPath: pluginRoot, - sourcePath: pluginRoot, -}; -fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); -fs.writeFileSync(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, "utf8"); -NODE -} - -run_logged() { - local label="$1" - shift - local log_file="/tmp/openclaw-plugins-e2e-${label}.log" - if ! "$@" >"$log_file" 2>&1; then - cat "$log_file" - exit 1 - fi -} - -write_fixture_plugin() { - local dir="$1" - local id="$2" - local version="$3" - local method="$4" - local name="$5" - - mkdir -p "$dir" - cat > "$dir/package.json" < "$dir/index.js" < ({ ok: true })); - }, -}; -JS - cat > "$dir/openclaw.plugin.json" <<'JSON' -{ - "id": "placeholder", - "configSchema": { - "type": "object", - "properties": {} - } -} -JSON - node - <<'NODE' "$dir/openclaw.plugin.json" "$id" -const fs = require("node:fs"); -const file = process.argv[2]; -const id = process.argv[3]; -const parsed = JSON.parse(fs.readFileSync(file, "utf8")); -parsed.id = id; -fs.writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`); -NODE -} - -demo_plugin_id="demo-plugin" -demo_plugin_root="$OPENCLAW_PLUGIN_HOME/$demo_plugin_id" -mkdir -p "$demo_plugin_root" - -cat > "$demo_plugin_root/index.js" <<'JS' -module.exports = { - id: "demo-plugin", - name: "Demo Plugin", - description: "Docker E2E demo plugin", - register(api) { - api.registerTool(() => null, { name: "demo_tool" }); - api.registerGatewayMethod("demo.ping", async () => ({ ok: true })); - api.registerCli(() => {}, { commands: ["demo"] }); - api.registerService({ id: "demo-service", start: () => {} }); - }, -}; -JS -cat > "$demo_plugin_root/openclaw.plugin.json" <<'JSON' -{ - "id": "demo-plugin", - "configSchema": { - "type": "object", - "properties": {} - } -} -JSON -record_fixture_plugin_trust "$demo_plugin_id" "$demo_plugin_root" 1 - -node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json -node "$OPENCLAW_ENTRY" plugins inspect demo-plugin --json > /tmp/plugins-inspect.json - -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin"); -if (!plugin) throw new Error("plugin not found"); -if (plugin.status !== "loaded") { - throw new Error(`unexpected status: ${plugin.status}`); -} - -const assertIncludes = (list, value, label) => { - if (!Array.isArray(list) || !list.includes(value)) { - throw new Error(`${label} missing: ${value}`); - } -}; - -const inspectToolNames = Array.isArray(inspect.tools) - ? inspect.tools.flatMap((entry) => (Array.isArray(entry?.names) ? entry.names : [])) - : []; -assertIncludes(inspectToolNames, "demo_tool", "tool"); -assertIncludes(inspect.gatewayMethods, "demo.ping", "gateway method"); -assertIncludes(inspect.cliCommands, "demo", "cli command"); -assertIncludes(inspect.services, "demo-service", "service"); - -const diagErrors = (data.diagnostics || []).filter((diag) => diag.level === "error"); -if (diagErrors.length > 0) { - throw new Error(`diagnostics errors: ${diagErrors.map((diag) => diag.message).join("; ")}`); -} - -console.log("ok"); -NODE - -echo "Testing tgz install flow..." -pack_dir="$(mktemp -d "/tmp/openclaw-plugin-pack.XXXXXX")" -mkdir -p "$pack_dir/package" -cat > "$pack_dir/package/package.json" <<'JSON' -{ - "name": "@openclaw/demo-plugin-tgz", - "version": "0.0.1", - "openclaw": { "extensions": ["./index.js"] } -} -JSON -cat > "$pack_dir/package/index.js" <<'JS' -module.exports = { - id: "demo-plugin-tgz", - name: "Demo Plugin TGZ", - register(api) { - api.registerGatewayMethod("demo.tgz", async () => ({ ok: true })); - }, -}; -JS -cat > "$pack_dir/package/openclaw.plugin.json" <<'JSON' -{ - "id": "demo-plugin-tgz", - "configSchema": { - "type": "object", - "properties": {} - } -} -JSON -tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package - -run_logged install-tgz node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz -node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json -node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-tgz --json > /tmp/plugins2-inspect.json - -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins2.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins2-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-tgz"); -if (!plugin) throw new Error("tgz plugin not found"); -if (plugin.status !== "loaded") { - throw new Error(`unexpected status: ${plugin.status}`); -} -if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes("demo.tgz")) { - throw new Error("expected gateway method demo.tgz"); -} -console.log("ok"); -NODE - -echo "Testing install from local folder (plugins.load.paths)..." -dir_plugin="$(mktemp -d "/tmp/openclaw-plugin-dir.XXXXXX")" -cat > "$dir_plugin/package.json" <<'JSON' -{ - "name": "@openclaw/demo-plugin-dir", - "version": "0.0.1", - "openclaw": { "extensions": ["./index.js"] } -} -JSON -cat > "$dir_plugin/index.js" <<'JS' -module.exports = { - id: "demo-plugin-dir", - name: "Demo Plugin DIR", - register(api) { - api.registerGatewayMethod("demo.dir", async () => ({ ok: true })); - }, -}; -JS -cat > "$dir_plugin/openclaw.plugin.json" <<'JSON' -{ - "id": "demo-plugin-dir", - "configSchema": { - "type": "object", - "properties": {} - } -} -JSON - -run_logged install-dir node "$OPENCLAW_ENTRY" plugins install "$dir_plugin" -node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json -node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-dir --json > /tmp/plugins3-inspect.json - -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins3.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins3-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-dir"); -if (!plugin) throw new Error("dir plugin not found"); -if (plugin.status !== "loaded") { - throw new Error(`unexpected status: ${plugin.status}`); -} -if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes("demo.dir")) { - throw new Error("expected gateway method demo.dir"); -} -console.log("ok"); -NODE - -echo "Testing install from npm spec (file:)..." -file_pack_dir="$(mktemp -d "/tmp/openclaw-plugin-filepack.XXXXXX")" -mkdir -p "$file_pack_dir/package" -cat > "$file_pack_dir/package/package.json" <<'JSON' -{ - "name": "@openclaw/demo-plugin-file", - "version": "0.0.1", - "openclaw": { "extensions": ["./index.js"] } -} -JSON -cat > "$file_pack_dir/package/index.js" <<'JS' -module.exports = { - id: "demo-plugin-file", - name: "Demo Plugin FILE", - register(api) { - api.registerGatewayMethod("demo.file", async () => ({ ok: true })); - }, -}; -JS -cat > "$file_pack_dir/package/openclaw.plugin.json" <<'JSON' -{ - "id": "demo-plugin-file", - "configSchema": { - "type": "object", - "properties": {} - } -} -JSON - -run_logged install-file node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package" -node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json -node "$OPENCLAW_ENTRY" plugins inspect demo-plugin-file --json > /tmp/plugins4-inspect.json - -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins4.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins4-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "demo-plugin-file"); -if (!plugin) throw new Error("file plugin not found"); -if (plugin.status !== "loaded") { - throw new Error(`unexpected status: ${plugin.status}`); -} -if (!Array.isArray(inspect.gatewayMethods) || !inspect.gatewayMethods.includes("demo.file")) { - throw new Error("expected gateway method demo.file"); -} -console.log("ok"); -NODE - -echo "Testing Claude bundle enable and inspect flow..." -bundle_plugin_id="claude-bundle-e2e" -bundle_root="$OPENCLAW_PLUGIN_HOME/$bundle_plugin_id" -mkdir -p "$bundle_root/.claude-plugin" "$bundle_root/commands" -cat > "$bundle_root/.claude-plugin/plugin.json" <<'JSON' -{ - "name": "claude-bundle-e2e" -} -JSON -cat > "$bundle_root/commands/office-hours.md" <<'MD' ---- -description: Help with architecture and rollout planning ---- -Act as an engineering advisor. - -Focus on: -$ARGUMENTS -MD -record_fixture_plugin_trust "$bundle_plugin_id" "$bundle_root" 0 - -node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-bundle-disabled.json -node - <<'NODE' -const fs = require("node:fs"); -const data = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-disabled.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "claude-bundle-e2e"); -if (!plugin) throw new Error("Claude bundle plugin not found"); -if (plugin.status !== "disabled") { - throw new Error(`expected disabled bundle before enable, got ${plugin.status}`); -} -console.log("ok"); -NODE - -run_logged enable-claude-bundle node "$OPENCLAW_ENTRY" plugins enable claude-bundle-e2e -node "$OPENCLAW_ENTRY" plugins inspect claude-bundle-e2e --json > /tmp/plugins-bundle-inspect.json -node - <<'NODE' -const fs = require("node:fs"); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-bundle-inspect.json", "utf8")); -if (inspect.plugin?.bundleFormat !== "claude") { - throw new Error(`expected Claude bundle format, got ${inspect.plugin?.bundleFormat}`); -} -if (inspect.plugin?.enabled !== true || inspect.plugin?.status !== "loaded") { - throw new Error( - `expected enabled loaded Claude bundle, got enabled=${inspect.plugin?.enabled} status=${inspect.plugin?.status}`, - ); -} -console.log("ok"); -NODE - -echo "Testing plugin install visible after explicit restart..." -slash_install_dir="$(mktemp -d "/tmp/openclaw-plugin-slash-install.XXXXXX")" -cat > "$slash_install_dir/package.json" <<'JSON' -{ - "name": "@openclaw/slash-install-plugin", - "version": "0.0.1", - "openclaw": { "extensions": ["./index.js"] } -} -JSON -cat > "$slash_install_dir/index.js" <<'JS' -module.exports = { - id: "slash-install-plugin", - name: "Slash Install Plugin", - register(api) { - api.registerGatewayMethod("demo.slash.install", async () => ({ ok: true })); - }, -}; -JS -cat > "$slash_install_dir/openclaw.plugin.json" <<'JSON' -{ - "id": "slash-install-plugin", - "configSchema": { - "type": "object", - "properties": {} - } -} -JSON - -run_logged install-slash-plugin node "$OPENCLAW_ENTRY" plugins install "$slash_install_dir" -node "$OPENCLAW_ENTRY" plugins inspect slash-install-plugin --json > /tmp/plugin-command-install-show.json -node - <<'NODE' -const fs = require("node:fs"); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugin-command-install-show.json", "utf8")); -if (inspect.plugin?.status !== "loaded") { - throw new Error(`expected loaded status after install, got ${inspect.plugin?.status}`); -} -if (inspect.plugin?.enabled !== true) { - throw new Error(`expected enabled status after install, got ${inspect.plugin?.enabled}`); -} -if (!inspect.gatewayMethods.includes("demo.slash.install")) { - throw new Error(`expected installed gateway method, got ${inspect.gatewayMethods.join(", ")}`); -} -console.log("ok"); -NODE - -echo "Testing marketplace install and update flows..." -marketplace_root="$HOME/.claude/plugins/marketplaces/fixture-marketplace" -mkdir -p "$HOME/.claude/plugins" "$marketplace_root/.claude-plugin" -write_fixture_plugin \ - "$marketplace_root/plugins/marketplace-shortcut" \ - "marketplace-shortcut" \ - "0.0.1" \ - "demo.marketplace.shortcut.v1" \ - "Marketplace Shortcut" -write_fixture_plugin \ - "$marketplace_root/plugins/marketplace-direct" \ - "marketplace-direct" \ - "0.0.1" \ - "demo.marketplace.direct.v1" \ - "Marketplace Direct" -cat > "$marketplace_root/.claude-plugin/marketplace.json" <<'JSON' -{ - "name": "Fixture Marketplace", - "version": "1.0.0", - "plugins": [ - { - "name": "marketplace-shortcut", - "version": "0.0.1", - "description": "Shortcut install fixture", - "source": "./plugins/marketplace-shortcut" - }, - { - "name": "marketplace-direct", - "version": "0.0.1", - "description": "Explicit marketplace fixture", - "source": { - "type": "path", - "path": "./plugins/marketplace-direct" - } - } - ] -} -JSON -cat > "$HOME/.claude/plugins/known_marketplaces.json" < /tmp/marketplace-list.json - -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/marketplace-list.json", "utf8")); -const names = (data.plugins || []).map((entry) => entry.name).sort(); -if (data.name !== "Fixture Marketplace") { - throw new Error(`unexpected marketplace name: ${data.name}`); -} -if (!names.includes("marketplace-shortcut") || !names.includes("marketplace-direct")) { - throw new Error(`unexpected marketplace plugins: ${names.join(", ")}`); -} -console.log("ok"); -NODE - -run_logged install-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins install marketplace-shortcut@claude-fixtures -run_logged install-marketplace-direct node "$OPENCLAW_ENTRY" plugins install marketplace-direct --marketplace claude-fixtures -node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace.json -node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json > /tmp/plugins-marketplace-shortcut-inspect.json -node "$OPENCLAW_ENTRY" plugins inspect marketplace-direct --json > /tmp/plugins-marketplace-direct-inspect.json - -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace.json", "utf8")); -const shortcutInspect = JSON.parse( - fs.readFileSync("/tmp/plugins-marketplace-shortcut-inspect.json", "utf8"), -); -const directInspect = JSON.parse( - fs.readFileSync("/tmp/plugins-marketplace-direct-inspect.json", "utf8"), -); -const getPlugin = (id) => { - const plugin = (data.plugins || []).find((entry) => entry.id === id); - if (!plugin) throw new Error(`plugin not found: ${id}`); - if (plugin.status !== "loaded") { - throw new Error(`unexpected status for ${id}: ${plugin.status}`); - } - return plugin; -}; - -const shortcut = getPlugin("marketplace-shortcut"); -const direct = getPlugin("marketplace-direct"); -if (shortcut.version !== "0.0.1") { - throw new Error(`unexpected shortcut version: ${shortcut.version}`); -} -if (direct.version !== "0.0.1") { - throw new Error(`unexpected direct version: ${direct.version}`); -} -if (!shortcutInspect.gatewayMethods.includes("demo.marketplace.shortcut.v1")) { - throw new Error("expected marketplace shortcut gateway method"); -} -if (!directInspect.gatewayMethods.includes("demo.marketplace.direct.v1")) { - throw new Error("expected marketplace direct gateway method"); -} -console.log("ok"); -NODE - -node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; -if (!allowLegacyCompat && !index.installRecords) { - throw new Error("expected modern installRecords in installed plugin index"); -} -const installRecords = allowLegacyCompat - ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} - : index.installRecords ?? {}; -for (const id of ["marketplace-shortcut", "marketplace-direct"]) { - const record = installRecords[id]; - if (!record) { - if (allowLegacyCompat) { - console.log(`legacy package did not persist marketplace install record for ${id}`); - continue; - } - throw new Error(`missing marketplace install record for ${id}`); - } - if (record.source !== "marketplace") { - throw new Error(`unexpected source for ${id}: ${record.source}`); - } - if (record.marketplaceSource !== "claude-fixtures") { - throw new Error(`unexpected marketplace source for ${id}: ${record.marketplaceSource}`); - } - if (record.marketplacePlugin !== id) { - throw new Error(`unexpected marketplace plugin for ${id}: ${record.marketplacePlugin}`); - } -} -console.log("ok"); -NODE - -write_fixture_plugin \ - "$marketplace_root/plugins/marketplace-shortcut" \ - "marketplace-shortcut" \ - "0.0.2" \ - "demo.marketplace.shortcut.v2" \ - "Marketplace Shortcut" -run_logged update-marketplace-shortcut-dry-run node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut --dry-run -run_logged update-marketplace-shortcut node "$OPENCLAW_ENTRY" plugins update marketplace-shortcut -node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-marketplace-updated.json -node "$OPENCLAW_ENTRY" plugins inspect marketplace-shortcut --json > /tmp/plugins-marketplace-updated-inspect.json - -node - <<'NODE' -const fs = require("node:fs"); - -const data = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-marketplace-updated-inspect.json", "utf8")); -const plugin = (data.plugins || []).find((entry) => entry.id === "marketplace-shortcut"); -if (!plugin) throw new Error("updated marketplace plugin not found"); -if (plugin.version !== "0.0.2") { - throw new Error(`unexpected updated version: ${plugin.version}`); -} -if (!inspect.gatewayMethods.includes("demo.marketplace.shortcut.v2")) { - throw new Error(`expected updated gateway method, got ${inspect.gatewayMethods.join(", ")}`); -} -console.log("ok"); -NODE - -if [ "${OPENCLAW_PLUGINS_E2E_CLAWHUB:-1}" = "0" ]; then - echo "Skipping ClawHub plugin install and uninstall (OPENCLAW_PLUGINS_E2E_CLAWHUB=0)." -else -echo "Testing ClawHub kitchen-sink plugin install and uninstall..." -CLAWHUB_PLUGIN_SPEC="${OPENCLAW_PLUGINS_E2E_CLAWHUB_SPEC:-clawhub:openclaw-kitchen-sink}" -CLAWHUB_PLUGIN_ID="${OPENCLAW_PLUGINS_E2E_CLAWHUB_ID:-openclaw-kitchen-sink-fixture}" -export CLAWHUB_PLUGIN_SPEC CLAWHUB_PLUGIN_ID - -start_clawhub_fixture_server() { - local fixture_dir="$1" - local server_log="$fixture_dir/clawhub-fixture.log" - local server_port_file="$fixture_dir/clawhub-fixture-port" - local server_pid_file="$fixture_dir/clawhub-fixture-pid" - - node - <<'NODE' "$server_port_file" >"$server_log" 2>&1 & -const crypto = require("node:crypto"); -const http = require("node:http"); -const path = require("node:path"); -const { createRequire } = require("node:module"); - -const portFile = process.argv[2]; -const requireFromApp = createRequire(path.join(process.cwd(), "package.json")); -const JSZip = requireFromApp("jszip"); -const packageName = "openclaw-kitchen-sink"; -const pluginId = "openclaw-kitchen-sink-fixture"; -const version = "0.1.0"; - -async function main() { - const zip = new JSZip(); - zip.file( - "package/package.json", - `${JSON.stringify( - { - name: packageName, - version, - openclaw: { extensions: ["./index.js"] }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - zip.file( - "package/index.js", - `module.exports = { - id: "${pluginId}", - name: "OpenClaw Kitchen Sink", - description: "Docker E2E kitchen-sink plugin fixture", - register(api) { - api.on("before_agent_start", async (event, context) => ({ - kitchenSink: true, - observedEventKeys: Object.keys(event || {}), - observedContextKeys: Object.keys(context || {}), - })); - api.registerTool(() => null, { name: "kitchen_sink_tool" }); - api.registerGatewayMethod("kitchen-sink.ping", async () => ({ ok: true })); - api.registerCli(() => {}, { commands: ["kitchen-sink"] }); - api.registerService({ id: "kitchen-sink-service", start: () => {} }); - }, -}; -`, - { date: new Date(0) }, - ); - zip.file( - "package/openclaw.plugin.json", - `${JSON.stringify( - { - id: pluginId, - configSchema: { - type: "object", - properties: {}, - }, - }, - null, - 2, - )}\n`, - { date: new Date(0) }, - ); - - const archive = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }); - const sha256hash = crypto.createHash("sha256").update(archive).digest("hex"); - - const json = (response, value) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end(`${JSON.stringify(value)}\n`); - }; - - const server = http.createServer((request, response) => { - const url = new URL(request.url, "http://127.0.0.1"); - if (request.method !== "GET") { - response.writeHead(405); - response.end("method not allowed"); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}`) { - json(response, { - package: { - name: packageName, - displayName: "OpenClaw Kitchen Sink", - family: "code-plugin", - channel: "official", - isOfficial: true, - runtimeId: pluginId, - latestVersion: version, - createdAt: 0, - updatedAt: 0, - compatibility: { - pluginApiRange: ">=2026.4.26", - minGatewayVersion: "2026.4.26", - }, - }, - }); - return; - } - if ( - url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/versions/${version}` - ) { - json(response, { - version: { - version, - createdAt: 0, - changelog: "Kitchen-sink fixture package for Docker plugin E2E.", - sha256hash, - compatibility: { - pluginApiRange: ">=2026.4.26", - minGatewayVersion: "2026.4.26", - }, - }, - }); - return; - } - if (url.pathname === `/api/v1/packages/${encodeURIComponent(packageName)}/download`) { - response.writeHead(200, { - "content-type": "application/zip", - "content-length": String(archive.length), - }); - response.end(archive); - return; - } - response.writeHead(404, { "content-type": "text/plain" }); - response.end(`not found: ${url.pathname}`); - }); - - server.listen(0, "127.0.0.1", () => { - require("node:fs").writeFileSync(portFile, String(server.address().port)); - }); -} - -main().catch((error) => { - console.error(error); - process.exit(1); -}); -NODE - local server_pid="$!" - echo "$server_pid" > "$server_pid_file" - - for _ in $(seq 1 100); do - if [[ -s "$server_port_file" ]]; then - export OPENCLAW_CLAWHUB_URL="http://127.0.0.1:$(cat "$server_port_file")" - trap 'if [[ -f "'"$server_pid_file"'" ]]; then kill "$(cat "'"$server_pid_file"'")" 2>/dev/null || true; fi' EXIT - return 0 - fi - if ! kill -0 "$server_pid" 2>/dev/null; then - cat "$server_log" - return 1 - fi - sleep 0.1 - done - - cat "$server_log" - echo "Timed out waiting for ClawHub fixture server." >&2 - return 1 -} - -if [[ -z "${OPENCLAW_CLAWHUB_URL:-}" && -z "${CLAWHUB_URL:-}" ]]; then - # Keep the release-path smoke hermetic; live ClawHub can rate-limit CI. - clawhub_fixture_dir="$(mktemp -d "/tmp/openclaw-clawhub-fixture.XXXXXX")" - start_clawhub_fixture_server "$clawhub_fixture_dir" -fi - -node - <<'NODE' -const spec = process.env.CLAWHUB_PLUGIN_SPEC; -if (!spec?.startsWith("clawhub:")) { - throw new Error(`expected clawhub: spec, got ${spec}`); -} - -const parsePackageName = (rawSpec) => { - const value = rawSpec.slice("clawhub:".length).trim(); - const slashIndex = value.lastIndexOf("/"); - const atIndex = value.lastIndexOf("@"); - return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; -}; - -const packageName = parsePackageName(spec); -const baseUrl = (process.env.OPENCLAW_CLAWHUB_URL || process.env.CLAWHUB_URL || "https://clawhub.ai") - .replace(/\/+$/, ""); -const token = - process.env.OPENCLAW_CLAWHUB_TOKEN || - process.env.CLAWHUB_TOKEN || - process.env.CLAWHUB_AUTH_TOKEN || - ""; -const response = await fetch(`${baseUrl}/api/v1/packages/${encodeURIComponent(packageName)}`, { - headers: token ? { Authorization: `Bearer ${token}` } : undefined, -}); -if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new Error(`ClawHub package preflight failed for ${packageName}: ${response.status} ${body}`); -} -const detail = await response.json(); -const family = detail.package?.family; -if (family !== "code-plugin" && family !== "bundle-plugin") { - throw new Error(`ClawHub package ${packageName} is not installable as a plugin: ${family}`); -} -if (detail.package?.runtimeId && detail.package.runtimeId !== process.env.CLAWHUB_PLUGIN_ID) { - throw new Error( - `ClawHub package ${packageName} runtimeId ${detail.package.runtimeId} does not match expected ${process.env.CLAWHUB_PLUGIN_ID}`, - ); -} -console.log(`Using ClawHub package ${packageName} (${family}).`); -NODE - -run_logged install-clawhub node "$OPENCLAW_ENTRY" plugins install "$CLAWHUB_PLUGIN_SPEC" -node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-clawhub-installed.json -node "$OPENCLAW_ENTRY" plugins inspect "$CLAWHUB_PLUGIN_ID" --json > /tmp/plugins-clawhub-inspect.json - -node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.env.CLAWHUB_PLUGIN_ID; -const spec = process.env.CLAWHUB_PLUGIN_SPEC; -const parsePackageName = (rawSpec) => { - const value = rawSpec.slice("clawhub:".length).trim(); - const slashIndex = value.lastIndexOf("/"); - const atIndex = value.lastIndexOf("@"); - return atIndex > 0 && atIndex > slashIndex ? value.slice(0, atIndex) : value; -}; -const packageName = parsePackageName(spec); -const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-installed.json", "utf8")); -const inspect = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-inspect.json", "utf8")); -const plugin = (list.plugins || []).find((entry) => entry.id === pluginId); -if (!plugin) throw new Error(`ClawHub plugin not found after install: ${pluginId}`); -if (plugin.status !== "loaded") { - throw new Error(`unexpected ClawHub plugin status for ${pluginId}: ${plugin.status}`); -} -if (inspect.plugin?.id !== pluginId) { - throw new Error(`unexpected ClawHub inspect plugin id: ${inspect.plugin?.id}`); -} - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = JSON.parse(fs.readFileSync(indexPath, "utf8")); -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const allowLegacyCompat = process.env.OPENCLAW_PACKAGE_ACCEPTANCE_LEGACY_COMPAT === "1"; -if (!allowLegacyCompat && !index.installRecords) { - throw new Error("expected modern installRecords in installed plugin index"); -} -const installRecords = allowLegacyCompat - ? index.installRecords ?? index.records ?? config.plugins?.installs ?? {} - : index.installRecords ?? {}; -const record = installRecords[pluginId]; -if (!record) throw new Error(`missing ClawHub install record for ${pluginId}`); -if (record.source !== "clawhub") { - throw new Error(`unexpected ClawHub install source for ${pluginId}: ${record.source}`); -} -if (record.clawhubPackage !== packageName) { - throw new Error( - `unexpected ClawHub package for ${pluginId}: ${record.clawhubPackage}, expected ${packageName}`, - ); -} -if (record.clawhubFamily !== "code-plugin" && record.clawhubFamily !== "bundle-plugin") { - throw new Error(`unexpected ClawHub family for ${pluginId}: ${record.clawhubFamily}`); -} -if (typeof record.installPath !== "string" || record.installPath.length === 0) { - throw new Error(`missing ClawHub install path for ${pluginId}`); -} - -const installPath = record.installPath.replace(/^~(?=$|\/)/, process.env.HOME); -const extensionsRoot = path.join(process.env.HOME, ".openclaw", "extensions"); -if (!installPath.startsWith(`${extensionsRoot}${path.sep}`)) { - throw new Error(`ClawHub install path is outside managed extensions root: ${installPath}`); -} -if (!fs.existsSync(installPath)) { - throw new Error(`ClawHub install path missing on disk: ${installPath}`); -} -fs.writeFileSync("/tmp/plugins-clawhub-install-path.txt", installPath, "utf8"); -console.log("ok"); -NODE - -run_logged uninstall-clawhub node "$OPENCLAW_ENTRY" plugins uninstall "$CLAWHUB_PLUGIN_SPEC" --force -node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins-clawhub-uninstalled.json - -node - <<'NODE' -const fs = require("node:fs"); -const path = require("node:path"); - -const pluginId = process.env.CLAWHUB_PLUGIN_ID; -const installPath = fs.readFileSync("/tmp/plugins-clawhub-install-path.txt", "utf8").trim(); -const list = JSON.parse(fs.readFileSync("/tmp/plugins-clawhub-uninstalled.json", "utf8")); -if ((list.plugins || []).some((entry) => entry.id === pluginId)) { - throw new Error(`ClawHub plugin still listed after uninstall: ${pluginId}`); -} - -const indexPath = path.join(process.env.HOME, ".openclaw", "plugins", "installs.json"); -const index = fs.existsSync(indexPath) ? JSON.parse(fs.readFileSync(indexPath, "utf8")) : {}; -const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const config = fs.existsSync(configPath) ? JSON.parse(fs.readFileSync(configPath, "utf8")) : {}; -const installRecords = index.installRecords ?? index.records ?? config.plugins?.installs ?? {}; -if (installRecords[pluginId]) { - throw new Error(`ClawHub install record still present after uninstall: ${pluginId}`); -} - -const configAfterUninstallPath = path.join(process.env.HOME, ".openclaw", "openclaw.json"); -const configAfterUninstall = fs.existsSync(configAfterUninstallPath) - ? JSON.parse(fs.readFileSync(configAfterUninstallPath, "utf8")) - : {}; -if (configAfterUninstall.plugins?.entries?.[pluginId]) { - throw new Error(`ClawHub config entry still present after uninstall: ${pluginId}`); -} -if ((configAfterUninstall.plugins?.allow || []).includes(pluginId)) { - throw new Error(`ClawHub allowlist entry still present after uninstall: ${pluginId}`); -} -if ((configAfterUninstall.plugins?.deny || []).includes(pluginId)) { - throw new Error(`ClawHub denylist entry still present after uninstall: ${pluginId}`); -} -if (fs.existsSync(installPath)) { - throw new Error(`ClawHub managed install directory still exists after uninstall: ${installPath}`); -} -console.log("ok"); -NODE -fi - -EOF -then - cat "$RUN_LOG" - rm -f "$RUN_LOG" - exit 1 -fi -rm -f "$RUN_LOG" +docker_e2e_run_logged_with_harness plugins-run "${DOCKER_ENV_ARGS[@]}" "$IMAGE_NAME" bash scripts/e2e/lib/plugins/sweep.sh echo "OK" diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index c899462bb72..1267fac6b11 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -67,3 +67,14 @@ docker_e2e_run_with_harness() { docker_e2e_harness_mount_args docker run --rm "${DOCKER_E2E_HARNESS_ARGS[@]}" "$@" } + +docker_e2e_run_detached_with_harness() { + docker_e2e_harness_mount_args + docker run -d "${DOCKER_E2E_HARNESS_ARGS[@]}" "$@" +} + +docker_e2e_run_logged_with_harness() { + local label="$1" + shift + run_logged "$label" docker_e2e_run_with_harness "$@" +}