refactor: simplify docker e2e harness scripts

This commit is contained in:
Peter Steinberger
2026-04-29 08:45:36 +01:00
parent 2b811fe6d9
commit 8ac2dd4cd2
27 changed files with 1763 additions and 1801 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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" <<WRAPPER
#!/usr/bin/env bash
set -euo pipefail
printf "%s\n" "\$@" >> "$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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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" <<WRAPPER
#!/usr/bin/env bash
set -euo pipefail
printf "%s\n" "\$@" >> "$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

View File

@@ -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)}`);
}

View File

@@ -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}`);
});

View File

@@ -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" <<JSON
{
"agents": {
"defaults": {
"model": { "primary": "openai/gpt-5" },
"models": {
"openai/gpt-5": {
"params": {
"transport": "sse",
"openaiWsWarmup": false
}
}
}
}
},
"models": {
"providers": {
"openai": {
"api": "openai-responses",
"baseUrl": "http://api.openai.com/v1",
"apiKey": { "source": "env", "provider": "default", "id": "OPENAI_API_KEY" },
"request": { "allowPrivateNetwork": true },
"models": [
{
"id": "gpt-5",
"name": "gpt-5",
"api": "openai-responses",
"reasoning": true,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 128000,
"contextTokens": 96000,
"maxTokens": 4096
}
]
}
}
},
"tools": {
"web": {
"search": {
"enabled": true,
"maxResults": 3
}
}
},
"plugins": {
"enabled": true,
"allow": ["openai"],
"entries": {
"openai": { "enabled": true }
}
},
"gateway": {
"auth": {
"mode": "token",
"token": "${TOKEN}"
}
}
}
JSON
MOCK_PORT="$MOCK_PORT" \
MOCK_REQUEST_LOG="$MOCK_REQUEST_LOG" \
SUCCESS_MARKER="$SUCCESS_MARKER" \
RAW_SCHEMA_ERROR="$RAW_SCHEMA_ERROR" \
node scripts/e2e/lib/openai-web-search-minimal/mock-server.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
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"

View File

@@ -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");
}

View File

@@ -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)),

View File

@@ -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" <<JSON
{
"name": "@openclaw/$id",
"version": "$version",
"openclaw": { "extensions": ["./index.js"] }
}
JSON
cat >"$dir/index.js" <<JS
module.exports = {
id: "$id",
name: "$name",
register(api) {
api.registerGatewayMethod("$method", async () => ({ 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" <<JSON
{
"claude-fixtures": {
"installLocation": "$marketplace_root",
"source": {
"type": "github",
"repo": "openclaw/fixture-marketplace"
}
}
}
JSON
node "$OPENCLAW_ENTRY" plugins marketplace list claude-fixtures --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

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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)"

View File

@@ -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" <<JSON
{
"agents": {
"defaults": {
"model": { "primary": "openai/gpt-5" },
"models": {
"openai/gpt-5": {
"params": {
"transport": "sse",
"openaiWsWarmup": false
}
}
}
}
},
"models": {
"providers": {
"openai": {
"api": "openai-responses",
"baseUrl": "http://api.openai.com/v1",
"apiKey": { "source": "env", "provider": "default", "id": "OPENAI_API_KEY" },
"request": { "allowPrivateNetwork": true },
"models": [
{
"id": "gpt-5",
"name": "gpt-5",
"api": "openai-responses",
"reasoning": true,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 128000,
"contextTokens": 96000,
"maxTokens": 4096
}
]
}
}
},
"tools": {
"web": {
"search": {
"enabled": true,
"maxResults": 3
}
}
},
"plugins": {
"enabled": true,
"allow": ["openai"],
"entries": {
"openai": { "enabled": true }
}
},
"gateway": {
"auth": {
"mode": "token",
"token": "${TOKEN}"
}
}
}
JSON
cat >/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

View File

@@ -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

View File

@@ -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" <<JSON
{
"name": "@openclaw/$id",
"version": "$version",
"openclaw": { "extensions": ["./index.js"] }
}
JSON
cat > "$dir/index.js" <<JS
module.exports = {
id: "$id",
name: "$name",
register(api) {
api.registerGatewayMethod("$method", async () => ({ 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" <<JSON
{
"claude-fixtures": {
"installLocation": "$marketplace_root",
"source": {
"type": "github",
"repo": "openclaw/fixture-marketplace"
}
}
}
JSON
node "$OPENCLAW_ENTRY" plugins marketplace list claude-fixtures --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"

View File

@@ -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 "$@"
}